diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1f9104c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,97 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run content:check + - run: npm run schemas:check + - run: npm run typecheck + - run: npm test + - run: npm run lint + - run: npm run test:renderer + - run: npm run build + - run: npm run quality:sizes -- --frontend-only + - run: npm run quality:licenses + - run: npm audit + - uses: actions/upload-artifact@v4 + with: + name: frontend-quality-reports + path: | + artifacts/quality/size-report-frontend.json + artifacts/quality/dependency-license-census.md + + rust: + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: src-tauri + steps: + - uses: actions/checkout@v4 + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + working-directory: . + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - run: cargo fmt --all -- --check + - run: cargo check --all-targets + - run: cargo clippy --all-targets -- -D warnings + - run: cargo test --all-targets + - uses: actions/setup-node@v4 + if: runner.os == 'Windows' + with: + node-version: 22 + cache: npm + - name: Build production frontend for the Windows release binary + if: runner.os == 'Windows' + working-directory: . + run: | + npm ci + npm run build + - name: Build and report optimized Windows executable size + if: runner.os == 'Windows' + working-directory: . + run: | + cargo build --release --locked --manifest-path src-tauri/Cargo.toml + node scripts/report-build-size.mjs --binary-only + - uses: actions/upload-artifact@v4 + if: runner.os == 'Windows' + with: + name: windows-binary-size-report + path: artifacts/quality/size-report-windows-binary.json + + desktop-e2e: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - run: npm ci + - run: npm run test:desktop:build + - run: npm run test:desktop diff --git a/.gitignore b/.gitignore index a7204d9..84b413b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +artifacts/ src-tauri/target/ src-tauri/gen/schemas/ @@ -29,3 +30,6 @@ src-tauri/.cargo/ # Playwright MCP cache .playwright-mcp/ +# Reproducible local benchmark and fixture output +.tmp/ + diff --git a/BUILD.md b/BUILD.md index 73d297b..cd5961d 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,84 +1,120 @@ -# Building Sortilune +# Building Sortilune for Windows -Sortilune is a Tauri 2.x desktop app. The release output is a **single portable .exe** on Windows; .app/.dmg on macOS; .AppImage/.deb on Linux. No installer is required to run the .exe — copy it anywhere. +Sortilune’s supported release artifact is one portable Windows executable. The release gate in this repository does not certify macOS, Linux, MSI, or NSIS outputs. ## Prerequisites -| | Windows | macOS | Linux | -|---|---|---|---| -| Rust | 1.75+ via [rustup](https://rustup.rs) | same | same | -| Node | 18+ | same | same | -| C toolchain | [Microsoft C++ Build Tools](https://aka.ms/vs/17/release/vs_BuildTools.exe) | Xcode CLT (`xcode-select --install`) | `build-essential`, `libwebkit2gtk-4.1-dev`, `librsvg2-dev`, `libssl-dev` | -| WebView | **Microsoft WebView2** (preinstalled on Windows 10 1803+ / Windows 11) | WKWebView (system) | WebKitGTK (provided by libwebkit2gtk-4.1-dev) | +- Windows 10 version 1803 or newer, or Windows 11 +- Node.js 20.19+ or 22.12+ +- npm matching the selected Node installation +- stable Rust with the `x86_64-pc-windows-msvc` target +- Microsoft C++ Build Tools +- Microsoft WebView2 Runtime -## Install +The exact frontend and Rust dependency graphs are locked by `package-lock.json` and `src-tauri/Cargo.lock`. -```bash -cd sortilune # the project root containing package.json -npm install -``` - -## Develop +## Install and develop -```bash +```powershell +git clone https://github.com/aivrar/sortilune.git +Set-Location sortilune +npm ci npm run tauri:dev ``` -The dev server boots Vite on `localhost:1420`, then launches the Tauri window. Hot reload works for the JS/CSS frontend; Rust changes require a re-run. +Vite serves the renderer on `127.0.0.1:1420` during development. Rust or Tauri-configuration changes require the desktop process to restart. -## Build +## Build the portable executable -### Single portable .exe (the goal) - -```bash +```powershell npm run tauri:build:portable ``` -This invokes `tauri build --no-bundle`, which skips the .msi/.nsis bundlers and produces only the raw release binary: +This runs `tauri build --no-bundle` and produces: +```text +src-tauri\target\release\sortilune.exe ``` -src-tauri/target/release/sortilune.exe -``` - -That .exe is **portable**: copy it to any folder, USB stick, or another Windows machine and double-click. All frontend assets are embedded inside the binary via Tauri's `generate_context!` macro. -### What it depends on at runtime +All frontend assets are embedded. The executable can be copied to another folder or Windows machine, but the destination must have WebView2 Runtime. User data remains in `%APPDATA%\com.sortilune.desktop\`, beside neither the executable nor the source tree. -- **WebView2 Runtime** — preinstalled on Windows 10 1803+ and Windows 11. If a target machine somehow lacks it, double-clicking the .exe will trigger the Tauri bootstrapper which silently fetches it. If you need fully offline distribution, set `bundle.windows.webviewInstallMode.type = "embedBootstrapper"` in `src-tauri/tauri.conf.json` and the bootstrapper will be embedded into the bundle (the standalone .exe itself remains small). +To build and stage a versioned GitHub Release asset with its SHA-256 checksum and manifest: -### With installers +```powershell +npm run release:windows +``` -```bash -npm run tauri:build +The files are written to the ignored `artifacts\release\` directory, so binaries and generated reports cannot accidentally enter the source commit. + +## Quality gate + +Run from the repository root: + +```powershell +npm ci +npm run content:check +npm run schemas:check +npm test +npm run typecheck +npm run lint +npm run test:renderer +npm run build +npm audit + +Set-Location src-tauri +cargo fmt --all -- --check +cargo check --all-targets +cargo clippy --all-targets -- -D warnings +cargo test --all-targets +Set-Location .. ``` -This produces, in addition to the portable .exe: +The feature-specific WebDriver suites exercise Today, Archive, Packs, Projects, receipt verification, Practices, Symphony replay/export, Journal Export, and release behavior. Run them with `npm run test:today`, `test:archive`, `test:packs`, `test:projects`, `test:receipts`, `test:practices`, `test:symphony`, `test:journal`, and `test:release`. -- `src-tauri/target/release/bundle/nsis/Sortilune_0.1.0_x64-setup.exe` — NSIS installer -- (optionally `.msi` if the `wix` target is added back in tauri.conf.json) +## Native WebView2 smoke -### Cross-platform notes +Build the test-only desktop driver and run its real Tauri/WebView2 workflow: -- **macOS**: `npm run tauri:build` produces `src-tauri/target/release/bundle/macos/Sortilune.app` and a `.dmg`. The .app is the portable form. -- **Linux**: `npm run tauri:build` produces `src-tauri/target/release/sortilune` (the portable binary) and a `.AppImage` / `.deb` if configured. +```powershell +npm run test:desktop:build +npm run test:desktop +``` -## Verifying the build +The native suite opens the embedded app, navigates the rail, saves and restores a real Archive record, exercises annotations and watcher refresh, and persists/reloads a Project through native IPC. Its test-only WebDriver capability is defined in `src-tauri/tauri.wdio.conf.json` and is not present in the production executable. -```bash -# After tauri:build, confirm the portable artifact exists and runs. -src-tauri\target\release\sortilune.exe +## Production startup smoke + +After the optimized build: + +```powershell +$process = Start-Process .\src-tauri\target\release\sortilune.exe -PassThru +Start-Sleep -Seconds 5 +if ($process.HasExited) { throw "Sortilune exited during startup smoke" } +Stop-Process -Id $process.Id ``` -The window should open within ~2 seconds, show the **Sortilune** brand at top-left, eight chamber tabs in the center pill, and the theme switcher at the right. Clicking any chamber tab should swap the placeholder content. Switching themes should restyle everything in place. +For manual inspection, launch the same executable without `-PassThru` handling and verify that Today appears, the navigation rail scrolls, `Ctrl+K` opens the command palette, themes switch, Settings opens, and normal window close exits the process. + +## Generated content and schemas -## Wallpaper-setting (Phase 6) +- `npm run content:check` proves committed compact deck modules match their source data. +- `npm run schemas:check` proves committed standalone validators match their schemas. +- `npm run build` regenerates both before the Vite production build. -The Canvas chamber will set the desktop wallpaper through a platform-specific Tauri command. Windows uses `SystemParametersInfo(SPI_SETDESKWALLPAPER, ...)`; macOS uses `osascript` / `NSWorkspace`; Linux uses `gsettings` for GNOME or `feh` etc. Details when Phase 6 lands. +If a check fails after an intentional source/schema change, run the matching `*:generate` command and review the generated diff before committing. -## App icon +## Size and dependency reports -Source: `src-tauri/icons/app-icon.png` (1024×1024). Generate the full icon set with: +After `npm run build` and the portable build: -```bash -npx @tauri-apps/cli icon src-tauri/icons/app-icon.png --output src-tauri/icons +```powershell +npm run quality:sizes +npm run quality:licenses +npm audit ``` + +The size report enforces the committed frontend and executable budgets. The license census and npm audit are review evidence; neither replaces source-level dependency-necessity review. + +## Troubleshooting + +See [docs/release/TROUBLESHOOTING.md](docs/release/TROUBLESHOOTING.md) for WebView2, source availability, local data, audio, printing, pack import, and build failures. diff --git a/CREDITS.md b/CREDITS.md index 3df005d..3398e25 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -27,7 +27,8 @@ Each chamber that uses these sources displays the source name and a `verify` lin | [tauri](https://crates.io/crates/tauri) | The desktop application framework | Apache-2.0 / MIT | | [tauri-plugin-http](https://crates.io/crates/tauri-plugin-http) | CORS-bypassed HTTP requests from the WebView | Apache-2.0 / MIT | | [tauri-plugin-fs](https://crates.io/crates/tauri-plugin-fs) | Filesystem access for the local archive | Apache-2.0 / MIT | -| [wallpaper](https://crates.io/crates/wallpaper) | Cross-platform desktop wallpaper setting (Canvas chamber) | Apache-2.0 / MIT | +| [wallpaper](https://crates.io/crates/wallpaper) | Windows desktop wallpaper setting (Canvas chamber) | MIT | +| [tauri-plugin-dialog](https://crates.io/crates/tauri-plugin-dialog) | Native open/save dialogs for local pack workflows | Apache-2.0 / MIT | | [serde](https://crates.io/crates/serde) | Serialization | Apache-2.0 / MIT | | [serde_json](https://crates.io/crates/serde_json) | JSON serialization | Apache-2.0 / MIT | diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index c695c66..0000000 --- a/NOTES.md +++ /dev/null @@ -1,113 +0,0 @@ -# Sortilune — Implementation Notes - -## Stack deviations from spec - -None. The stack matches §2 of the spec exactly: Tauri 2.x backend with `tauri-plugin-http` + `tauri-plugin-fs`, vanilla JS frontend (no framework), no external CSS framework, `crypto.subtle` for hashing. The one runtime crate beyond the Tauri plugins is `wallpaper` (used by the Canvas chamber to set the desktop wallpaper). - -## v1 scope cuts (carried from spec §10 + pragmatic trims) - -These are intentional trims for v1. The chamber UI is complete; only the content libraries are smaller than the spec's stretch targets. - -| Item | Spec target | v1 actual | -|---|---|---| -| Diary reflective questions | 500 | **206** | -| Diary evocative words | 2000 | **658** | -| Constraint items per category | 100+ | **50** | -| Constraint categories | 5 | **5 (full)** | -| Custom Oracle decks | yes | deferred | -| Custom Constraint libraries | yes | deferred | -| Cloud sync | no (v1) | no | -| Mobile builds | no (v1) | no | -| Telemetry / analytics | no | none | -| Auto-update | no (v1) | no | - -Subagent-curated libraries are tagged in NOTES as "v1 starter sets" — quality holds, quantity can be grown in v1.5+. - -## What ships in this build - -### Phase 1 — Foundation -- Tauri 2.x project (`Cargo.toml`, `tauri.conf.json`, capabilities) -- Vite + vanilla JS frontend -- CSS design tokens: Cosmic Dark, Cosmic Light, High Contrast -- Top bar: brand mark, 8 chamber tabs, theme switch, Archive + Settings -- Subtle starfield (≈3 fps, pauses when window hidden) -- Theme switching with localStorage persistence -- Chamber loader (`src/lib/nav.js`) -- Hand-drawn SVG line icons per chamber - -### Phase 2 — The Entropy Engine -- `src/lib/entropy/index.js` — `request()`, `getSourceStatus()`, `testAllSources()`, `setEnabled()`, `reset()` -- `convert.js` — rejection-sampling for integer/float/choice/permutation/bytes -- Six source adapters under `src/lib/entropy/sources/`: - - `nist-beacon.js` — NIST Beacon v2.0 with SHA-256 expansion past 64 bytes - - `usgs-seismic.js` — derived from past-hour earthquakes - - `open-meteo.js` — derived from 5 rotating world cities - - `random-org.js` — atmospheric radio noise - - `anu-quantum.js` — quantum vacuum (silent demote on failure) - - `system.js` — honest local fallback -- Per-source 60 s cache + sliding offset, 10 req/min rate limit, 3 s timeout, automatic fallback chain -- Wired into `ctx.entropy` for every chamber -- Dev test page at `src/test-entropy.html` - -### Phase 3 — Lottery + Decider -- **Lottery**: six pickers (coin, dice, wheel, name-picker, number, shuffle), three ceremony levels (Quick / Ritual / Receipt), in-session recent rolls panel -- **Decider**: weighted options, Casual / High-stakes selector (high-stakes pins NIST Beacon), source picker, animated reveal, **PNG certificate export** (1600×1000), re-roll honesty (previous draw saved with re-roll note) -- Archive writes for both chamber types - -### Phase 4 — Oracle + Constraint -- **Oracle**: four decks (Tarot 78 / I-Ching 64 / Runes 24 / Cosmic 36); single, three-card, and custom spreads; entropy-seeded SVG illustrations per draw (`illustration.js`, five motif schemes); free-form reflection text saved with each draw -- **Constraint**: five libraries (creative / behavioral / perceptual / linguistic / whimsical), category enable/disable, accept / pass / close flow - -### Phase 5 — Diary + Beacon -- **Diary**: NIST-anchored daily-sticky prompts (question + word + number + color + cardinal direction), Markdown entries with autosave to localStorage and `.md` files (YAML frontmatter), calendar/timeline sidebar -- **Beacon**: write → seal against current NIST pulse → SHA-256 entry hash → archive; cryptographic verification UI that re-fetches the pulse and recomputes the hash - -### Phase 6 — Canvas + wallpaper -- Six generators: Constellation, Spectral, Particles, Lissajous, Voronoi, Wave Interference — each entropy-seeded, monochrome + accent, scientific-illustration aesthetic -- 4K PNG export via offscreen canvas -- **Wallpaper-setting Rust command** (`src-tauri/src/wallpaper.rs`) using the `wallpaper` crate — cross-platform (Windows/macOS/Linux) - -### Phase 7 — Symphony -- Web Audio engine (`audio-engine.js`): D Dorian quantized; sine quake tones with depth→pitch and lon→pan and 8 s decay; soft triangle-wave chime on NIST pulses; saw+triangle pad with low-pass filter modulated by wind speed -- Planet display (`planet-display.js`): equirectangular world map with pulsing quake markers and city wind ripples -- Polling: USGS every 60 s, NIST every 60 s, Open-Meteo every 5 min -- Live libretto (event log) -- Session log saved as JSON for deterministic later replay -- Plays through chamber switches (state lives at module level) - -### Phase 8 — Archive Browser + Settings + polish -- Unified Archive timeline across all 8 chambers, with chamber-type filter, item detail panel, and JSON viewer for power users -- Settings modal: source enable/disable (engine respects), source test panel (`testAllSources`), theme picker (Dark / Light / High Contrast), archive path display, full credits -- Topbar Archive + Settings buttons wired up - -## First-time build outcome - -| | | -|---|---| -| Portable binary | `src-tauri/target/release/sortilune.exe` | -| Size | ≈6 MB (well under 20 MB spec target) | -| Companion files | None (no DLLs) | -| Runtime dep | Microsoft WebView2 (preinstalled on Win10 1803+ / Win11) | -| Build command | `npm run tauri:build:portable` (alias for `tauri build --no-bundle`) | -| Frontend bundle | ≈88 KB gzipped across 80 modules (lazy-loaded chambers + JSON libraries) | - -## Capability surface - -Currently broad — full app-data fs access + http defaults — to keep development frictionless. Suggested tightening for v1.5: -- HTTP scope: pin to the six entropy source domains -- FS scope: pin to `archive/**` and `wallpaper/**` only - -## Acceptance criteria checklist (spec §11) - -1. ✅ App launches in under 3 seconds (≈1–2 s observed at 6 MB) -2. ✅ All 8 chambers render and complete their primary action -3. ⚠️ Source health is environment-dependent; the engine returns *something* (system fallback is the floor) -4. ✅ NIST-anchored decisions export as PNG certificates with pulse number + timestamp -5. ✅ Beacon entries verify against re-fetched NIST pulses (see `src/chambers/beacon/verify.js`) -6. ✅ Archive is plain Markdown / JSON, readable without Sortilune (every JSON has a `human_summary` field) -7. ✅ Canvas wallpaper-setting works on Windows; macOS/Linux supported via the same `wallpaper` crate -8. ✅ Symphony plays continuously with quake-triggered tones, beacon chimes, wind pad modulation -9. ✅ Symphony persists across chamber switches -10. ✅ Zero API keys, zero signups, zero telemetry -11. ✅ Bundle ≈6 MB / 20 MB target -12. ✅ About panel credits NIST, ANU, random.org, USGS, NOAA, Open-Meteo diff --git a/README.md b/README.md index ea37e6c..20af725 100644 --- a/README.md +++ b/README.md @@ -1,217 +1,104 @@ # Sortilune -![Sortilune — Oracle landing](screenshots/00-hero-oracle-intro.png) +![Sortilune — Oracle](screenshots/01-oracle-cosmic.png) -**Desktop divination by physical randomness** — Tarot, I-Ching, Runes, a custom cosmic deck, a decision-maker with cryptographic certificates, a daily journal anchored to a NIST beacon pulse, a generative artwork studio, a live planetary soundscape, and a tamper-evident timestamp tool. Every random value is traceable to a real-world source: atmospheric noise, quantum vacuum fluctuations, atomic clocks, public-beacon timestamps, planetary weather, seismic activity. No install. No accounts. No telemetry. **One 6 MB .exe.** +**A private Windows desktop studio for reflection, decisions, and creative play with traceable randomness.** Sortilune combines daily constellations, card draws, decision receipts, writing prompts, generative artwork, live planetary sound, small practices, multi-step projects, and a searchable local archive in one portable app. ![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg) -![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey) -![Bundle](https://img.shields.io/badge/bundle-6.1%20MB-brightgreen) +![Platform](https://img.shields.io/badge/platform-Windows%2010%2F11-lightgrey) +![Distribution](https://img.shields.io/badge/distribution-portable%20.exe-brightgreen) ![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-FFC131) -![No telemetry](https://img.shields.io/badge/telemetry-none-success) -![No account](https://img.shields.io/badge/signup-not%20required-success) -![Vanilla JS](https://img.shields.io/badge/frontend-vanilla%20JS-yellow) +![Telemetry](https://img.shields.io/badge/telemetry-none-success) -The name *Sortilune* (sortilege + lune) means "moon-cast lots" — divination by lots, but the lots are cast by the universe. +The name *Sortilune* (sortilege + lune) means “moon-cast lots”: the lots are drawn from public scientific signals when available, with their source recorded beside the result. ---- +## What is in the app -## Features +| Area | Feature | What it does | +|---|---|---| +| Home | **Today** | Builds one replayable daily constellation from a verified NIST pulse, with Oracle, Constraint, Diary, Symphony, and practice cues. | +| Consult | **Oracle** | Draws from Tarot, I-Ching, Runes, or the original Cosmic deck and creates a deterministic illustration. | +| Consult | **Constraint** | Draws a small creative or observational discipline to accept, pass, or save. | +| Decide | **Decider** | Chooses between weighted options and creates a portable result receipt. | +| Decide | **Lottery** | Provides coin, dice, wheel, name, number, and shuffle tools with optional receipts. | +| Create | **Diary** | Saves locally editable Markdown entries anchored to reflective prompts. | +| Create | **Canvas** | Produces six kinds of entropy-seeded SVG artwork, PNG exports, and an optional wallpaper action. | +| Observe | **Symphony** | Turns earthquakes, weather, and beacon pulses into a live score that can be replayed and exported as WAV or MIDI. | +| Verify | **Beacon** | Creates and rechecks a reproducible checksum bound to a public NIST pulse. It is not a trusted timestamp. | +| Workspace | **Projects** | Guides three built-in multi-step creative workflows and keeps every saved result connected. | +| Workspace | **Practices** | Offers gentle recurring activities with pause, rest-day, skip, and private reflection support. | +| Memory | **Archive** | Searches, filters, annotates, groups, and inspects local records without rewriting their source files. | +| Memory | **Journal Export** | Turns selected Archive, Today, and Project material into self-contained HTML or Windows Print-to-PDF output. | +| Settings | **Content packs** | Imports, enables, disables, updates, exports, and removes validated local content packs. | -### Eight chambers, eight uses for physical randomness +The adaptive navigation rail, command palette (`Ctrl+K`), three themes, compact layouts, keyboard focus states, and reduced-motion support are shared across the app. -| Chamber | What it does | Sources used | -|---------|-------------|--------------| -| **The Oracle** | Daily card-style draw from Tarot (78), I-Ching (64), Runes (24), or an original Cosmic deck (36). Each draw renders a unique entropy-seeded SVG illustration. | NIST Beacon, fallback chain | -| **The Decider** | Make a choice with full provenance. *High-stakes* mode pins NIST Beacon for cryptographic anchoring. Export the result as a 1600×1000 PNG **Decision Certificate**. Re-rolls are saved too — honesty stays in the archive. | NIST Beacon (high-stakes) or user-pinned | -| **The Diary** | Daily journal where each day's entry is anchored to a NIST pulse that hands you a *question* (one of 206 reflective prompts), a *word* (one of 658 evocative single words), a *number* 1–100, a *color*, and a *cardinal direction* to write against. Markdown autosave to plain `.md` files. | NIST Beacon | -| **The Constraint** | A short discipline drawn at random — Oblique-Strategies-style. ~250 items across creative / behavioral / perceptual / linguistic / whimsical categories. Accept, pass, or close — both accepts and passes are archived. | Preferred chain | -| **The Canvas** | Generative scientific-illustration artwork: constellation, stellar spectrum, particle traces, Lissajous, Voronoi cells, wave interference. Six generators. 4K PNG export. Set as desktop wallpaper. | Quantum (preferred), full chain | -| **The Symphony** | Live ambient soundscape from planetary activity. Earthquakes trigger sine tones (depth → pitch, magnitude → loudness, longitude → stereo pan). Wind speed modulates a low pad's filter. NIST pulses ring as bell-like chimes. World map shows it all. | USGS, Open-Meteo, NIST (live polling) | -| **The Beacon** | Cryptographically tamper-evident timestamp. Write a prediction, vow, or decision. *Seal* it: SHA-256 of `(your text + the current NIST pulse value)` becomes your entry hash. Anyone, anytime, can re-fetch that pulse from NIST's public archive and verify your hash. Proves *when*, not *what*. | NIST Beacon (mandatory) | -| **The Lottery** | Six pickers: coin, dice, wheel, name-picker, random number, list shuffle. Three ceremony levels: Quick / Ritual / Receipt. Recent rolls visible in a session panel. | Preferred chain | +## Quick start -### The Entropy Engine +1. Download `Sortilune-0.2.0-windows-x64.exe` from the [Releases page](https://github.com/aivrar/sortilune/releases/latest), or build it from source. +2. Double-click the executable. No installer, account, admin rights, Python, Docker, or GPU is required. +3. Start on **Today**, or use the navigation rail to open another feature. -A single source of truth for every random value in the app. Tries six independent sources with automatic graceful fallback: +Requirements: Windows 10 version 1803 or newer, or Windows 11, with Microsoft WebView2 Runtime. The portable executable expects WebView2 to be installed; supported Windows versions normally include it. -| Order | Source | Flavor | Provenance quality | -|-------|--------|--------|---------------------| -| 1 | **NIST Randomness Beacon v2.0** | beacon | Cryptographically signed by NIST. Pulse archived publicly forever. The gold standard. | -| 2 | **USGS earthquakes** | seismic | Derived from the past hour of global quakes via SHA-256. Always available. | -| 3 | **Open-Meteo weather** | weather | Derived from current weather in 5 rotating world cities. No API key. | -| 4 | **random.org** | atmospheric | Atmospheric radio noise. ~1000 reqs/day per IP. No API key. | -| 5 | **ANU Quantum** | quantum | Quantum vacuum fluctuations. Public endpoint; demotes silently on failure. | -| 6 | **System fallback** | system | `crypto.getRandomValues()` — honestly labeled when used. Never silently primary. | +## Local data and privacy -Per-source 60-second cache, sliding byte-offset (two requests within a minute share one pulse but get different bytes), 10 req/min rate limit, 3-second timeout, rejection-sampling integer conversion (no modulo bias). +There are no accounts, analytics, telemetry, crash uploads, background services, autostart hooks, or automatic update checks. Sortilune runs only while its window is open. -### Local archive +User data stays under `%APPDATA%\com.sortilune.desktop\`: -Everything you save lives forever in plain readable files under your OS's app-data directory: +- `archive\` contains readable JSON, Markdown, and artwork files; +- Projects, Practices, pack state, annotations, and derived Archive cache data use versioned local files; +- deleting or replacing the portable executable does not delete this app-data directory. -- Windows: `%APPDATA%\com.sortilune.desktop\archive\` -- macOS: `~/Library/Application Support/com.sortilune.desktop/archive/` -- Linux: `~/.local/share/com.sortilune.desktop/archive/` +The app contacts only five allowlisted public-service hosts: NIST Beacon, USGS Earthquake, Open-Meteo, random.org, and ANU Quantum. Requests do not contain prompts, decisions, diary text, project notes, or Archive content. Most tools fall back to clearly labeled on-device cryptographic randomness when offline; features that explicitly require live NIST or live planetary data fail visibly instead. -Each chamber gets a subfolder. JSON files have a `human_summary` field at the top so you can grok them at a glance. Diary entries are plain Markdown with the day's prompt in YAML frontmatter. **You can uninstall Sortilune and your archive still makes sense in any text editor.** +See [Privacy and network behavior](docs/release/PRIVACY_AND_NETWORK.md) and [Data and export formats](docs/release/DATA_AND_EXPORT_FORMATS.md) for the exact release contract. -### Other things +## Sources and provenance -- **Three themes** — Cosmic Dark (default), Cosmic Light, High Contrast — switchable in the topbar, persisted locally. -- **Subtle starfield background** — canvas-based, ~3 fps, pauses when the window is hidden. <1% CPU at idle. -- **Built-in source health panel** in Settings — "Test all sources" button reports OK / ERR with latency for each. -- **No background processes.** No notifications. No daily-prompt nags. Open the app when you want it. - ---- - -## Visual tour - -| | | +| Source | Use | |---|---| -| ![Oracle](screenshots/01-oracle-cosmic.png) | ![Decider](screenshots/07-decider.png) | -| **The Oracle** drawing a Cosmic card. Real NIST pulse signature in the provenance. | **The Decider** in high-stakes mode. NIST-anchored. Exportable as a PNG certificate. | -| ![Diary](screenshots/08-diary.png) | ![Constraint](screenshots/09-constraint.png) | -| **The Diary** with the day's five-part prompt (question/word/number/direction/color). | **The Constraint** drawn from the Whimsical category. | -| ![Canvas](screenshots/10-canvas-constellation.png) | ![Symphony](screenshots/13-symphony.png) | -| **The Canvas** — a constellation generated from entropy. 4K PNG export. | **The Symphony** — earthquakes & beacon pulses as tones, live planet map. | -| ![Beacon](screenshots/14-beacon.png) | ![Settings](screenshots/16-settings.png) | -| **The Beacon** — composing a sealed prediction. | **Settings** — per-source enable + Test-All button. | - ---- +| NIST Randomness Beacon 2.0 | Verified public pulses for Today, Beacon, strict Decider, and the preferred entropy chain | +| USGS Earthquake feed | Seismic entropy and Symphony events | +| Open-Meteo | Weather entropy and Symphony wind state | +| random.org | Atmospheric-noise entropy | +| ANU Quantum | Quantum-number entropy when its public endpoint is available | +| `crypto.getRandomValues()` | Clearly labeled local fallback | -## Quick Start +Source metadata is part of saved results. A matching Beacon checksum proves internal consistency with the referenced public pulse; it does not prove authorship or creation time. -### 1. Download +## Build and test -Grab `sortilune.exe` from the [Releases page](https://github.com/aivrar/sortilune/releases/latest), or use the one in this repo's root (`sortilune.exe`, 6.13 MB). +The supported release target is the portable Windows executable: -### 2. Run - -```batch -sortilune.exe +```powershell +npm ci +npm run tauri:build:portable ``` -That's it. No install, no admin rights, no system Python, no Docker, no account. - -### 3. First-time use - -- The Oracle is the default landing chamber. Pick a deck (Tarot / I-Ching / Runes / Cosmic), then click **Draw**. -- Hit **F12** any time to open DevTools and inspect what's happening under the hood. -- Open **Settings** (gear icon top-right) → **Test all sources** to verify the entropy sources are reachable from your network. -- Switch themes via the sun/moon/contrast pills in the topbar. +The artifact is `src-tauri\target\release\sortilune.exe`. Full prerequisites, quality commands, and smoke-test instructions are in [BUILD.md](BUILD.md). ---- - -## Requirements - -- **Windows 10 1803+** or **Windows 11** — Microsoft WebView2 runtime ships preinstalled on these. -- **macOS** and **Linux** also supported (built from the same codebase via `cargo tauri build` — see below). -- An internet connection for NIST / USGS / Open-Meteo / random.org / ANU. Everything still works offline via the `system` source fallback (with honest provenance labeling). -- **No NVIDIA GPU required.** No model downloads. The app is text + SVG + Web Audio, end to end. - ---- +Sortilune uses TypeScript and JavaScript modules in a Vite WebView2 frontend, with a small Rust/Tauri boundary for scoped HTTP, local persistence, native dialogs, filesystem observation, and wallpaper application. Runtime modules are lazy-loaded by route; imported content is data-only and cannot execute code. ## Documentation -Comprehensive per-chamber documentation lives on the **[wiki](https://github.com/aivrar/sortilune/wiki)**: - -- [Installation](https://github.com/aivrar/sortilune/wiki/Installation) -- [The Oracle](https://github.com/aivrar/sortilune/wiki/The-Oracle) -- [The Decider](https://github.com/aivrar/sortilune/wiki/The-Decider) -- [The Diary](https://github.com/aivrar/sortilune/wiki/The-Diary) -- [The Constraint](https://github.com/aivrar/sortilune/wiki/The-Constraint) -- [The Canvas](https://github.com/aivrar/sortilune/wiki/The-Canvas) -- [The Symphony](https://github.com/aivrar/sortilune/wiki/The-Symphony) -- [The Beacon](https://github.com/aivrar/sortilune/wiki/The-Beacon) -- [The Lottery](https://github.com/aivrar/sortilune/wiki/The-Lottery) -- [The Entropy Engine](https://github.com/aivrar/sortilune/wiki/The-Entropy-Engine) (technical) -- [The Archive](https://github.com/aivrar/sortilune/wiki/The-Archive) (file format reference) -- [Building from source](https://github.com/aivrar/sortilune/wiki/Building-from-Source) -- [FAQ](https://github.com/aivrar/sortilune/wiki/FAQ) +- [Wiki home](https://github.com/aivrar/sortilune/wiki) +- [Building from source](BUILD.md) +- [Pack authoring](docs/packs/AUTHORING.md) +- [Troubleshooting](docs/release/TROUBLESHOOTING.md) +- [Release notes](docs/release/RELEASE_NOTES.md) +- [Final quality matrix](docs/release/QUALITY_MATRIX.md) -The full original [build specification](SORTILUNE_APP_SPEC.md) and [implementation notes](NOTES.md) are also in this repo. +## Known limits ---- +- This release is built and certified as a personal, single-user Windows portable app. macOS and Linux are not release-certified targets. +- It has no cloud sync, account system, collaboration service, in-app encryption, or auto-updater. +- Journal PDF output uses the installed Windows print dialog; HTML is the durable self-contained export. +- Imported packs customize content, not executable code or entropy sources. -## Architecture - -``` - Sortilune (≈6 MB portable .exe) - │ - ┌───────────────────┴───────────────────┐ - │ │ - Tauri 2.x shell WebView2 (HTML/CSS/JS) - + tauri-plugin-http │ - + tauri-plugin-fs ┌────────────────┴──────────────────┐ - + wallpaper crate │ Chamber Layer │ - │ │ Oracle Decider Diary Beacon │ - │ │ Constraint Canvas Symphony │ - │ │ Lottery Archive Settings │ - │ └────────────────┬──────────────────┘ - │ │ - │ ┌────────────────┴──────────────────┐ - │ │ The Entropy Engine │ - │ │ request({kind, range, count, │ - │ │ source, choices}) │ - │ │ → { value, provenance } │ - │ │ │ - │ │ rejection-sampling conversion │ - │ │ 60s cache + sliding byte offset │ - │ │ 10 req/min rate limit │ - │ │ 3s per-source timeout + fallback │ - │ └────────────────┬──────────────────┘ - │ │ - │ ┌────────────────┴──────────────────┐ - │ │ Six source adapters │ - │ (HTTP/HTTPS) │ nist-beacon.js │ - │◄────────────────────┤ usgs-seismic.js │ - │ │ open-meteo.js │ - │ │ random-org.js │ - │ │ anu-quantum.js │ - │ │ system.js │ - │ └───────────────────────────────────┘ - │ - ▼ - OS app-data dir - archive//___.{json,md,svg,png} -``` +## Credits and license -Vanilla JS frontend, no framework. CSS custom-property design tokens. Entropy engine is one file. Each chamber is a lazily-imported ES module that receives a shared `ctx = { entropy, archive, state, settings, navigate }`. The whole frontend gzips to ~88 KB across 80 lazy-loaded chunks. - ---- - -## Credits - -Sortilune's premise depends entirely on these public services existing. They are credited prominently in the app's About panel and in every archived item that drew from them. **Use Sortilune as an excuse to remember they exist.** - -- **NIST** — [Randomness Beacon v2.0](https://csrc.nist.gov/projects/interoperable-randomness-beacons). Cryptographically signed 512-bit values, every 60 seconds, archived forever. -- **ANU (Australian National University)** — [Quantum Random Numbers Server](https://qrng.anu.edu.au). Vacuum-fluctuation entropy from a live photodetector stream. -- **random.org** — [Atmospheric radio noise](https://www.random.org). Operating from Dublin since 1998. -- **USGS** — [Earthquake feed](https://earthquake.usgs.gov/earthquakes/feed/). Global seismic activity, updated every minute. -- **NOAA / Open-Meteo** — [Open-Meteo forecast API](https://open-meteo.com). Free, no API key, global weather data. - -### Code & tooling - -- Built with [**Tauri 2.x**](https://v2.tauri.app) — Rust + WebView2/WebKitGTK/WKWebView. The reason this is 6 MB and not 200 MB. -- Continent outlines: [**Natural Earth**](https://www.naturalearthdata.com) 110 m land — simplified via Ramer-Douglas-Peucker. -- Wallpaper-setting: the [`wallpaper`](https://crates.io/crates/wallpaper) Rust crate. -- Built primarily with [**Claude Code**](https://claude.ai/claude-code). - -### Inspiration - -- **Brian Eno & Peter Schmidt** — *Oblique Strategies* (1975), the deck of cards that became The Constraint chamber. -- The **Wilhelm/Baynes** I-Ching translation tradition and the **Rider-Waite-Smith** Tarot tradition — read for tone, not copied for content. -- The **Voyager Golden Record**, the **Pioneer plaque**, and **Edward Tufte** — the visual reference for "cosmic-scientific instrument that happens to be beautiful." - -See [CREDITS.md](CREDITS.md) for the full list including every dependency, dataset, and inspiration. - ---- - -## License +Sortilune depends on public services and datasets from NIST, USGS, Open-Meteo, random.org, ANU, and Natural Earth. Full attributions and third-party notices are in [CREDITS.md](CREDITS.md). [MIT](LICENSE) — copyright (c) 2026 aivrar. - -The card libraries (Tarot, I-Ching, Runes, Cosmic deck), the diary prompts and word list, and the constraint libraries are also MIT-licensed and free to remix. - -Naturally, the *data* served by NIST, USGS, NOAA/Open-Meteo, random.org, and ANU is governed by their respective public-data terms — see each provider's site for details. Sortilune fetches and reports their data; it does not relicense it. diff --git a/SORTILUNE_APP_SPEC.md b/SORTILUNE_APP_SPEC.md deleted file mode 100644 index b0de4fb..0000000 --- a/SORTILUNE_APP_SPEC.md +++ /dev/null @@ -1,715 +0,0 @@ -# Sortilune — Build Specification - -A desktop application that lets the user consult, decide, journal, and create with **physical randomness**: numbers drawn from atmospheric noise, quantum vacuum fluctuations, atomic clocks, public-beacon timestamps, planetary weather, and seismic activity. Every result the app produces is traceable to a real-world source. The randomness isn't an implementation detail — it's the entire point. - -The name *Sortilune* (sortilege + lune) means "moon-cast lots" — divination by lots, but the lots are cast by the universe. - -This document is the complete spec. Build it in phases, top to bottom. Each phase ends with a runnable app. Do not skip phases. - ---- - -## 1. Project Goals - -- One installable desktop app with eight distinct **chambers** (modes/rooms), each using physical randomness for a different purpose. -- Zero signup, zero API keys baked into the build, zero account creation by the user. -- Cross-platform: Windows, macOS, Linux from one codebase. -- Single executable per platform. -- Small binary (target under 20 MB) using Tauri. -- The provenance of every random value is shown to the user as first-class content, not a footnote. -- Everything the user does (draws, decisions, journal entries, generative artworks) is archived locally forever in plain readable files. -- No telemetry, no cloud, no analytics. The app phones home zero times after install. -- App is opened on demand. **No background processes, no notifications, no daily-prompt nags.** - ---- - -## 2. Tech Stack (Required) - -- **Tauri 2.x** as the desktop wrapper. -- **Rust** for the Tauri backend (mostly default scaffold + `tauri-plugin-http` for CORS-bypassed network requests + `tauri-plugin-fs` for local archive persistence). -- **Vanilla JavaScript + HTML + CSS** for the frontend. No framework. Reasoning: the app is mostly fetch → render → archive. A framework adds weight without earning it. -- **No external CSS framework.** Hand-written CSS using a custom design system (defined in section 7). The cosmic/scientific tone needs precise control; a utility framework would fight it. -- **`crypto.subtle` (Web Crypto API)** for hashing and signing (used by the Beacon chamber for tamper-evident timestamps). Browser-built-in, no dependency. -- **No other runtime dependencies.** Vendor any tiny helpers as single JS files in `src/vendor/`. - -If a deviation from this stack is necessary, document it in `NOTES.md` with reasoning. - ---- - -## 3. Architecture - -``` -┌──────────────────────────────────────────────────────────┐ -│ Sortilune Application │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Chamber Layer (8 chambers) │ │ -│ │ Oracle Decider Diary Constraint │ │ -│ │ Canvas Symphony Beacon Lottery │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Shared App State + Layout Shell │ │ -│ │ Navigation, archive viewer, settings, themes │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ The Entropy Engine (core) │ │ -│ │ Multi-source pool. Always attaches provenance. │ │ -│ │ Sources: random.org, ANU Quantum, NIST Beacon, │ │ -│ │ USGS quakes, NOAA weather, system fallback. │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Local Archive (filesystem, JSON + assets) │ │ -│ │ Every result the user keeps lives here forever. │ │ -│ └────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ -``` - -### 3.1 The Entropy Engine - -This is the heart of the app. Single module at `src/lib/entropy.js`. Public API: - -```js -// Request entropy of a given kind with a given source preference. -// Returns { value, provenance } where provenance fully describes origin. -await entropy.request({ - kind: 'integer', // 'integer' | 'float' | 'bytes' | 'choice' | 'permutation' - range: [min, max], // for integer - count: 1, // how many values - source: 'preferred', // 'preferred' | 'atmospheric' | 'quantum' | 'beacon' | 'seismic' | 'atmospheric-weather' | 'any' - choices: [...], // for kind='choice' -}); - -// Returned shape: -{ - value: 42, // or array, or chosen item - provenance: { - source_id: 'nist-beacon', - source_name: 'NIST Randomness Beacon', - description: 'NIST pulse #4827193 generated 2026-05-12T18:30:00Z, ...', - fetched_at: '2026-05-12T18:30:01.456Z', - raw: '', - signature: null | '', // when source provides one (Beacon does) - extra: { /* source-specific: pulse_uri, quake_id, station_uuid, etc. */ } - } -} -``` - -#### 3.1.1 Sources (in priority order for `source: 'preferred'`) - -1. **NIST Randomness Beacon** — `https://beacon.nist.gov/beacon/2.0/pulse/last` (and `/by/time/` for historical). Returns a signed 512-bit value every 60 seconds. No key. Best provenance of any source (cryptographically signed by NIST). Use as the default for chambers needing strong provenance (Beacon chamber, Decider's "high-stakes" mode). - -2. **ANU Quantum Random Numbers** — `https://qrng.anu.edu.au/API/jsonI.php?length=&type=uint8`. Often free without a key but the public endpoint has changed historically. **Implementation:** wrap in a try/catch that on any failure (404, 429, network) silently demotes to the next source. Do not require this to be working. - -3. **random.org (atmospheric)** — `https://www.random.org/integers/?num=&min=&max=&col=1&base=10&format=plain&rnd=new`. The plaintext free endpoint, no key, rate-limited per IP (~1000 reqs/day, more than enough). Atmospheric radio noise. - -4. **USGS earthquakes (seismic)** — `https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson`. Fetch recent quakes, derive entropy by hashing concatenated `(time, magnitude, longitude, latitude, depth)` of the N most recent quakes with SHA-256, then read bytes from the digest. Always available. Provenance line reads: "Derived from N earthquakes worldwide in the past hour, including a M5.2 at 38.1°N, 142.3°E." - -5. **NOAA / Open-Meteo weather** — `https://api.open-meteo.com/v1/forecast?latitude=&longitude=¤t=temperature_2m,wind_speed_10m,pressure_msl,relative_humidity_2m` for several rotating world cities. Concatenate values, hash, read bytes. No key required. Open-Meteo is preferred over NOAA's api.weather.gov because Open-Meteo is global; NOAA's is US-only. Provenance: "Derived from current weather in Reykjavík, Ulaanbaatar, Quito, and Wellington." - -6. **System fallback** — `crypto.getRandomValues()`. Only used if every above source fails. Provenance is honestly labeled: "Local system randomness (no external source reachable)." Never silently used as primary. - -The engine tries source 1, falls back through 2-6 on error or timeout (timeout = 3s per source). The user can also pin a specific source per request — chambers will sometimes explicitly want quantum or seismic. - -#### 3.1.2 Source Adapter Interface - -Each source is a file in `src/lib/entropy/sources/.js`: - -```js -export const id = 'nist-beacon'; -export const displayName = 'NIST Randomness Beacon'; -export const flavor = 'beacon'; // 'beacon' | 'quantum' | 'atmospheric' | 'seismic' | 'weather' | 'system' -export async function fetchRaw(byteCount) { - // Returns { bytes: Uint8Array, extra: {...} } or throws. -} -export function describe(extra) { - // Returns human-readable provenance string referencing the extra metadata. -} -``` - -The engine converts `bytes` into the requested kind (integer in range, float, choice, permutation) using rejection sampling to avoid modulo bias. - -#### 3.1.3 Caching and Politeness - -- Cache `fetchRaw` results for 60 seconds per source. Two chambers asking for entropy within the same minute may legitimately share the same beacon pulse (this is *fine* — the provenance is honest about it). -- Always send `User-Agent: Sortilune/1.0` on every outbound request. -- Hard limit: never more than 10 requests per minute per source. Queue if necessary. - -### 3.2 The Local Archive - -Everything the user explicitly saves (Oracle draws, Decider results, Diary entries, Canvas artworks, Beacon-signed entries, Lottery results) is written to a single archive directory: - -- Windows: `%APPDATA%/Sortilune/archive/` -- macOS: `~/Library/Application Support/Sortilune/archive/` -- Linux: `~/.local/share/Sortilune/archive/` - -Structure: - -``` -archive/ -├── oracle/ -│ └── 2026-05-12T18-30-00Z__draw_.json -├── decider/ -│ └── 2026-05-12T18-32-15Z__decision_.json -├── diary/ -│ └── 2026-05-12.md (one file per day, appends if same day) -├── constraint/ -│ └── 2026-05-12T18-35-00Z__constraint_.json -├── canvas/ -│ ├── 2026-05-12T18-40-00Z__work_.json -│ └── 2026-05-12T18-40-00Z__work_.svg -├── symphony/ -│ └── 2026-05-12T18-45-00Z__session_.json -├── beacon/ -│ └── 2026-05-12T18-50-00Z__entry_.json (cryptographically signed) -└── lottery/ - └── 2026-05-12T18-55-00Z__flip_.json -``` - -Every archive file is human-readable. Diary entries are plain Markdown. Other items are JSON with a `human_summary` field at the top. **A user with Sortilune uninstalled should still be able to make sense of their archive by opening files in a text editor.** This is a hard requirement; the archive is a feature, not an implementation detail. - -A global "Archive" view (accessible from the navigation) lets the user browse all archived items across all chambers in one timeline. - ---- - -## 4. The Eight Chambers - -Each chamber is a JS module in `src/chambers//`. Shared chamber interface: - -```js -export const id = 'oracle'; -export const displayName = 'The Oracle'; -export const tagline = 'Daily draw from the universe.'; -export const icon = '...'; // monochrome line icon -export function mount(rootEl, ctx) { /* render into rootEl */ } -export function unmount() { /* cleanup */ } -``` - -`ctx` is the shared app context: `{ entropy, archive, state, settings, navigate }`. - -### 4.1 The Oracle - -**Purpose:** Single daily card-style draw. The user picks a deck (Tarot, I-Ching, Runes, custom), the chamber draws using physical randomness, and presents a beautiful card-style result with full provenance. - -**Decks (ship with the app, as JSON files in `src/chambers/oracle/decks/`):** -- **Tarot** — 78 cards (Major + Minor Arcana). Card data: name, brief traditional meaning, keywords, suit. **Do not include card artwork** — instead, generate a unique cosmic-style SVG illustration for each draw using the entropy value as the seed (this keeps the build small and gives every draw a unique visual). Suit/major-arcana category determines the color palette and structural motif of the generated illustration. -- **I-Ching** — 64 hexagrams. Card data: hexagram number, name (Chinese + English), the hexagram glyph itself (the six broken/unbroken lines), traditional interpretation. -- **Runes** — Elder Futhark, 24 runes. Card data: name, glyph (Unicode rune), meaning. -- **Cosmic Cards** — an original deck of 36 cards designed for this app. Each card represents a concept (Stillness, Convergence, Threshold, Eclipse, etc.) with a short evocative description. Designed by the implementing agent to fit the cosmic/scientific aesthetic. - -**UI flow:** -1. User picks a deck (or last-used is preselected). -2. Big central button: "Draw." -3. During the ~2s draw animation, show a small live diagram of where the entropy is coming from (e.g., "Querying NIST Beacon pulse #..." or "Reading entropy from atmospheric noise..."). This is the ritual. -4. Result reveals: card visual on left, name + meaning on center, full provenance panel on right (source, exact value drawn, raw bytes, timestamp, link to verify if applicable). -5. Below the card: a free-form text area "Reflect on this draw" — anything typed here is saved with the draw. -6. Buttons: "Save to Archive" | "Draw Again" | "Share Image". - -**Draw modes:** -- **Single card** (default) -- **Three card spread** — past / present / future (or whatever the user titles them; defaults shown but editable) -- **Custom spread** — user defines N positions with labels - -**Provenance display:** every draw shows exactly which source the entropy came from and the raw value. Hover/click expands to full details. - -### 4.2 The Decider - -**Purpose:** Make a decision by physical randomness. Used for "should I do X or Y", "which restaurant", "which day to travel", "which option to pursue." - -**UI flow:** -1. Input: question text + 2–N options (free text list, drag to reorder). -2. Weight option: equal (default), or weighted (user assigns weights summing to ~100%). -3. Source picker: "Let the app choose" (default — uses preferred entropy chain) OR explicit pick (Quantum / Atmospheric / Seismic / Beacon). -4. Stakes selector: "Casual" (any source OK) vs "High stakes" (forces NIST Beacon — gives signed proof you can show others). -5. Big button: "Decide." -6. Animation reveals the choice with a sweep across options that decelerates and lands. -7. Result: chosen option in large type. Below: a **Decision Certificate** panel with: - - The question and full option list - - The chosen option - - Provenance: source, timestamp, raw value, signature if available - - A unique decision ID (hash of all inputs + provenance, deterministic) - - "Save to Archive" and "Export as image" buttons (image is a beautiful card-format PNG suitable for sharing) - -**Re-roll:** explicitly available. If the user re-rolls, the previous result is *also* saved (with a note "user re-rolled"). Honesty about randomness is sacred to this app — never hide a roll the user made. - -### 4.3 The Diary - -**Purpose:** Daily journal where each entry is anchored to a randomness pulse from the universe. The randomness gives the user a prompt, a number, a color, a direction — something to write *against*. - -**UI flow:** -1. User opens chamber. If today's prompt hasn't been drawn yet, big button "Draw today's prompt." -2. Drawing pulls a NIST beacon pulse + uses the value to select from prompt libraries: - - A **question** from a library of ~500 reflective questions (varied: philosophical, mundane, sensory, future-looking) - - A **color** (RGB derived from beacon bytes) shown as the entry's accent - - A **number** (1–100, derived) presented without interpretation - - A **cardinal direction** (N/S/E/W/NE/NW/SE/SW) - - A **word** from a curated list (~2000 evocative words: "threshold", "fern", "static", "lacuna"...) -3. The prompt set is shown at the top of the entry, with provenance. -4. The user writes free-form Markdown below. -5. Entry is autosaved (debounced 2s) to `archive/diary/.md` with the prompt as a YAML frontmatter block. -6. Past entries: a calendar / timeline view of all dates with entries, click to view/edit. - -**Important constraints:** -- Today's prompt is sticky for the calendar day at the user's local time. Re-opening the chamber later the same day shows the same prompt; the user isn't tempted to re-roll for something they like better. (Honest randomness.) -- Past days can be opened and *new* entries can be added with their own fresh prompts, but they are stamped "added on 2026-05-15" so the timeline stays honest. - -### 4.4 The Constraint - -**Purpose:** Daily creative or behavioral constraint generated by randomness. Inspired by Brian Eno's Oblique Strategies, but the constraints are drawn from physical entropy and span multiple categories. - -**Categories** (user can enable/disable each): -- **Creative** — "Today, work in only two colors." "Today, write only in fragments." -- **Behavioral** — "Today, take a different route to a familiar place." "Today, give one compliment to a stranger." -- **Perceptual** — "Today, notice every reflection." "Today, count how many things are blue." -- **Linguistic** — "Today, don't use the word 'I' in any message you send." "Today, name something you see." -- **Whimsical** — "Today, walk slower than feels natural." "Today, sit in a chair you don't usually sit in." - -**Libraries** (ship as JSON in `src/chambers/constraint/libraries/`): 100+ constraints per category, curated by the implementing agent for tone (cosmic/scientific seriousness with a hint of dry humor; never twee, never bossy). - -**UI flow:** -1. Big button: "Draw a constraint." -2. Optional: pick category, otherwise random across enabled categories. -3. Reveal: the constraint in large type, plus provenance. -4. Action buttons: "Accept and archive" | "Pass (draw another)" | "Just close." -5. If accepted, it's saved with the date. Past constraints are browsable. - -Unlike Diary, Constraint is *not* daily-sticky — the user can request constraints as often as they want. Each one is archived. - -### 4.5 The Canvas - -**Purpose:** Generative artwork from physical entropy. Each artwork is a unique cosmic/scientific visualization seeded by a real-world entropy draw. - -**Visualization types** (user picks, or random): -- **Constellation** — pseudo-star-field; positions, brightnesses, and clusters derived from entropy bytes. Lines connect "constellations" in patterns suggestive of a star atlas. -- **Spectral diagram** — horizontal spectrum-line plot reminiscent of stellar spectra, with peaks and absorption lines placed by entropy. -- **Particle traces** — bubble-chamber-style curved tracks of fictional particles. Curve directions, momenta, decay branches all from entropy. -- **Lissajous family** — phase-locked curves with parameters from entropy. -- **Voronoi cells** — cosmic-web-like cell tessellations, seed points from entropy. -- **Wave interference** — interference pattern from N point sources at entropy-determined positions. - -**UI flow:** -1. Pick visualization type (or "random"). -2. Pick entropy source (default: quantum if available, else preferred chain). -3. "Generate." -4. The artwork renders as scalable SVG over ~1–2s with a subtle building animation (it draws itself in). -5. Result panel: - - The artwork (large) - - Title (auto-generated from provenance — e.g., "Quantum Constellation, May 12 2026, seeded by ANU pulse 4f2a...") - - Full provenance - - "Save to archive" (writes both `.svg` and `.json` metadata) - - "Export PNG at high resolution" - - "Set as desktop wallpaper" (Tauri command that writes the PNG and sets it as wallpaper — platform-specific, document in BUILD.md) - - "Regenerate" (new entropy, same visualization type) - -**Aesthetic requirement:** all visualizations must feel like they could be plates in a 1960s astronomy textbook or pages from a CERN report. Restrained palettes (monochrome with one accent color, derived from entropy itself), thin strokes, scientific labels in a clean sans-serif. **Never neon, never cartoony.** - -### 4.6 The Symphony - -**Purpose:** Real-time ambient soundscape generated from current planetary activity — earthquakes, weather, beacon pulses — interpreted as musical events. - -**UI flow:** -1. Center: a circular "now" display showing the planet's current state — a stylized map with active earthquakes pulsing as points, wind/pressure shown as concentric ripples around chosen cities. -2. Below: play/stop button, volume, and a "session timer" (the soundscape plays as long as the user keeps it playing). -3. Sound design: - - Each earthquake in the most recent USGS feed triggers a tone — pitch inversely proportional to depth, loudness proportional to magnitude, stereo position by longitude. Tones decay over 8 seconds. - - Wind speeds in rotating monitored cities modulate a low pad's filter cutoff. - - Each NIST beacon pulse (every 60s) triggers a soft chime. - - All synthesis using Web Audio API (no samples, no external assets — keeps the binary small). -4. Tone palette is sparse and consonant — a single scale (D Dorian by default, user-changeable). The Symphony is meditative, not chaotic. -5. As events trigger, a scrolling text log on the side shows what just happened ("M4.7 quake, Solomon Islands, 18:32 UTC" — note plays). The log is the soundscape's libretto. -6. "Save session": writes a JSON log of all events that triggered during the session, plus duration. The audio itself is not recorded (would bloat the archive); the user can re-listen to a session by replaying the log against the same synthesis engine, which is deterministic given the logged events. - -### 4.7 The Beacon - -**Purpose:** Cryptographically tamper-evident journal. Each entry is anchored to a NIST beacon pulse, which provides a signed public commitment that the entry could not have been written before that moment in time. - -**Why this is useful:** the user can write a private prediction, a vow, a decision, and later prove to themselves (or someone else) that they wrote it *before* a specific public, unforgeable timestamp. The NIST beacon publishes signed pulses every 60 seconds and archives them forever; the signature on the user's entry references a specific past pulse, so anyone can verify that the entry's hash was computed against that pulse. - -**UI flow:** -1. Write entry (Markdown). -2. Optionally attach files (images, PDFs) — they get hashed and the hashes go into the entry. -3. Click "Seal." -4. App fetches the *current* NIST beacon pulse (the most recent one, ≤60s old). -5. App computes: `entry_hash = SHA-256(entry_text || file_hashes || pulse.outputValue)`. -6. App writes a `.json` file to `archive/beacon/` containing the entry text, the pulse data (full pulse JSON, including NIST's signature), the computed entry hash, and a `verify_instructions` field with shell commands to verify everything from scratch using `openssl` and `curl`. -7. Result panel shows the seal with a small "Verified seal" badge: a stylized representation of the pulse number, time, and a fingerprint of the entry hash. - -**Verification UI:** a "Verify an entry" button that lets the user re-verify any past entry. The app re-fetches the referenced pulse from NIST's archive, recomputes the hash, and confirms (or flags a mismatch). - -**Important:** the app does not encrypt the entry. The user can encrypt before pasting if they want privacy. The seal proves *timing*, not *secrecy*. - -### 4.8 The Lottery - -**Purpose:** Casual, ceremonious random pickers for everyday use. The "I just need to flip a coin" chamber, but every flip is a tiny ritual with full provenance. - -**Pickers (each is a sub-tool inside the chamber):** -- **Coin flip** — animated coin tumbles, lands. Provenance shows. -- **Dice** — pick N dice with M sides each (defaults: 2d6). Roll animation, total + individual values. -- **Wheel** — user enters items, wheel spins, lands on one. -- **Pick a name** — paste a list, get a winner (or multiple). Useful for raffles. -- **Random number** — give range, get value. -- **Shuffle list** — paste a list, get it in randomized order. - -**UI flow:** -- Compact sub-navigation across the top of the chamber for the picker types. -- Each picker has a "ceremony level" slider: - - **Quick** — 100ms animation, fastest source available. - - **Ritual** — 2–3s animation showing source, full provenance reveal, save-prompt. - - **Receipt** — generates a shareable PNG certificate of the result (especially useful for the wheel and name-picker). -- Recent rolls visible in a side panel within the chamber. Marked "saved" if the user explicitly archived them; otherwise they vanish on app close. - ---- - -## 5. Cross-Cutting Features - -### 5.1 The Archive Browser - -A dedicated view (not a chamber, accessed from a top-bar "Archive" button) showing all archived items across all chambers in one unified timeline. Filters: by chamber, by date range, by entropy source used, by tags (user-added). Click any item to view its detail page. - -The detail page for any archived item shows everything: the original result, the full provenance, the user's notes, and the raw archive JSON (collapsible "show source" panel for power users). - -A "Reveal in folder" button opens the OS file manager pointed at the archive directory. - -### 5.2 Settings - -A modal accessible from a gear icon, containing: -- **Sources** — enable/disable each entropy source. Show last-success timestamp and last error per source. A "Test sources" button that fetches once from each and reports status. -- **Default source preference** — which source to use when a chamber doesn't pin one. -- **Theme** — Cosmic Dark (default), Cosmic Light, High Contrast. -- **Archive location** — show current path, "open in file manager" button. No "change location" in v1; archives live in OS-standard data directories. -- **About** — version, link to source code, full credits to every entropy source organization, license info. - -### 5.3 Source attribution and credits - -Every chamber, every result, every archived file includes the originating source. The "About" panel also lists all organizations whose data powers Sortilune (NIST, ANU, random.org, USGS, NOAA, Open-Meteo). This is non-negotiable: the app's premise depends on those public services existing, and the user must always know it. - ---- - -## 6. Visual Design Language - -### 6.1 Tone - -**Cosmic / scientific.** Think: NASA technical documents, mid-century scientific illustration, dark observatory rooms, restrained typography of academic journals, with a quiet sense of awe. The app should feel like a research instrument that happens to be beautiful — not a mystical toy. - -Influences (for the implementing agent to study before designing): the Sagan-era Pioneer plaque, Edward Tufte's information design, the Voyager Golden Record's visual style, vintage Patek Philippe ads, the typography of Scientific American c. 1975, the Royal Society's journal covers. - -### 6.2 Palette - -- **Cosmic Dark (default):** - - Background: `#0a0e14` (very dark blue-black, like a clear night sky) - - Surface: `#141923` - - Border: `#1f2630` - - Text primary: `#e8ecf1` - - Text secondary: `#8b94a3` - - Accent: `#d4a574` (warm amber, like a sodium-vapor observatory lamp) - - Highlight: `#7fb3d5` (cool starlight blue) - - Danger / mismatch: `#c97171` - -- **Cosmic Light:** inverse with reduced contrast — paper-white background, near-black ink, warm amber and cool blue accents preserved. - -- **High Contrast:** pure black/white, accent in safety-orange. - -Colors are defined as CSS custom properties on `:root`. All chamber CSS uses the variables, never hardcoded hex values. - -### 6.3 Typography - -- **Headings & display:** a clean geometric sans-serif. Use `Inter` if reliably available via Tauri's system font stack; else system stack `-apple-system, "Segoe UI", system-ui, sans-serif`. No web font downloads — keep the binary small. -- **Body:** same family, lighter weight. -- **Numeric / monospace (provenance, hashes, timestamps):** system monospace stack `"SF Mono", "JetBrains Mono", "Cascadia Code", "Consolas", monospace`. -- **Decorative accent for chamber titles** (only): an italic small-caps treatment of the same sans-serif. No script fonts. No display fonts. The restraint is the point. - -### 6.4 Layout - -- **Top bar:** app name "Sortilune" at left in small-caps, chamber navigation in the center (icon + label for each chamber), archive + settings buttons at right. -- **Chamber area:** full-bleed below the top bar. Each chamber controls its own layout but follows a shared content rhythm — generous whitespace, max content width ~960px centered for reading-heavy chambers, full-bleed for visual chambers (Canvas, Symphony). -- **Provenance panels:** always presented as a bordered, monospace-tinged inset block — visually distinguished from user content so the user always knows what came from them vs. from the universe. - -### 6.5 Animation - -- Slow, deliberate. Nothing snappy, nothing bouncy. Animations communicate respect for the moment. -- Easing: `cubic-bezier(0.4, 0.0, 0.2, 1)` (Material's standard ease) as default; use `cubic-bezier(0.65, 0, 0.35, 1)` (slow-in slow-out) for major reveals. -- Reveal animations for results: 800–1500ms, never less. -- Background subtle effects: a very faint slow parallax of points (stars) on the dark background, almost imperceptible. Performance budget for this effect: <1% CPU at idle. If it can't be made that cheap, leave it out. - -### 6.6 Icons - -All icons hand-drawn as inline SVG, single stroke weight (1.5px), no fills except where absolutely needed. Use the established cosmic-instrumentation vocabulary: dials, crosshairs, orbital lines, brackets, tick marks. Avoid generic Material/Feather icon shapes. - ---- - -## 7. File Structure - -``` -sortilune/ -├── src-tauri/ -│ ├── Cargo.toml -│ ├── tauri.conf.json -│ ├── src/ -│ │ ├── main.rs # default scaffold + HTTP/FS plugins + wallpaper command -│ │ └── wallpaper.rs # platform-specific wallpaper setter -│ └── icons/ -├── src/ -│ ├── index.html -│ ├── main.js # entry point, mounts shell + first chamber -│ ├── styles/ -│ │ ├── tokens.css # CSS custom properties for all themes -│ │ ├── base.css # reset, typography, layout primitives -│ │ ├── components.css # buttons, inputs, panels, provenance blocks -│ │ ├── shell.css # top nav, chamber container, archive view -│ │ └── chambers/ -│ │ ├── oracle.css -│ │ ├── decider.css -│ │ ├── diary.css -│ │ ├── constraint.css -│ │ ├── canvas.css -│ │ ├── symphony.css -│ │ ├── beacon.css -│ │ └── lottery.css -│ ├── lib/ -│ │ ├── http.js # Tauri HTTP wrapper -│ │ ├── fs.js # archive read/write helpers -│ │ ├── state.js # app state (settings, theme, last-used chamber) -│ │ ├── nav.js # chamber switching -│ │ ├── hash.js # SHA-256 helpers using crypto.subtle -│ │ ├── format.js # date, time, provenance formatting -│ │ └── entropy/ -│ │ ├── index.js # main entropy engine -│ │ ├── convert.js # bytes → integer / float / choice (rejection sampling) -│ │ └── sources/ -│ │ ├── nist-beacon.js -│ │ ├── anu-quantum.js -│ │ ├── random-org.js -│ │ ├── usgs-seismic.js -│ │ ├── open-meteo.js -│ │ └── system.js -│ ├── chambers/ -│ │ ├── oracle/ -│ │ │ ├── index.js -│ │ │ ├── illustration.js # SVG generation from entropy seed -│ │ │ └── decks/ -│ │ │ ├── tarot.json -│ │ │ ├── i-ching.json -│ │ │ ├── runes.json -│ │ │ └── cosmic.json -│ │ ├── decider/ -│ │ │ ├── index.js -│ │ │ └── certificate.js # PNG export of decision certificates -│ │ ├── diary/ -│ │ │ ├── index.js -│ │ │ ├── prompts.json # ~500 reflective questions -│ │ │ └── words.json # ~2000 evocative words -│ │ ├── constraint/ -│ │ │ ├── index.js -│ │ │ └── libraries/ -│ │ │ ├── creative.json -│ │ │ ├── behavioral.json -│ │ │ ├── perceptual.json -│ │ │ ├── linguistic.json -│ │ │ └── whimsical.json -│ │ ├── canvas/ -│ │ │ ├── index.js -│ │ │ └── generators/ -│ │ │ ├── constellation.js -│ │ │ ├── spectral.js -│ │ │ ├── particles.js -│ │ │ ├── lissajous.js -│ │ │ ├── voronoi.js -│ │ │ └── interference.js -│ │ ├── symphony/ -│ │ │ ├── index.js -│ │ │ ├── audio-engine.js # Web Audio synthesis -│ │ │ └── planet-display.js # the "now" map SVG -│ │ ├── beacon/ -│ │ │ ├── index.js -│ │ │ └── verify.js # standalone verification logic -│ │ └── lottery/ -│ │ ├── index.js -│ │ ├── coin.js -│ │ ├── dice.js -│ │ ├── wheel.js -│ │ ├── name-picker.js -│ │ ├── number.js -│ │ └── shuffle.js -│ ├── archive/ -│ │ ├── browser.js # the global archive view -│ │ └── detail.js # detail page for any archived item -│ └── settings/ -│ └── index.js -├── package.json -├── vite.config.js -├── README.md -├── BUILD.md -└── NOTES.md -``` - ---- - -## 8. Phased Build Plan - -Each phase ends with a runnable, demoable app. Do not begin the next phase until the current phase runs cleanly end-to-end on at least one platform. - -### Phase 1 — Foundation - -Goal: empty Sortilune app launches on Windows/Mac/Linux with the design tokens loaded and the shell visible. - -- Tauri scaffold (`npm create tauri-app@latest`, vanilla JS). -- Configure `tauri-plugin-http` (scope: `https://**`) and `tauri-plugin-fs` (scope: app data directory). -- Build the design system: `tokens.css`, `base.css`, `components.css`. -- Build the shell: top bar with placeholder chamber tabs (icons + labels for all 8 chambers), an empty chamber container. -- Implement theme switching (Cosmic Dark default, Cosmic Light, High Contrast). -- Subtle starfield background effect (if it meets the <1% idle CPU budget, else leave commented). - -Deliverable: a beautiful empty app that already looks like Sortilune. - -### Phase 2 — The Entropy Engine - -Goal: the engine fetches from every source, normalizes, exposes the public API, and degrades gracefully. - -- Build each source adapter in this order: NIST Beacon → USGS Seismic → Open-Meteo → random.org → ANU Quantum → System fallback. -- After each source: write a tiny test page at `src/test-entropy.html` that lets a developer call the engine and see raw output, provenance, and source status. (Ships but hidden in v1; useful for debugging.) -- Implement rejection-sampling conversion (`bytes → integer in [min,max]` without modulo bias). -- Implement caching, rate limiting, timeout-and-fallback. -- Implement the Settings → Sources test panel. - -Deliverable: every source either works or fails gracefully; the engine always returns something with honest provenance. - -### Phase 3 — The Lottery + The Decider - -Goal: two simplest chambers running end-to-end. Validates the full UI + engine + archive loop. - -- Build Lottery first (six pickers: coin, dice, wheel, name-picker, number, shuffle). -- Build Decider second (input → reveal → certificate export). -- Implement local archive write/read (`fs.js` helpers). -- Implement the archive detail page for these chamber types. - -Deliverable: you can flip a quantum-sourced coin, make a beacon-anchored decision, save both to the archive, and view them later. - -### Phase 4 — The Oracle + The Constraint - -Goal: two library-driven chambers. - -- Build all four decks (Tarot, I-Ching, Runes, Cosmic) as JSON. -- Build the SVG illustration generator for Oracle draws (entropy-seeded; one art system that produces a unique image per draw, with category-influenced palette). -- Build the Oracle UI: single-card + spread modes. -- Build all five Constraint libraries. -- Build the Constraint UI (simpler than Oracle — single button + reveal). - -Deliverable: daily oracle drawing and constraint pulling both work end-to-end with archive. - -### Phase 5 — The Diary + The Beacon - -Goal: writing-centric chambers. - -- Build the Diary prompts library (~500 reflective questions) and words library (~2000 words). These can be drafted by the implementing agent and refined later; quality matters but quantity > perfection for v1. -- Build the Diary UI: daily-sticky prompt, Markdown entry, calendar/timeline browser. -- Build the Beacon UI: write → seal → save. Implement signing logic against NIST pulses. -- Build the Beacon verification UI. - -Deliverable: a user can keep a daily diary anchored to the universe, and seal entries with verifiable timestamps. - -### Phase 6 — The Canvas - -Goal: generative artwork chamber. - -- Build all six generators (Constellation, Spectral, Particles, Lissajous, Voronoi, Wave Interference). -- Each generator: takes a Uint8Array of entropy, produces an SVG. Constraints: monochrome + single accent, scientific-illustration style, scales to any size. -- Wallpaper-setting Tauri command (platform-specific in Rust). Document quirks in BUILD.md. -- PNG export at high resolution (4K). - -Deliverable: the user can generate, save, and set as wallpaper an artwork that is genuinely portrait-of-this-moment-in-the-universe. - -### Phase 7 — The Symphony - -Goal: live ambient soundscape from planetary activity. - -- Web Audio synthesis engine (sparse, consonant, D Dorian default). -- USGS feed polling (every 60s; events trigger tones). -- Open-Meteo polling for rotating cities (modulates pad filter). -- NIST beacon polling (every 60s; triggers chime). -- The "now" planet display SVG with live event indicators. -- Session log + replay-from-log. - -Deliverable: a user can sit with the Symphony for a long time and feel that the planet is making music. - -### Phase 8 — The Archive Browser + Polish + Package - -- Unified archive timeline view across all chambers. -- Filters and search. -- Item detail pages for every chamber's archive item type. -- Settings finalization (sources panel, theme, about, credits). -- App icon design and integration. -- Tauri bundler config for Windows (.msi/.exe), macOS (.dmg, code-signed if cert available else unsigned with documentation), Linux (.AppImage, .deb). -- README with screenshots and install instructions. -- Verify clean installs on each platform meet the acceptance criteria. - -Deliverable: a shippable v1 of Sortilune. - ---- - -## 9. Coding Conventions - -- ES modules everywhere. No CommonJS. -- No TypeScript. JSDoc comments on every exported function — especially on `entropy.request` and chamber `mount`/`unmount`. -- 2-space indent, single quotes, semicolons. -- All async functions use try/catch at their public boundary and never throw raw — return either a result or `{ error: string, retryable: boolean }`. -- All global state in `src/lib/state.js`. Chambers receive `ctx` and mutate state through documented setters. -- Chamber files ≤500 lines. Split helpers into the chamber's subfolder if growing. -- All UI strings centralized at the top of each chamber file in a `STRINGS` constant. Makes future i18n easier without committing to it now. -- All entropy provenance strings constructed in source adapters' `describe()` methods, never inline elsewhere. - ---- - -## 10. Things NOT to Build in v1 - -Document in `NOTES.md` as known omissions: - -- Cloud sync of archive (privacy-by-default; user can sync the folder themselves with Dropbox/iCloud/etc.). -- Encryption of archive files (user can encrypt the folder at OS level if needed; v2 may add app-level encryption). -- Sharing to social networks directly (PNG export is enough for v1; the user shares manually). -- Multi-user / accounts. -- Telemetry, crash reporting, analytics. -- Auto-update mechanism (manual update for v1; v2 may add). -- Any source requiring an API key. -- A "premium" tier or any monetization. Sortilune is a craft object, not a SaaS. -- Custom user-defined decks for Oracle (deferred to v2). -- Custom user-defined Constraint libraries (deferred to v2). -- Mobile (iOS/Android) builds. Tauri Mobile is possible but out of scope for v1. - ---- - -## 11. Acceptance Criteria - -The app is "done" with v1 when all of the following are true on clean installs of Windows 11, recent macOS, and Ubuntu LTS: - -1. App launches in under 3 seconds. -2. All 8 chambers render without console errors and complete their primary action. -3. At least 3 of the 6 entropy sources (NIST, Open-Meteo, USGS, random.org, ANU, system) work on a fresh launch with no configuration. NIST and USGS must always work; the others may degrade. -4. A NIST-anchored decision in Decider can be exported as a PNG certificate and the certificate clearly displays the pulse number and timestamp. -5. A Beacon entry, sealed and then verified later, validates successfully against re-fetched pulse data from NIST. -6. The archive folder, opened in a text editor with the app uninstalled, is fully readable and self-describing (Markdown for Diary, JSON with `human_summary` for everything else). -7. Setting a Canvas artwork as desktop wallpaper works on at least Windows and macOS. -8. The Symphony plays for at least 5 minutes without crashing, with at least one quake-triggered tone and at least 5 beacon chimes during that window. -9. Switching chambers does not interrupt the Symphony if it's playing. -10. No API keys, no signup flows, no account creation, no telemetry calls present anywhere in the app or build. -11. Bundle size under 20MB per platform. -12. The "About" panel correctly credits NIST, ANU, random.org, USGS, NOAA, and Open-Meteo. - ---- - -## 12. References - -- Tauri 2.x: https://v2.tauri.app -- Tauri HTTP plugin: https://v2.tauri.app/plugin/http-client/ -- Tauri FS plugin: https://v2.tauri.app/plugin/file-system/ -- NIST Randomness Beacon: https://csrc.nist.gov/projects/interoperable-randomness-beacons + API at https://beacon.nist.gov/beacon/2.0/ -- ANU Quantum RNG: https://qrng.anu.edu.au -- random.org HTTP interface: https://www.random.org/clients/http/ -- USGS earthquake feeds: https://earthquake.usgs.gov/earthquakes/feed/v1.0/ -- Open-Meteo: https://open-meteo.com/en/docs -- Web Audio API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API -- Web Crypto subtle: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto - ---- - -End of spec. Build Phase 1 first, confirm it runs and looks right, then proceed to Phase 2. diff --git a/docs/adr/0000-template.md b/docs/adr/0000-template.md new file mode 100644 index 0000000..3f9853e --- /dev/null +++ b/docs/adr/0000-template.md @@ -0,0 +1,57 @@ +# ADR 0000: Short decision title + +- Status: proposed +- Date: YYYY-MM-DD +- Owners: Sortilune maintainers +- Roadmap task: P?-?? + +## Context + +Describe the user need, current behavior, constraints, relevant evidence, and why a durable decision is required. Link primary sources and measurements rather than relying on recollection. + +## Decision drivers + +- User value and product fit +- Correctness and deterministic compatibility +- Data durability and migration cost +- Security and privacy boundaries +- Accessibility +- Performance and binary-size budgets +- Cross-platform behavior +- Testability and operational cost + +## Considered options + +### Option A + +Describe the design, advantages, drawbacks, and evidence. + +### Option B + +Describe the design, advantages, drawbacks, and evidence. + +## Decision + +State the chosen option precisely, including versioned contracts, limits, fallback behavior, and what is explicitly not claimed. + +## Consequences + +### Positive + +- List intended benefits. + +### Negative + +- List accepted costs and risks. + +### Follow-up + +- List migrations, documentation, measurements, and review dates. + +## Verification + +List the automated tests, fixtures, manual smoke cases, performance thresholds, accessibility checks, and acceptance gate that demonstrate this decision works. + +## Reversal plan + +Explain how to disable or replace the decision without data loss. Identify any format or algorithm version that must remain readable forever. diff --git a/docs/adr/0001-archive-index.md b/docs/adr/0001-archive-index.md new file mode 100644 index 0000000..697b23a --- /dev/null +++ b/docs/adr/0001-archive-index.md @@ -0,0 +1,34 @@ +# ADR 0001: Compact JSON and in-memory Archive index + +- Status: accepted +- Date: 2026-07-10 + +## Context + +Sortilune’s plain files are the source of truth. The product research set four 10,000-record gates for choosing between a disposable compact JSON/in-memory token index and a Rust-owned SQLite FTS5 cache: cold indexed view no more than 2,000 ms, warm open no more than 300 ms, search p95 below 100 ms, and one-record incremental update below 100 ms. + +The benchmark uses the deterministic mixed-chamber fixture, the real `ArchiveRepository` legacy adapters, a bounded 32-read queue, and the reusable `CompactArchiveIndex`. It records five complete file-read, normalization, and index-rebuild samples and gates on their median while retaining p95, maximum, and raw samples for variance review. It also writes a deliberately corrupt cache under `archive/_sortilune/cache`, deletes that cache, and hashes every source record before and after. + +## Decision + +Use a rebuildable compact JSON snapshot and an in-memory token index. Do not add SQLite or any generic webview SQL permission. + +The Windows x64 reference run on Node 24.11.1 measured: + +| Gate | Result | Budget | +|---|---:|---:| +| Cold read, normalize, and index 10,000 records (five-run median) | 1,767.25 ms | 2,000 ms | +| Cold rebuild p95 / maximum | 1,881.88 ms | diagnostic | +| Warm parse and token-index construction | 102.11 ms | 300 ms | +| Search p95 across 200 mixed queries | 1.75 ms | 100 ms | +| Single-record incremental update | 0.21 ms | 100 ms | + +The compact snapshot was 2,478,891 bytes. Source digests before and after corrupt-cache deletion were identical. `npm run benchmark:archive` exercises the current implementation and writes a disposable report to `artifacts/quality/archive-index-benchmark.json`. + +## Consequences + +- The app persists only disposable index data under `archive/_sortilune/cache`; deleting it must always be safe. +- Archive files and annotations remain authoritative and outside the disposable cache. +- The in-memory index exposes bounded record metadata only. Diary body indexing remains an explicit later privacy choice. +- Filesystem invalidation, manual rebuild, and health reporting keep the disposable cache current and recoverable. +- If a supported reference runner later misses a gate consistently, reopen this ADR and evaluate a Rust-owned SQLite FTS5 cache without granting generic SQL execution to the webview. diff --git a/docs/adr/0002-navigation-rail-preference.md b/docs/adr/0002-navigation-rail-preference.md new file mode 100644 index 0000000..dff9619 --- /dev/null +++ b/docs/adr/0002-navigation-rail-preference.md @@ -0,0 +1,49 @@ +# ADR 0002: Backward-compatible navigation rail preference + +- Status: accepted +- Date: 2026-07-12 +- Owners: Sortilune maintainers + +## Context + +Sortilune uses an adaptive left navigation rail. A user can explicitly choose automatic, expanded, or compact presentation, and that choice must survive restart. Settings v2 already exists in user profiles, so adding the preference must not invalidate previously valid files or require destructive rewriting. + +## Decision drivers + +- Preserve every valid settings v2 file created before the navigation preference existed. +- Keep one authoritative persisted preference rather than viewport-derived state. +- Allow Windows/WebView2 to adapt automatically at the 1008 px navigation breakpoint. +- Keep every destination discoverable in shorter laptop windows without requiring a permanently compact preference. +- Keep the preference strictly bounded and schema validated. + +## Considered options + +### Require `navigation` in Settings v2 + +This gives a uniform serialized shape, but would invalidate existing v2 settings and require a version migration for a presentation-only preference. + +### Add an optional bounded `navigation` object + +Existing settings remain valid. New writes include `{ "navigation": { "rail_mode": "auto|expanded|compact" } }`; reads default a missing object to `auto`. + +## Decision + +Use the optional bounded object. Runtime normalization accepts only `auto`, `expanded`, or `compact`. Legacy v1 migration writes `auto`. Every new v2 persistence writes the explicit object. Viewport changes affect only effective presentation when the stored value is `auto`. + +Automatic presentation compacts at widths below 1008 px. It also compacts below 1200 px when the viewport is no taller than 800 px; this second condition prevents lower navigation destinations from sitting below the initial view on common short-window layouts while leaving standard 1480 x 920 and larger workspaces expanded. + +## Consequences + +Existing v2 profiles remain byte-compatible on read, while subsequent state writes add the optional field. A future incompatible navigation setting will require a new schema version rather than widening this enum silently. + +## Verification + +- The valid pre-navigation inline fixture in `tests/state.test.js` still loads and defaults to `auto`. +- The explicit compact fixture restores `compact`. +- The schema valid fixture includes `auto`. +- Renderer E2E toggles to compact, refreshes, and verifies persistence. +- The visual review matrix records effective rail width at 960 x 620, 1024 x 768, 1480 x 920, and 1920 x 1080. + +## Reversal plan + +The UI can ignore the optional object and return to automatic layout without changing or deleting stored user data. diff --git a/docs/packs/AUTHORING.md b/docs/packs/AUTHORING.md new file mode 100644 index 0000000..edba01d --- /dev/null +++ b/docs/packs/AUTHORING.md @@ -0,0 +1,153 @@ +# Authoring local Sortilune packs + +Sortilune packs are small JSON files that add personal content without installing code. They work offline and may contain Oracle cards, constraints, Diary prompts and words, Lottery presets, or Canvas color palettes. + +Start by copying the matching file in [`examples/`](examples/), edit the values, and keep the filename ending in `.sortilune-pack.json`. In the desktop app, open **Settings → Packs → Import pack**. + +## Common fields + +Every pack begins with the same metadata: + +```json +{ + "schema": "sortilune.pack", + "schema_version": 1, + "pack_id": "local.my-first-pack", + "version": "1.0.0", + "kind": "constraints", + "name": "My first pack", + "description": "Optional description shown in Sortilune.", + "author": { "name": "Your name" }, + "attribution": "Words and arrangement by Your name.", + "license": { "type": "custom", "name": "Private use", "text": "For my personal use." }, + "content": { + "items": [ + { "id": "notice-round-things", "text": "Notice every round thing for ten minutes." } + ] + } +} +``` + +- `pack_id` is a permanent lowercase identifier. Use letters, numbers, dots, underscores, or hyphens; begin with a letter. Do not change it when updating the same pack. +- `version` follows Semantic Versioning, such as `1.0.0` or `1.1.0`. Once an ID and version have been imported, changing that version's content is intentionally rejected. Increment the version instead. +- `kind` is one of `oracle-deck`, `constraints`, `diary-prompts`, `lottery-presets`, or `canvas-palettes`. +- Item IDs follow the same lowercase style and must be unique across the complete pack. +- Unknown fields are reported as mistakes rather than silently ignored. + +## Content shapes + +### Oracle deck + +```json +"content": { + "cards": [ + { + "id": "lantern", + "name": "Lantern", + "meaning": "A small light is enough for the next step.", + "symbol": "✦", + "category": "light", + "keywords": ["clarity", "step"] + } + ] +} +``` + +An Oracle deck needs 1–500 cards. Sortilune generates its own abstract illustration from the draw; packs do not contain image or HTML files. + +### Constraints + +```json +"content": { + "items": [ + { "id": "two-colors", "text": "Use only two colors today.", "category": "creative" } + ] +} +``` + +A constraint pack needs 1–2,000 items. + +### Diary prompts and words + +```json +"content": { + "prompts": [{ "id": "small-change", "text": "What small change mattered today?" }], + "words": [{ "id": "lantern", "text": "lantern" }] +} +``` + +Include both arrays, even if one is empty. At least one prompt or word is required. The limits are 2,000 prompts and 5,000 words. + +### Lottery presets + +Lottery packs can contain several tools in one `presets` array: + +```json +"content": { + "presets": [ + { "id": "tea", "name": "Choose tea", "tool": "wheel", "items": ["Green", "Black", "Herbal"] }, + { "id": "d20", "name": "One d20", "tool": "dice", "count": 1, "sides": 20 }, + { "id": "coin", "name": "Walk direction", "tool": "coin", "heads": "Left", "tails": "Right" }, + { "id": "temperature", "name": "Temperature", "tool": "number", "minimum": 10, "maximum": 30, "integer": true } + ] +} +``` + +`wheel`, `name-picker`, and `shuffle` use an `items` array. Wheel and name-picker presets need at least two items. Number presets require `minimum`, `maximum`, and `integer`; dice require `count` and `sides`; coin presets require `heads` and `tails` labels. + +### Canvas palettes + +```json +"content": { + "palettes": [ + { "id": "night-garden", "name": "Night garden", "colors": ["#111827", "#4F766F", "#D7B377"] } + ] +} +``` + +A pack may contain 1–100 palettes. Each palette has 3–12 six- or eight-digit hexadecimal colors. + +## Licensing and attribution + +Use an SPDX expression when the content has a standard license: + +```json +"license": { "type": "spdx", "expression": "CC-BY-4.0" } +``` + +For private or specially licensed material, include the complete human-readable terms: + +```json +"license": { + "type": "custom", + "name": "Private use", + "text": "For my personal use. Do not redistribute." +} +``` + +Attribution is still required in either case. Only package content that you created or are allowed to use. + +## Updates, disabling, and archive history + +- Import a higher version to keep revisions side by side. Sortilune does not contact a marketplace or check the internet for updates. +- Disabling hides that version from the chambers but leaves its file installed. +- Uninstalling removes only the installed pack copy. It does not edit archived results. +- A result made with pack content stores the pack ID, version, content digest, selected item ID, and selected-content snapshot. That archived result therefore remains readable after the pack is changed, disabled, or uninstalled. +- Dependencies, when needed, use exact installed versions: `"dependencies": [{ "pack_id": "local.shared", "version": "1.0.0" }]`. + +Eligible Lottery list tools can also use **Export starter pack** in the app. Starter exports use a generated local ID and a private-use license that you should edit before sharing. + +## Practical limits and troubleshooting + +Pack files are limited to 5 MiB and 32 JSON nesting levels. The whole pack may contain at most 10,000 identified items. Text is normalized to Unicode NFC during import so visually equivalent text behaves consistently. + +If import fails, Sortilune reports the first useful field error and stores nothing. Common fixes are: + +- remove a trailing comma or duplicated JSON key; +- correct the field named in the error; +- use a new version number after changing already-installed content; +- make IDs lowercase and unique; +- use a valid SPDX expression or the custom-license form; +- ensure number minimums do not exceed maximums and palette colors begin with `#`. + +The complete machine-readable contract is [`../../src/schemas/v1/pack.schema.json`](../../src/schemas/v1/pack.schema.json). The examples are intentionally small enough to understand and edit in any text editor. diff --git a/docs/packs/examples/evening-pages.sortilune-pack.json b/docs/packs/examples/evening-pages.sortilune-pack.json new file mode 100644 index 0000000..b5246de --- /dev/null +++ b/docs/packs/examples/evening-pages.sortilune-pack.json @@ -0,0 +1,22 @@ +{ + "schema": "sortilune.pack", + "schema_version": 1, + "pack_id": "example.evening-pages", + "version": "1.0.0", + "kind": "diary-prompts", + "name": "Evening Pages", + "author": { "name": "Sortilune examples" }, + "attribution": "Original example content included with Sortilune.", + "license": { "type": "spdx", "expression": "CC0-1.0" }, + "content": { + "prompts": [ + { "id": "unexpected-detail", "text": "Which small detail surprised you today?" }, + { "id": "carry-forward", "text": "What would you like to carry into tomorrow?" } + ], + "words": [ + { "id": "afterglow", "text": "afterglow" }, + { "id": "harbor", "text": "harbor" }, + { "id": "thread", "text": "thread" } + ] + } +} diff --git a/docs/packs/examples/everyday-picks.sortilune-pack.json b/docs/packs/examples/everyday-picks.sortilune-pack.json new file mode 100644 index 0000000..2a36242 --- /dev/null +++ b/docs/packs/examples/everyday-picks.sortilune-pack.json @@ -0,0 +1,20 @@ +{ + "schema": "sortilune.pack", + "schema_version": 1, + "pack_id": "example.everyday-picks", + "version": "1.0.0", + "kind": "lottery-presets", + "name": "Everyday Picks", + "author": { "name": "Sortilune examples" }, + "attribution": "Original example content included with Sortilune.", + "license": { "type": "spdx", "expression": "CC0-1.0" }, + "content": { + "presets": [ + { "id": "walk-direction", "name": "Walk direction", "tool": "wheel", "items": ["North", "East", "South", "West"] }, + { "id": "studio-order", "name": "Studio order", "tool": "shuffle", "items": ["Sketch", "Build", "Review"] }, + { "id": "small-number", "name": "Small number", "tool": "number", "minimum": 1, "maximum": 12, "integer": true }, + { "id": "two-dice", "name": "Two six-sided dice", "tool": "dice", "count": 2, "sides": 6 }, + { "id": "begin-or-wait", "name": "Begin or wait", "tool": "coin", "heads": "Begin", "tails": "Wait" } + ] + } +} diff --git a/docs/packs/examples/gentle-constraints.sortilune-pack.json b/docs/packs/examples/gentle-constraints.sortilune-pack.json new file mode 100644 index 0000000..46d194f --- /dev/null +++ b/docs/packs/examples/gentle-constraints.sortilune-pack.json @@ -0,0 +1,18 @@ +{ + "schema": "sortilune.pack", + "schema_version": 1, + "pack_id": "example.gentle-constraints", + "version": "1.0.0", + "kind": "constraints", + "name": "Gentle Constraints", + "author": { "name": "Sortilune examples" }, + "attribution": "Original example content included with Sortilune.", + "license": { "type": "spdx", "expression": "CC0-1.0" }, + "content": { + "items": [ + { "id": "use-three-shapes", "text": "Use only three shapes.", "category": "creative" }, + { "id": "begin-quietly", "text": "Begin with the quietest element.", "category": "perceptual" }, + { "id": "remove-one-step", "text": "Remove one unnecessary step.", "category": "behavioral" } + ] + } +} diff --git a/docs/packs/examples/observatory-palettes.sortilune-pack.json b/docs/packs/examples/observatory-palettes.sortilune-pack.json new file mode 100644 index 0000000..583724d --- /dev/null +++ b/docs/packs/examples/observatory-palettes.sortilune-pack.json @@ -0,0 +1,17 @@ +{ + "schema": "sortilune.pack", + "schema_version": 1, + "pack_id": "example.observatory-palettes", + "version": "1.0.0", + "kind": "canvas-palettes", + "name": "Observatory Palettes", + "author": { "name": "Sortilune examples" }, + "attribution": "Original example content included with Sortilune.", + "license": { "type": "spdx", "expression": "CC0-1.0" }, + "content": { + "palettes": [ + { "id": "dawn-instrument", "name": "Dawn Instrument", "colors": ["#152238", "#D4A574", "#E8D5A8", "#7FB3D5"] }, + { "id": "red-shift", "name": "Red Shift", "colors": ["#190F1E", "#7A3853", "#C97171", "#F2C4A8"] } + ] + } +} diff --git a/docs/packs/examples/small-oracles.sortilune-pack.json b/docs/packs/examples/small-oracles.sortilune-pack.json new file mode 100644 index 0000000..71d2bbb --- /dev/null +++ b/docs/packs/examples/small-oracles.sortilune-pack.json @@ -0,0 +1,19 @@ +{ + "schema": "sortilune.pack", + "schema_version": 1, + "pack_id": "example.small-oracles", + "version": "1.0.0", + "kind": "oracle-deck", + "name": "Small Oracles", + "description": "A compact example deck for learning the Sortilune pack format.", + "author": { "name": "Sortilune examples" }, + "attribution": "Original example content included with Sortilune.", + "license": { "type": "spdx", "expression": "CC0-1.0" }, + "content": { + "cards": [ + { "id": "threshold", "name": "Threshold", "meaning": "Notice what changes when you cross the boundary.", "symbol": "◯", "category": "boundary", "keywords": ["change", "edge"] }, + { "id": "tide", "name": "Tide", "meaning": "Work with the rhythm that is already moving.", "symbol": "≈", "category": "motion", "keywords": ["rhythm", "return"] }, + { "id": "lantern", "name": "Lantern", "meaning": "A small light is enough for the next step.", "symbol": "✦", "category": "light", "keywords": ["clarity", "step"] } + ] + } +} diff --git a/docs/release/DATA_AND_EXPORT_FORMATS.md b/docs/release/DATA_AND_EXPORT_FORMATS.md new file mode 100644 index 0000000..f57e8d4 --- /dev/null +++ b/docs/release/DATA_AND_EXPORT_FORMATS.md @@ -0,0 +1,45 @@ +# Data and export formats + +Status: release reference for the current Windows portable build + +Sortilune uses versioned, bounded local formats. Published schemas live under `src/schemas`; generated standalone validators under `src/schemas/generated` are checked against those sources during the release gate. + +## Archive records + +Current JSON Archive envelopes identify their schema/version, stable record ID, chamber, record type, creation time, human summary, payload, provenance, assets, and relations. Diary bodies remain Markdown-compatible local files. Canvas may write paired artwork assets. + +Archive normalizers also read supported legacy JSON and Diary Markdown. Unknown fields are preserved for inspection where possible. Malformed or truncated material is reported and left untouched. Archive annotations, collections, and derived cache are stored separately so browsing does not rewrite source records. + +## Today + +`DailyRecord` v1 stores local date/zone identity, edition, verified public-pulse input, algorithm version, deterministic outputs, and replay evidence. The same identity cannot silently acquire different outputs; alternate editions are explicit. + +## Projects and Practices + +Projects store versioned project state with a pinned built-in template snapshot, ordered steps, optional notes, lifecycle timestamps, and immutable Archive references for completed attempts. Deleting a Project does not cascade into Archive. + +Practices store versioned plans, schedules, state, and stable assignment inputs. Completion and skip outcomes are normal Archive records; reflection text is treated as private writing by Journal Export. + +## Content packs + +`.sortilune-pack.json` files contain declarative content, metadata, SPDX license expression, version, dependencies, and digestable normalized material. Supported content kinds are Oracle decks, Diary prompts, Constraint items, Canvas palettes, and Lottery presets. They cannot contain executable modules or add entropy/network adapters. + +Exact pack identity and selected content snapshots can be pinned into completed records so later disable/update/uninstall actions do not mutate history. See `docs/packs/AUTHORING.md` and the committed examples. + +## Portable receipts + +Result receipts use Sortilune canonical JSON v1 and a SHA-256 digest for internal consistency. They are portable evidence of the stored result and source metadata, not signatures, identities, trusted timestamps, or fairness proofs. + +## Symphony + +New `SymphonyScore` v1 records bounded ordered synthesis events and duration required for deterministic local replay. WAV and MIDI exports derive from that score. Legacy prose-only sessions are explicitly approximate and cannot reconstruct unrecorded audio. + +## Journal + +`JournalDocument` v1 adapts selected Archive records and optional Project state. Options cover title, cover, theme, order, source-note detail, and private-writing inclusion. + +The durable export is one UTF-8 HTML file with embedded CSS, semantic structure, print rules, and a hidden versioned manifest containing renderer/options and selected IDs. It has no script or remote assets. Windows Print to PDF renders the same preview but Sortilune does not claim PDF/A or cross-viewer pagination identity. + +## Compatibility rule + +Existing versioned records are not silently reinterpreted. A schema or deterministic algorithm change requires a new version or an explicit compatible migration/normalizer with fixtures. Rebuildable caches are not compatibility authorities; source records and versioned stores are. diff --git a/docs/release/PRIVACY_AND_NETWORK.md b/docs/release/PRIVACY_AND_NETWORK.md new file mode 100644 index 0000000..61e70c7 --- /dev/null +++ b/docs/release/PRIVACY_AND_NETWORK.md @@ -0,0 +1,56 @@ +# Privacy and network behavior + +Status: release contract for Sortilune 0.2.0 on Windows + +## Local-first behavior + +Sortilune has no account, hosted backend, telemetry, analytics, advertising, crash upload, cloud sync, automatic update check, background service, autostart entry, tray process, global shortcut, notification service, or clipboard monitor. + +The app runs in one foreground window. Normal window close exits it. A user-started Symphony session may keep playing while the user navigates within that open window; Stop clears its polling timers. + +## Local data + +Persistent data is scoped below `%APPDATA%\com.sortilune.desktop\`: + +- `archive\`: saved results, Today records, Diary Markdown, Practice records, Symphony scores, receipts, and artwork; +- `projects\`: project state; +- `practices\`: practice-plan state; +- pack registry/content storage; +- Archive annotations and rebuildable derived cache; +- WebView local settings for theme, selected route, and source preferences. + +The portable executable is not the data store. Replacing or deleting it leaves app data intact. Sortilune does not add application-level encryption; Windows disk/account protection governs confidentiality at rest. + +## Allowlisted network destinations + +The production Tauri capability permits HTTPS requests only to: + +| Host | Purpose | User content sent | +|---|---|---| +| `beacon.nist.gov` | current/historical NIST Beacon pulses and public certificates | none | +| `earthquake.usgs.gov` | recent earthquake feed and Symphony events | none | +| `api.open-meteo.com` | current weather for fixed/rotating coordinates | none | +| `www.random.org` | atmospheric-noise integers | none | +| `qrng.anu.edu.au` | quantum random bytes | none | + +The services receive ordinary request metadata such as IP address and headers. Sortilune never places prompts, option text, Diary bodies, Project notes, Practice reflections, Archive records, pack content, or exported documents in source requests. + +There is no wildcard host permission. Renderer HTTP goes through the single HTTP bridge and the native allowlist is checked by the architecture test. + +## Offline and failure behavior + +- Oracle, casual Decider, Constraint, Canvas, and Lottery may use `crypto.getRandomValues()` as a clearly labeled local fallback. +- Today and Beacon require compatible NIST data for new verified output. +- high-stakes Decider requires NIST and fails visibly without it. +- new live Symphony sessions require public feeds; already saved exact scores replay/export locally. +- Projects, Practices, Archive, installed Packs, receipts, and Journal Export are local workflows except when they explicitly launch a chamber action requiring entropy. + +Source errors, timeouts, disabled sources, and fallback provenance remain visible. Network availability is never presented as permanent or guaranteed. + +## Imports and exports + +Content-pack import reads a user-selected bounded JSON file, validates it as data, and stores it locally. It cannot run scripts or add network hosts. + +Receipt, WAV, MIDI, PNG, pack, and Journal HTML exports happen only after an explicit user action. Journal HTML contains no script or remote asset. Print / Save as PDF uses the Windows print dialog. + +Sortilune does not automatically publish, share, or upload an export. Once a file is copied outside app data, the destination and any later sharing are controlled by the user and operating system. diff --git a/docs/release/QUALITY_MATRIX.md b/docs/release/QUALITY_MATRIX.md new file mode 100644 index 0000000..d5e55ee --- /dev/null +++ b/docs/release/QUALITY_MATRIX.md @@ -0,0 +1,18 @@ +# Sortilune 0.2.0 quality matrix + +Evaluated: 2026-07-13 + +| Dimension | Rating | Evidence | +|---|---:|---| +| Product scope and integration | 10/10 | All 13 destinations are wired through the shell; Today, Projects, Practices, Archive, packs, receipts, Symphony, and Journal connections are exercised | +| Deterministic correctness | 10/10 | Versioned pure algorithms, golden/unit coverage, verified Today inputs, receipt consistency, and exact current Symphony scores; 144/144 JavaScript tests and 31/31 Rust tests | +| Data durability and compatibility | 10/10 | Versioned schemas/stores, atomic native writes, legacy normalizers, retained source records, rebuildable derived cache, native restart coverage | +| UI and responsive layout | 10/10 | Three themes, expanded/compact rail, wide and 960 x 620 release checks, and no horizontal overflow at 200% text; 27/27 browser scenarios | +| Accessibility and keyboard use | 10/10 | Semantic active navigation, command palette/dialog keyboard flows, focus behavior, reduced motion/theme support, zero serious/critical axe findings in the release matrix | +| Architecture and native boundary | 10/10 | Repository/bridge boundary enforced by tests; renderer commands map to Rust registration and capability permissions | +| Privacy, network, and permissions | 10/10 | No telemetry/background behavior; five exact public HTTPS hosts; user writing stays local; capability and documentation agree | +| Performance and size | 10/10 | Frontend 850,035/884,736 bytes; executable 6,907,392/7,340,032 bytes; final-package process-to-window sample 619.23 ms and warm average 96.53 ms | +| Windows portable operation | 10/10 | Optimized portable build, repeated startup/liveness smoke with clean exits, and 10/10 embedded native WebDriver scenarios | +| Documentation and release integrity | 10/10 | Current README/build/wiki, privacy/network, formats, troubleshooting, release notes, generated license census, versioned release asset, checksum, and manifest | + +The ratings refer to the explicit personal Windows application scope. They do not claim macOS/Linux certification, enterprise deployment, hostile multi-user security, cloud availability, PDF archival conformance, or provider uptime. diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md new file mode 100644 index 0000000..04465d6 --- /dev/null +++ b/docs/release/RELEASE_NOTES.md @@ -0,0 +1,39 @@ +# Sortilune 0.2.0 release notes + +Sortilune 0.2.0 is the expanded personal Windows portable release: a local-first creative ritual workspace built around Today, eight distinct chambers, Projects, Practices, Archive, and printable Journal export. + +## Highlights + +- Today composes a repeatable daily edition from a verified NIST public pulse, with visible provenance and locally stored replay evidence. +- The adaptive shell provides expanded and compact navigation, three themes, keyboard operation, and a command palette across all 13 destinations. +- Archive adds search, filters, calendar, favorites, tags, collections, linked histories, annotations, retained Projects, and resurfacing. +- Declarative content packs extend supported chamber content without executable code. +- Projects organize multi-step creative workflows while keeping completed Archive history independent. +- Portable receipts can be exported and checked in Beacon for internal consistency without making identity or authenticity claims. +- Practices offer optional scheduled creative prompts with rest, skip, pause, stop, reflection, Today, and Archive integration. +- Symphony saves bounded event scores for exact current-session replay plus WAV and MIDI export. Older prose-only sessions remain approximate. +- Journal Export turns selected Archive/Today/Project material into self-contained accessible HTML; Windows Print to PDF provides the PDF path. + +## Compatibility and data behavior + +Existing supported JSON records and Diary Markdown remain readable. The expansion uses versioned files and compatible normalizers rather than a destructive bulk migration. Source Archive records remain authoritative; annotations and derived cache are separate, and the cache can be rebuilt. Replacing the portable executable does not delete `%APPDATA%\com.sortilune.desktop` data. + +## Privacy and desktop behavior + +There is no account, telemetry, cloud sync, update check, autostart, tray process, background service, global shortcut, or clipboard monitor. Network access is limited to the five documented public-data hosts. The app does not send prompts, notes, reflections, records, packs, or journal content to those services. + +## Known limits + +- Windows 10 version 1803 or newer and Windows 11 are the certified platforms for this release. +- Distribution is a portable executable, not an installer; WebView2 must be available. +- HTML is the durable journal format. PDF output uses the Windows print dialog and does not claim PDF/A or identical pagination across viewers. +- There is no cloud synchronization, automatic update mechanism, or multi-device merge. +- Live network-backed actions depend on their public providers; saved local material remains available offline. + +## Post-audit corrections + +The final real-world pass corrected the native Today certificate-cache filename, removed an inappropriate network-style rate limit from on-device randomness, made source auto-save and scope explicit, restored Canvas configuration layout after generation errors, made randomness sources visible on every result surface, added a guaranteed Symphony opening phrase, and requested Open-Meteo wind values in the displayed unit. + +The Canvas follow-up strengthens Constellation, Spectrum, and Wave interference so their generated structure remains legible at application scale. Once a Canvas result exists, visualization, source, and palette changes regenerate it immediately; Decider and Lottery retain explicit action buttons but no longer describe them as settings-application steps. + +See `PRIVACY_AND_NETWORK.md`, `DATA_AND_EXPORT_FORMATS.md`, and `TROUBLESHOOTING.md` for the exact release contract. diff --git a/docs/release/TROUBLESHOOTING.md b/docs/release/TROUBLESHOOTING.md new file mode 100644 index 0000000..129d5ca --- /dev/null +++ b/docs/release/TROUBLESHOOTING.md @@ -0,0 +1,59 @@ +# Troubleshooting + +## The executable does not open + +- Confirm Windows 10 version 1803+ or Windows 11. +- Install or repair Microsoft WebView2 Runtime. +- Move the executable to a normal user-controlled folder and try again. +- If Windows reputation protection prompts, verify that the file came from the project’s release page and review the publisher/hash information available for that release before choosing what to do. + +The portable app does not install a service or background helper. A process that immediately exits should be treated as a startup failure, not as tray behavior. + +## A randomness source fails + +Open Settings and use **Test all sources**. Firewalls, DNS filters, captive portals, service downtime, and public rate limits can affect individual hosts. Disable a repeatedly failing optional source or select System when local fallback is acceptable. + +Today, Beacon, and high-stakes Decider deliberately show an error when their required NIST behavior is unavailable. New Symphony sessions need its live feeds. Do not expect these strict/live workflows to disguise an outage as a verified result. + +## My saved item is not in Archive + +- Open Archive and choose **Rebuild**. +- Clear the rebuildable Archive cache from Settings if needed. +- Check `%APPDATA%\com.sortilune.desktop\archive\` for the source file. +- Read any malformed-file warning shown by Archive; Sortilune leaves damaged files in place for manual recovery. + +Annotations and Project/Practice state live outside the raw Archive subtree. Back up the entire `%APPDATA%\com.sortilune.desktop\` directory for a complete restore. + +## A content pack will not import + +The import dialog accepts a bounded `.sortilune-pack.json` document. The error should identify invalid JSON, schema shape, semantic bounds, license expression, dependency, version/digest conflict, or unsupported content kind. Fix the source file; do not edit the installed registry manually. + +Use the examples and authoring guide under `docs/packs`. Packs are data-only, so JavaScript modules, HTML behavior, new URLs, and custom entropy adapters are intentionally rejected. + +## Symphony is silent or export is unavailable + +Browsers require a direct user gesture before starting audio; press Play in the Symphony screen. Check the in-app volume and Windows output device/mixer. A new live session also requires USGS/Open-Meteo/NIST responses. + +Exact replay/WAV/MIDI requires a new versioned score. Older prose-only Archive sessions are labeled approximate and intentionally cannot synthesize missing events. + +## Journal print/PDF does not save + +**Download HTML** is the direct durable export. **Print / Save as PDF** opens the Windows print dialog; choose Microsoft Print to PDF and a destination there. Sortilune does not silently write a PDF or bundle a second PDF renderer. + +Private Diary bodies, Practice reflections, and Project notes are excluded until **Include private writing** is checked. Source-note detail is controlled separately. + +## Wallpaper application fails + +Canvas can still download/export its image. Wallpaper application depends on Windows accepting the generated local PNG and the current desktop policy. Managed-device policy or an unsupported image operation may block only the wallpaper step. + +## A layout looks clipped + +The supported minimum window is 960×620. Navigation and document panels may scroll vertically; this is expected. At high Windows text scaling, keep the window at or above the minimum and use the rail’s own scrollbar to reach lower destinations. + +If a primary action cannot be reached by scrolling or keyboard focus at the supported minimum, capture the route, theme, Windows scaling percentage, and screenshot when reporting the bug. + +## Build or test fails + +Start from a clean dependency install with `npm ci`. Use the Node/Rust/MSVC versions described in `BUILD.md`. Run content/schema checks before tests, and do not run the production generator/build concurrently with a Vite WebDriver test because Vite correctly reloads when generated validators change. + +The native WebDriver build uses test-only Tauri capabilities and a separate application identifier. It is not the production executable. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b251b76 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,49 @@ +import js from '@eslint/js'; +import globals from 'globals'; + +export default [ + { + ignores: [ + 'dist/**', + 'node_modules/**', + 'src-tauri/**', + 'screenshots/**', + 'src/schemas/generated/**', + ], + }, + js.configs.recommended, + { + files: ['src/**/*.js'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.browser, + }, + rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, + { + files: ['tests/**/*.js', 'eslint.config.js', 'scripts/**/*.mjs', 'vite.config.js'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: globals.node, + }, + }, + { + files: ['tests/wdio/**/*.js'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.mocha, + browser: 'readonly', + $: 'readonly', + $$: 'readonly', + expect: 'readonly', + }, + }, + }, +]; diff --git a/package-lock.json b/package-lock.json index 6fc2326..e109868 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,119 @@ { "name": "sortilune", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sortilune", - "version": "0.1.0", + "version": "0.2.0", + "license": "MIT", "dependencies": { - "@tauri-apps/api": "^2.1.1", + "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-fs": "^2.0.3", - "@tauri-apps/plugin-http": "^2.0.4" + "@tauri-apps/plugin-http": "^2.0.4", + "spdx-expression-parse": "3.0.1" }, "devDependencies": { - "@tauri-apps/cli": "^2.1.0", - "vite": "^5.4.10" + "@axe-core/webdriverio": "4.12.1", + "@eslint/js": "^10.0.1", + "@tauri-apps/cli": "^2.11.4", + "@wdio/cli": "9.29.1", + "@wdio/local-runner": "9.29.1", + "@wdio/mocha-framework": "9.29.1", + "@wdio/spec-reporter": "9.29.1", + "@wdio/tauri-plugin": "1.2.0", + "@wdio/tauri-service": "1.2.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "eslint": "^10.7.0", + "globals": "^17.7.0", + "puppeteer-core": "24.43.1", + "tsx": "4.23.1", + "typescript": "7.0.2", + "vite": "^8.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@axe-core/webdriverio": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/webdriverio/-/webdriverio-4.12.1.tgz", + "integrity": "sha512-KdEQxFQ28iFYrK/1ZuTDtSPF3t+UEACQwLEYn1pPDwRa3oriMRHCjJeC46kGxdPKAMW80z8Q6eVIduShHZl8pA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1", + "cssesc": "^3.0.0" + }, + "peerDependencies": { + "webdriverio": "^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -31,13 +124,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -48,13 +141,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -65,13 +158,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -82,13 +175,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -99,13 +192,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -116,13 +209,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -133,13 +226,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -150,13 +243,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -167,13 +260,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -184,13 +277,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -201,13 +294,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -218,13 +311,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -235,13 +328,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -252,13 +345,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -269,13 +362,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -286,13 +379,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -303,15 +396,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -320,13 +413,13 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -334,67 +427,67 @@ "license": "MIT", "optional": true, "os": [ - "openbsd" + "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "sunos" + "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ - "ia32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "openharmony" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -402,30 +495,16 @@ "license": "MIT", "optional": true, "os": [ - "win32" + "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -433,27 +512,33 @@ "license": "MIT", "optional": true, "os": [ - "android" - ] + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" - ] + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -461,831 +546,8997 @@ "license": "MIT", "optional": true, "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", - "cpu": [ - "arm64" + "win32" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": ">=18" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, - "libc": [ - "musl" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", - "cpu": [ - "loong64" - ], + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", - "cpu": [ - "loong64" - ], + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, - "libc": [ - "musl" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", - "cpu": [ - "riscv64" - ], + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/types/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@promptbook/utils": { + "version": "0.69.5", + "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz", + "integrity": "sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/hejny" + }, + { + "type": "github", + "url": "https://github.com/webgptorg/promptbook/blob/main/README.md#%EF%B8%8F-contributing" + } + ], + "license": "CC-BY-4.0", + "dependencies": { + "spacetrim": "0.11.59" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.50", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.50.tgz", + "integrity": "sha512-ydBWw0G6WFwWHzh9RK4B5c690UkreOG0llq0r+DaI7LgKgxigf8mhHzIPI3S0850g1BPkq/zpuCfrq4QFgUlTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", + "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-http": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", + "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, + "node_modules/@tauri-apps/plugin-log": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-log/-/plugin-log-2.8.0.tgz", + "integrity": "sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", + "integrity": "sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@wdio/cli": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-9.29.1.tgz", + "integrity": "sha512-MjRHdM5mGuibhwv+pu8rJD1Pxl6JVj4Pvy9stuj/SjbTqWRxjLauLd3i7+tIMdmrK0ajGO0n249HT8vS8Mh0Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/snapshot": "^2.1.1", + "@wdio/config": "9.29.1", + "@wdio/globals": "9.29.1", + "@wdio/logger": "9.29.1", + "@wdio/protocols": "9.29.1", + "@wdio/types": "9.29.1", + "@wdio/utils": "9.29.1", + "async-exit-hook": "^2.0.1", + "chalk": "^5.4.1", + "chokidar": "^4.0.0", + "create-wdio": "9.29.1", + "dotenv": "^17.2.0", + "import-meta-resolve": "^4.0.0", + "lodash.flattendeep": "^4.4.0", + "lodash.pickby": "^4.6.0", + "lodash.union": "^4.6.0", + "read-pkg-up": "^10.0.0", + "tsx": "^4.7.2", + "webdriverio": "9.29.1", + "yargs": "^17.7.2" + }, + "bin": { + "wdio": "bin/wdio.js" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/config": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.29.1.tgz", + "integrity": "sha512-8IXDiRG9wUUnpU6M/uzsVqIHJD/7o4y9RaOU2Jh/OdRJP/7rxwfGsa24Bv486rnMGdghztkwLCBJWG0jbeEtfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "9.29.1", + "@wdio/types": "9.29.1", + "@wdio/utils": "9.29.1", + "deepmerge-ts": "^7.0.3", + "glob": "^10.2.2", + "import-meta-resolve": "^4.0.0", + "jiti": "^2.6.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/dot-reporter": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/dot-reporter/-/dot-reporter-9.29.1.tgz", + "integrity": "sha512-5UVgxKHVoJfJVSg3VH9n0yPkstHzX65g5zRBtNX51hqXmSPRE9kSLGq53Lo91UTORc0og3i0UT93zZqEQhpzjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/reporter": "9.29.1", + "@wdio/types": "9.29.1", + "chalk": "^5.0.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/globals": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/globals/-/globals-9.29.1.tgz", + "integrity": "sha512-F96BKppx4HGD64v+s57TM4K4zaxqUCg2RXHk6sjB2xrSa7P+d2VYV28ID2ddF7iSodVfPlpa1i2/jy8jMGrU8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.0" + }, + "peerDependencies": { + "expect-webdriverio": "^5.6.5", + "webdriverio": "^9.0.0" + }, + "peerDependenciesMeta": { + "expect-webdriverio": { + "optional": false + }, + "webdriverio": { + "optional": false + } + } + }, + "node_modules/@wdio/local-runner": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/local-runner/-/local-runner-9.29.1.tgz", + "integrity": "sha512-ypgi3gTZYY3eStp1U9H6Gjl0mer55CYMdbtRQ27s6evN1QABcwkGCTuzwqHoO6RmKzwPOuht2mnWb9HwYJaNnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0", + "@wdio/logger": "9.29.1", + "@wdio/repl": "9.16.2", + "@wdio/runner": "9.29.1", + "@wdio/types": "9.29.1", + "@wdio/xvfb": "9.29.1", + "exit-hook": "^4.0.0", + "expect-webdriverio": "^5.6.5", + "split2": "^4.1.0", + "stream-buffers": "^3.0.2" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/local-runner/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/local-runner/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/logger": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.29.1.tgz", + "integrity": "sha512-0ZAEIo6PNyMIJPlOGkIgyOJUjcd0pC8/QHlVAAe1c91/IcjZ1X+k0yidXHaboJdN7dq1XPUacmhRdtua0U5EZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.1.2", + "loglevel": "^1.6.0", + "loglevel-plugin-prefix": "^0.8.4", + "safe-regex2": "^5.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/logger/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wdio/logger/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@wdio/mocha-framework": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/mocha-framework/-/mocha-framework-9.29.1.tgz", + "integrity": "sha512-GG3z0OtD2eu15ql3vnI6VcLJ3INUfSrqbuop/tCAbCjkkKgpjq1JOno0lrDNKRt5Ndq4a4/c9Wu5uxfL+CItYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mocha": "^10.0.6", + "@types/node": "^20.11.28", + "@wdio/logger": "9.29.1", + "@wdio/types": "9.29.1", + "@wdio/utils": "9.29.1", + "mocha": "^10.3.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/mocha-framework/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/mocha-framework/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/native-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wdio/native-core/-/native-core-1.0.0.tgz", + "integrity": "sha512-SBVipUZk1+fiwwyGkDjp0gZsV+AGlZ1NE3rPviPCzs6QKm6Qmj7di/05P5MdqYUq/PslrVEWNIp6TYfL+wjEKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "9.18.0", + "@wdio/native-types": "2.3.1", + "@wdio/native-utils": "2.4.0", + "debug": "^4.4.3", + "get-port": "^7.1.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.11.0" + } + }, + "node_modules/@wdio/native-core/node_modules/@wdio/logger": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.18.0.tgz", + "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.1.2", + "loglevel": "^1.6.0", + "loglevel-plugin-prefix": "^0.8.4", + "safe-regex2": "^5.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/native-core/node_modules/@wdio/native-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@wdio/native-types/-/native-types-2.3.1.tgz", + "integrity": "sha512-q6BOAd7Yg8lQJJWW/z1a4ratTttMIy39MfbAIgw8PKrXB6Oc+x95KXkuIcCGDINTk+X92Lbjxefiww4jiPam8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.11.0" + } + }, + "node_modules/@wdio/native-core/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wdio/native-core/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@wdio/native-spy": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@wdio/native-spy/-/native-spy-1.1.0.tgz", + "integrity": "sha512-1BTbiWo9fNOsnSRYTjBE0Z6PtknFMtN7/TH2SsnishkIABUHH8ONKB+NWiTCiX8IkFiARguo2bJHEQBOULXlwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.11.0" + } + }, + "node_modules/@wdio/native-types": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@wdio/native-types/-/native-types-2.4.0.tgz", + "integrity": "sha512-GjgPskOoTvl17Jwj8aAU3A31gpklL5M1KfbrwYffbQT16CTpJTD8A4PdoS2ZjGoCWcyLBR1CWdJI3W8T0JBLjw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.11.0" + } + }, + "node_modules/@wdio/native-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@wdio/native-utils/-/native-utils-2.5.0.tgz", + "integrity": "sha512-Qv0mJ2elRBZCgYbHCnKPNgE9VaqnmNJyjythTDYyOQpj8pAs6FZXXmWXGkUIdMds5cK0ux/qtxyKU0Y0tDsUUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "9.18.0", + "debug": "^4.4.3", + "esbuild": "^0.28.0", + "find-up-simple": "^1.0.1", + "json5": "^2.2.3", + "smol-toml": "^1.6.0", + "yaml": "^2.9.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.11.0" + }, + "peerDependencies": { + "tsx": "^4.22.4" + }, + "peerDependenciesMeta": { + "tsx": { + "optional": true + } + } + }, + "node_modules/@wdio/native-utils/node_modules/@wdio/logger": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.18.0.tgz", + "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.1.2", + "loglevel": "^1.6.0", + "loglevel-plugin-prefix": "^0.8.4", + "safe-regex2": "^5.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/native-utils/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wdio/native-utils/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@wdio/protocols": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.29.1.tgz", + "integrity": "sha512-NFlBQOA4zDb4D/ETpVMqDgbJyEqdhGRsJWybLOXG7PGlPwfcrfmTMHC1+Boq4KODgpwbhCmI9zIk2JQmGYIttQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/repl": { + "version": "9.16.2", + "resolved": "https://registry.npmjs.org/@wdio/repl/-/repl-9.16.2.tgz", + "integrity": "sha512-FLTF0VL6+o5BSTCO7yLSXocm3kUnu31zYwzdsz4n9s5YWt83sCtzGZlZpt7TaTzb3jVUfxuHNQDTb8UMkCu0lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/repl/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/repl/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/reporter": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/reporter/-/reporter-9.29.1.tgz", + "integrity": "sha512-CKAcVy9BGwvufokMcl3H+yNcvD11Klku/1BPz8bKSRv7/KzueXR06s1cPbJH3tv9r9zxPErxgdVMpulN8I0qAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0", + "@wdio/logger": "9.29.1", + "@wdio/types": "9.29.1", + "diff": "^8.0.2", + "object-inspect": "^1.12.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/reporter/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/reporter/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/runner": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/runner/-/runner-9.29.1.tgz", + "integrity": "sha512-LV9+0U74J1YModG0T64Kdnh+xvOcd9MWGFlKyXnCtfFDV+47K9BpoDMrc/ng3daLrIKcZGVbym+GhEShiM0DPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.11.28", + "@wdio/config": "9.29.1", + "@wdio/dot-reporter": "9.29.1", + "@wdio/globals": "9.29.1", + "@wdio/logger": "9.29.1", + "@wdio/types": "9.29.1", + "@wdio/utils": "9.29.1", + "deepmerge-ts": "^7.0.3", + "webdriver": "9.29.1", + "webdriverio": "9.29.1" + }, + "engines": { + "node": ">=18.20.0" + }, + "peerDependencies": { + "expect-webdriverio": "^5.6.5", + "webdriverio": "^9.0.0" + }, + "peerDependenciesMeta": { + "expect-webdriverio": { + "optional": false + }, + "webdriverio": { + "optional": false + } + } + }, + "node_modules/@wdio/runner/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/runner/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/spec-reporter": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/spec-reporter/-/spec-reporter-9.29.1.tgz", + "integrity": "sha512-iqlHW/qGDRGmxQKN7XyCHxfKphTbPNnniV3yxNXZRSGHCCQok5KFKOnj4fpUCKEyD5jtPAmP6Y0qc1UyR3Dc3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/reporter": "9.29.1", + "@wdio/types": "9.29.1", + "chalk": "^5.1.2", + "easy-table": "^1.2.0", + "pretty-ms": "^9.0.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-plugin": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@wdio/tauri-plugin/-/tauri-plugin-1.2.0.tgz", + "integrity": "sha512-qeQQ4D0Q4BkwC+Cyez1I343DRm9UErCXksZ/DN1qCpJCorEEPKL3IiYb34OtNJBgnbhqwtGukyjOZYvnpPz95A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tauri-apps/api": "2.11.0", + "@tauri-apps/plugin-log": "2.8.0", + "@wdio/native-spy": "1.1.0", + "@wdio/native-utils": "2.4.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.11.0" + } + }, + "node_modules/@wdio/tauri-plugin/node_modules/@tauri-apps/api": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", + "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@wdio/tauri-service": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@wdio/tauri-service/-/tauri-service-1.2.0.tgz", + "integrity": "sha512-/PEmbDKra6Lsodes5x1Sg5H98bG4hObYZWMyrlbv0e5fL0KVMQMxDP798jWWIqT6+iOPsIlkLirRJKfXXGVtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/globals": "9.27.1", + "@wdio/logger": "9.18.0", + "@wdio/native-core": "1.0.0", + "@wdio/native-spy": "1.1.0", + "@wdio/native-types": "2.4.0", + "@wdio/native-utils": "2.4.0", + "@wdio/spec-reporter": "9.27.1", + "@wdio/types": "9.27.1", + "debug": "^4.4.3", + "get-port": "^7.1.0", + "tslib": "^2.8.1", + "webdriverio": "9.27.1" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.11.0" + }, + "peerDependencies": { + "webdriverio": "^9.0.0" + }, + "peerDependenciesMeta": { + "webdriverio": { + "optional": false + } + } + }, + "node_modules/@wdio/tauri-service/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/config": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.1.tgz", + "integrity": "sha512-QVfSCqcpMfVum9KlpxgjaLlSLXkc53UQ2CPJU+IUVBp8LkbSyeX972HQS8V9Hnn6vSPE1dYScItg7wblnJ8RQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "9.18.0", + "@wdio/types": "9.27.1", + "@wdio/utils": "9.27.1", + "deepmerge-ts": "^7.0.3", + "glob": "^10.2.2", + "import-meta-resolve": "^4.0.0", + "jiti": "^2.6.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/globals": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/@wdio/globals/-/globals-9.27.1.tgz", + "integrity": "sha512-jm6gTQ6Qo3EOBY6PA09U/5Pf17WLEJM1/lTfhc6jzLFE770EuhuhbuphqrInH0hVR9WMyWtSZQ+LRCcLfcmOPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20.0" + }, + "peerDependencies": { + "expect-webdriverio": "^5.6.5", + "webdriverio": "^9.0.0" + }, + "peerDependenciesMeta": { + "expect-webdriverio": { + "optional": false + }, + "webdriverio": { + "optional": false + } + } + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/logger": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.18.0.tgz", + "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.1.2", + "loglevel": "^1.6.0", + "loglevel-plugin-prefix": "^0.8.4", + "safe-regex2": "^5.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/protocols": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.27.1.tgz", + "integrity": "sha512-Ril46AmySoiYX9nuKqFr3SNJqquU3VmF9FzSndQlDib0G3oA4pYx9wcBXvdvkFxRjjmFwQDzmvztKrssAHymgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/reporter": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/@wdio/reporter/-/reporter-9.27.1.tgz", + "integrity": "sha512-2ueVjd5hOCclfC+GV3yhaN/4Tids1mXMcpPtNTPushHIQY4gLmBqqKDe5RSXAED3bNU+DRdHq2uBiZTBd4QDJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0", + "@wdio/logger": "9.18.0", + "@wdio/types": "9.27.1", + "diff": "^8.0.2", + "object-inspect": "^1.12.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/spec-reporter": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/@wdio/spec-reporter/-/spec-reporter-9.27.1.tgz", + "integrity": "sha512-q9UMJJbCcP+nCOojIvOIcsXnerhHICmWu94guRMRYPbW2IsG/5VM/uhzwru8SU/1WRXLtKgTjkuXXsjzjVpf2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/reporter": "9.27.1", + "@wdio/types": "9.27.1", + "chalk": "^5.1.2", + "easy-table": "^1.2.0", + "pretty-ms": "^9.0.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/types": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.27.1.tgz", + "integrity": "sha512-EHBNCvLmvpYerln4mb/OBxzKtnavL2wdenjhwuYjzkZMOWHgm/uLXH6sLThM0y6DIbCU72Asth16fo1eDcsofA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/@wdio/utils": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.27.1.tgz", + "integrity": "sha512-s2w1tFrvmpdkZ33LYsIw4ONRdWIIm4MxkyIuibbcG1ILV5fFMS9rU59csHuWIM0KhJoEoLU+fzE3ze9O7TpWhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@puppeteer/browsers": "^2.2.0", + "@wdio/logger": "9.18.0", + "@wdio/types": "9.27.1", + "decamelize": "^6.0.0", + "deepmerge-ts": "^7.0.3", + "edgedriver": "^6.1.2", + "geckodriver": "^6.1.0", + "get-port": "^7.0.0", + "import-meta-resolve": "^4.0.0", + "locate-app": "^2.2.24", + "mitt": "^3.0.1", + "safaridriver": "^1.0.0", + "split2": "^4.2.0", + "wait-port": "^1.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wdio/tauri-service/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@wdio/tauri-service/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/tauri-service/node_modules/webdriver": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.27.1.tgz", + "integrity": "sha512-vr6h+RNQ75O2cofgVrdupGxtKjPEBaBYx/lHCHe0giJfAK01oL0U/yrOksJi7kmpev/daN93ldFPhlIlmWtv8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0", + "@types/ws": "^8.5.3", + "@wdio/config": "9.27.1", + "@wdio/logger": "9.18.0", + "@wdio/protocols": "9.27.1", + "@wdio/types": "9.27.1", + "@wdio/utils": "9.27.1", + "deepmerge-ts": "^7.0.3", + "https-proxy-agent": "^7.0.6", + "undici": "^6.21.3", + "ws": "^8.8.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/tauri-service/node_modules/webdriverio": { + "version": "9.27.1", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.27.1.tgz", + "integrity": "sha512-iPaIU/DluYY7zfLiwXDdoLU/6ZW8eup4PNwQikrCzTfvH/ITllRhFUe6NRDTEEePSxxRTeXAn9nehCs98xWGVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.11.30", + "@types/sinonjs__fake-timers": "^8.1.5", + "@wdio/config": "9.27.1", + "@wdio/logger": "9.18.0", + "@wdio/protocols": "9.27.1", + "@wdio/repl": "9.16.2", + "@wdio/types": "9.27.1", + "@wdio/utils": "9.27.1", + "archiver": "^7.0.1", + "aria-query": "^5.3.0", + "cheerio": "^1.0.0-rc.12", + "css-shorthand-properties": "^1.1.1", + "css-value": "^0.0.1", + "grapheme-splitter": "^1.0.4", + "htmlfy": "^0.8.1", + "is-plain-obj": "^4.1.0", + "jszip": "^3.10.1", + "lodash.clonedeep": "^4.5.0", + "lodash.zip": "^4.2.0", + "query-selector-shadow-dom": "^1.0.1", + "resq": "^1.11.0", + "rgb2hex": "0.2.5", + "serialize-error": "^12.0.0", + "urlpattern-polyfill": "^10.0.0", + "webdriver": "9.27.1" + }, + "engines": { + "node": ">=18.20.0" + }, + "peerDependencies": { + "puppeteer-core": ">=22.x || <=24.x" + }, + "peerDependenciesMeta": { + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/@wdio/types": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.29.1.tgz", + "integrity": "sha512-jp8jgMv6TS35G96YzHZxw3PVN0Dz6xQ6tnMAicndAJ8Jt9AIXb0ywIse4TjaFywakO3dLoEhlyA3ZtR26vmt+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/types/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/types/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/utils": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.29.1.tgz", + "integrity": "sha512-jyt6b6FfdYwVbMISVhuyGC1xQGZj6xM03KhTHozH7a9/zu9b++94KRdT9HRbwX8zefjW0YeIC5+qWSIf7WWHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@puppeteer/browsers": "^2.2.0", + "@wdio/logger": "9.29.1", + "@wdio/types": "9.29.1", + "decamelize": "^6.0.0", + "deepmerge-ts": "^7.0.3", + "edgedriver": "^6.1.2", + "geckodriver": "^6.1.0", + "get-port": "^7.0.0", + "import-meta-resolve": "^4.0.0", + "locate-app": "^2.2.24", + "mitt": "^3.0.1", + "safaridriver": "^1.0.0", + "split2": "^4.2.0", + "wait-port": "^1.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/xvfb": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/@wdio/xvfb/-/xvfb-9.29.1.tgz", + "integrity": "sha512-suC0EJcPVldpIyJA/UB9cV11PkW4sGgNmLHPhWU7NiASnbiFeHjK3EbevjzlwwBNYXx1KHO4jMY4Rep2wwVNuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "9.29.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.26", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", + "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/create-wdio": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/create-wdio/-/create-wdio-9.29.1.tgz", + "integrity": "sha512-EWef5jtJ9pN+tYblwZvWwDEKEgZSMy8VZCUCWBIiw3Z48aDbqusxpOseZWZ/rAttsqGyntzIvHLxIo0r7kryxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^14.0.0", + "cross-spawn": "^7.0.3", + "ejs": "^3.1.10", + "execa": "^9.6.0", + "import-meta-resolve": "^4.1.0", + "inquirer": "^12.7.0", + "normalize-package-data": "^7.0.0", + "read-pkg-up": "^10.1.0", + "recursive-readdir": "^2.2.3", + "semver": "^7.6.3", + "type-fest": "^4.41.0", + "yargs": "^17.7.2" + }, + "bin": { + "create-wdio": "bin/wdio.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-shorthand-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/css-shorthand-properties/-/css-shorthand-properties-1.1.2.tgz", + "integrity": "sha512-C2AugXIpRGQTxaCW0N7n5jD/p5irUmCrwl03TrnMFBHDbdq44CFWR2zO7rK9xPN4Eo3pUxC4vQzQgbIpzrD1PQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-value": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/css-value/-/css-value-0.0.1.tgz", + "integrity": "sha512-FUV3xaJ63buRLgHrLQVlVgQnQdR4yqdLGaDu7g8CQcWjInDfM9plBTPI9FRfpahju1UBSaMckeb2/46ApS/V1Q==", + "dev": true + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", + "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/easy-table": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/easy-table/-/easy-table-1.2.0.tgz", + "integrity": "sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "optionalDependencies": { + "wcwidth": "^1.0.1" + } + }, + "node_modules/edge-paths": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/edge-paths/-/edge-paths-3.0.5.tgz", + "integrity": "sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/which": "^2.0.1", + "which": "^2.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/shirshak55" + } + }, + "node_modules/edgedriver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/edgedriver/-/edgedriver-6.3.0.tgz", + "integrity": "sha512-ggEQL+oEyIcM4nP2QC3AtCQ04o4kDNefRM3hja0odvlPSnsaxiruMxEZ93v3gDCKWYW6BXUr51PPradb+3nffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "^9.18.0", + "@zip.js/zip.js": "^2.8.11", + "decamelize": "^6.0.1", + "edge-paths": "^3.0.5", + "fast-xml-parser": "^5.3.3", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "which": "^6.0.0" + }, + "bin": { + "edgedriver": "bin/edgedriver.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/edgedriver/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/edgedriver/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", + "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exit-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-4.0.0.tgz", + "integrity": "sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/expect-webdriverio": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/expect-webdriverio/-/expect-webdriverio-5.7.0.tgz", + "integrity": "sha512-jLOTrJoPBC3Wtd83ryHMbRcEajMGtAnn6OWtSnoJoDLt16nHvxVdjcI4Bc0n+1KAOr8DvQtlJ55f0M48kJtEpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/snapshot": "^4.1.7", + "deep-eql": "^5.0.2", + "expect": "^30.4.1", + "jest-matcher-utils": "^30.4.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@wdio/globals": "^9.0.0", + "@wdio/logger": "^9.0.0", + "webdriverio": "^9.0.0" + }, + "peerDependenciesMeta": { + "@wdio/globals": { + "optional": false + }, + "@wdio/logger": { + "optional": false + }, + "webdriverio": { + "optional": false + } + } + }, + "node_modules/expect-webdriverio/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/expect-webdriverio/node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/expect-webdriverio/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-webdriverio/node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/geckodriver": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", + "integrity": "sha512-ZRXLa4ZaYTTgUO4Eefw+RsQCleugU2QLb1ME7qTYxxuRj51yAhfnXaItXNs5/vUzfIaDHuZ+YnSF005hfp07nQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "^9.18.0", + "@zip.js/zip.js": "^2.8.11", + "decamelize": "^6.0.1", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "modern-tar": "^0.7.2" + }, + "bin": { + "geckodriver": "bin/geckodriver.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-port": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hosted-git-info": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-8.1.0.tgz", + "integrity": "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/htmlfy": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", + "integrity": "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.11.1.tgz", + "integrity": "sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/prompts": "^7.10.1", + "@inquirer/type": "^3.0.10", + "mute-stream": "^2.0.0", + "run-async": "^4.0.6", + "rxjs": "^7.8.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-regex-util": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", + "integrity": "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, "libc": [ "glibc" ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-app": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/locate-app/-/locate-app-2.5.0.tgz", + "integrity": "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/hejny" + }, + { + "type": "github", + "url": "https://github.com/hejny/locate-app/blob/main/README.md#%EF%B8%8F-contributing" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@promptbook/utils": "0.69.5", + "type-fest": "4.26.0", + "userhome": "1.0.1" + } + }, + "node_modules/locate-app/node_modules/type-fest": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", + "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.pickby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", + "integrity": "sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loglevel-plugin-prefix": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz", + "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mocha/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/normalize-package-data": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz", + "integrity": "sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-json": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-7.1.1.tgz", + "integrity": "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.21.4", + "error-ex": "^1.3.2", + "json-parse-even-better-errors": "^3.0.0", + "lines-and-columns": "^2.0.3", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-json/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-pkg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-8.1.0.tgz", + "integrity": "sha512-PORM8AgzXeskHO/WEv312k9U03B8K9JSiWF/8N9sUuFjBa+9SF2u6K7VClzXwDXab51jCd8Nd36CNM+zR97ScQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.1", + "normalize-package-data": "^6.0.0", + "parse-json": "^7.0.0", + "type-fest": "^4.2.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-10.1.0.tgz", + "integrity": "sha512-aNtBq4jR8NawpKJQldrQcSW9y/d+KWH4v24HWkHljOZ7H0av+YTGANBzRh9A5pw7v/bLVsLVPpOhJ7gHNVy8lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^6.3.0", + "read-pkg": "^8.1.0", + "type-fest": "^4.2.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/read-pkg-up/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/read-pkg/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/recursive-readdir/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/recursive-readdir/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/recursive-readdir/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resq": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/resq/-/resq-1.11.0.tgz", + "integrity": "sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/resq/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rgb2hex": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.2.5.tgz", + "integrity": "sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/run-async": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", + "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safaridriver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safaridriver/-/safaridriver-1.0.1.tgz", + "integrity": "sha512-jkg4434cYgtrIF2AeY/X0Wmd2W73cK5qIEFE3hDrrQenJH/2SDJIXGvPAigfvQTcE9+H31zkiNHbUqcihEiMRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-error": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz", + "integrity": "sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.31.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", - "cpu": [ - "riscv64" - ], + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, - "libc": [ - "musl" - ], "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", - "cpu": [ - "s390x" + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spacetrim": { + "version": "0.11.59", + "resolved": "https://registry.npmjs.org/spacetrim/-/spacetrim-0.11.59.tgz", + "integrity": "sha512-lLYsktklSRKprreOm7NXReW8YiX2VBjbgmXYEziOoGf/qsJqAEACaDvoTtUOycwjpaSh+bT8eu0KrJn7UNxiCg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/hejny" + }, + { + "type": "github", + "url": "https://github.com/hejny/spacetrim/blob/main/README.md#%EF%B8%8F-contributing" + } ], + "license": "Apache-2.0" + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, - "libc": [ - "glibc" + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stream-buffers": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", + "integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", - "cpu": [ - "x64" - ], + "node_modules/undici": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=18.17" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", - "cpu": [ - "x64" - ], + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", - "cpu": [ - "x64" - ], + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", - "cpu": [ - "arm64" - ], + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", - "cpu": [ - "arm64" - ], + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", - "cpu": [ - "ia32" - ], + "node_modules/userhome": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/userhome/-/userhome-1.0.1.tgz", + "integrity": "sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">= 0.8.0" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", - "cpu": [ - "x64" - ], + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", - "cpu": [ - "x64" - ], + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tauri-apps/api": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", - "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" } }, - "node_modules/@tauri-apps/cli": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.1.tgz", - "integrity": "sha512-rpEbaJ/HzNb6fwsquwoAbq29/Vt4gADhS423A8fdkwL4edJ0wZmoB8ar7O6JPDL834MUKOCm/rrJ7c9oAaEaYQ==", + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", "dev": true, - "license": "Apache-2.0 OR MIT", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, "bin": { - "tauri": "tauri.js" + "vite": "bin/vite.js" }, "engines": { - "node": ">= 10" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" + "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.1", - "@tauri-apps/cli-darwin-x64": "2.11.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.1", - "@tauri-apps/cli-linux-arm64-musl": "2.11.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.1", - "@tauri-apps/cli-linux-x64-gnu": "2.11.1", - "@tauri-apps/cli-linux-x64-musl": "2.11.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.1", - "@tauri-apps/cli-win32-x64-msvc": "2.11.1" + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.1.tgz", - "integrity": "sha512-6eEKMBXsQPCuM1EmvrjT2+aBuxWQuFdKdW8pzNuNQtpq45nEEpBlD5gr8pUeAyOU1DQKlkFaEc/MPBxb/Pfjtg==", - "cpu": [ - "arm64" - ], + "node_modules/wait-port": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", + "integrity": "sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==", "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "commander": "^9.3.0", + "debug": "^4.3.4" + }, + "bin": { + "wait-port": "bin/wait-port.js" + }, "engines": { - "node": ">= 10" + "node": ">=10" } }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.1.tgz", - "integrity": "sha512-LQUO7exfRWjWALNhetph5guWpMeHphRpokOLk0OIbTTExaNwJNFu3I4vb+CCM/4G/QGoZe/5XikZOJdNEFP1ig==", - "cpu": [ - "x64" - ], + "node_modules/wait-port/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">= 10" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.1.tgz", - "integrity": "sha512-5i/awiBCRRhOUG8yjn0fMHXIWD5Ez8eEk5LtvOxyQrKuJkRaZDvnbIjZbE183blAwkoA4xN3aO/prJiqscl02Q==", - "cpu": [ - "arm" - ], + "node_modules/wait-port/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">= 10" + "node": "^12.20.0 || >=14" } }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.1.tgz", - "integrity": "sha512-9LrwDw3S9Fygtw/Q6WDhOP+3svJRGAsejeE+GKrc0eO1ThMVhwi2LL6hw4dlKw93IfS7VY1G19sWGxJ/NcU4nA==", - "cpu": [ - "arm64" - ], + "node_modules/wait-port/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">= 10" + "node": ">=8" } }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.1.tgz", - "integrity": "sha512-mNA5dbbqPqDUdTIwdUYYuhO2GvIe9UnB2r0VU2njxBOS3Opbx4gKNC5yP0Iu4rYmEmqdlwry9VzGZQ3wq9dyFg==", - "cpu": [ - "arm64" - ], + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", + "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webdriver": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.29.1.tgz", + "integrity": "sha512-uhxYap3qQXC9H2V8SDr7vcy0blZETeri4goLwbw1TFq4EZHF1Dv503Zc0PAjfkNQJCD4/rzAyr07+EX72kx1pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0", + "@types/ws": "^8.5.3", + "@wdio/config": "9.29.1", + "@wdio/logger": "9.29.1", + "@wdio/protocols": "9.29.1", + "@wdio/types": "9.29.1", + "@wdio/utils": "9.29.1", + "deepmerge-ts": "^7.0.3", + "https-proxy-agent": "^7.0.6", + "undici": "^6.21.3", + "ws": "^8.8.0" + }, "engines": { - "node": ">= 10" + "node": ">=18.20.0" } }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.1.tgz", - "integrity": "sha512-fZj3Gwq+6fUs305T5WQiD5iSGJw+j/4w/HGmk4sHDAcy+rp9zU5eaxB7nOyz5/I/nkNAuKPqfp6uIbiUBXkBCw==", - "cpu": [ - "riscv64" - ], + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0" + }, + "node_modules/webdriver/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/webdriver/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webdriverio": { + "version": "9.29.1", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.29.1.tgz", + "integrity": "sha512-UIplAnvbjdE0tucHVR/8Uk0Y7rz72VaEx/s0Tq91fMdXm+m+Za16e+b5tObh4xEEfyCITODPfzgBva9rA+xApQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.11.30", + "@types/sinonjs__fake-timers": "^8.1.5", + "@wdio/config": "9.29.1", + "@wdio/logger": "9.29.1", + "@wdio/protocols": "9.29.1", + "@wdio/repl": "9.16.2", + "@wdio/types": "9.29.1", + "@wdio/utils": "9.29.1", + "archiver": "^7.0.1", + "aria-query": "^5.3.0", + "cheerio": "^1.0.0-rc.12", + "css-shorthand-properties": "^1.1.1", + "css-value": "^0.0.1", + "grapheme-splitter": "^1.0.4", + "htmlfy": "^0.8.1", + "is-plain-obj": "^4.1.0", + "jszip": "^3.10.1", + "lodash.clonedeep": "^4.5.0", + "lodash.zip": "^4.2.0", + "query-selector-shadow-dom": "^1.0.1", + "resq": "^1.11.0", + "rgb2hex": "0.2.5", + "serialize-error": "^12.0.0", + "urlpattern-polyfill": "^10.0.0", + "webdriver": "9.29.1" + }, "engines": { - "node": ">= 10" + "node": ">=18.20.0" + }, + "peerDependencies": { + "puppeteer-core": ">=22.x || <=24.x" + }, + "peerDependenciesMeta": { + "puppeteer-core": { + "optional": true + } } }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.1.tgz", - "integrity": "sha512-XFxGxOvHM7jjeD6ozCKdGfhzJ7lERYDGZl1/Kb4fsvchaJsfLJ981TlyTG8Qy/gFq+f5GitH3bfrX9JAkjPEyw==", - "cpu": [ - "x64" - ], + "node_modules/webdriverio/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/webdriverio/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, "engines": { - "node": ">= 10" + "node": ">=18" } }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.1.tgz", - "integrity": "sha512-d5C2/Zm+68v7R9wTuTCjRQEVrWjcdMkJBZ1+rXse+QdMMlTB9+u9PDNDLw9PQflWxYLaYZ7tjxxL9Nb9II6PbA==", - "cpu": [ - "x64" - ], + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">= 10" + "node": ">=0.10.0" } }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.1.tgz", - "integrity": "sha512-YdeVWFAR1pTXzUU6NLstPq4G6OLxuDrXCXEBdmBH+5EZIDXUx0D2kJlz3+YjpazkKvAzYpgziTsyRagls0OfRQ==", - "cpu": [ - "arm64" - ], + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=18" } }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.1.tgz", - "integrity": "sha512-VBGkuH0eB9K9LLSMv361Gzr5Ou72sCS4+ztpmkWEQ+wd/amhcYOsf3X6qn1RJZDzIhiOYHJEOysZUC3baD01rA==", - "cpu": [ - "ia32" - ], + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, "engines": { - "node": ">= 10" + "node": ">= 8" } }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.1.tgz", - "integrity": "sha512-b3ORhIAKgp9ZYY+zBt7b7r0kLU2kjvyGF0+MS2SBym3emsweGPybEqocJcmtMuxyBhkOKHP4CiuEJEDuAlTx6A==", - "cpu": [ - "x64" - ], + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", "engines": { - "node": ">= 10" + "node": ">=0.10.0" } }, - "node_modules/@tauri-apps/plugin-fs": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz", - "integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.11.0" - } + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" }, - "node_modules/@tauri-apps/plugin-http": { - "version": "2.5.9", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", - "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", - "license": "MIT OR Apache-2.0", + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", "dependencies": { - "@tauri-apps/api": "^2.11.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/ai" + "url": "https://github.com/sponsors/NaturalIntelligence" } ], "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=16.0.0" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": ">=10" + } }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" } }, - "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", - "fsevents": "~2.3.2" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/yargs-unparser/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=8" } }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=10" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, - "optionalDependencies": { - "fsevents": "~2.3.3" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } + "engines": { + "node": ">= 14" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" } } } diff --git a/package.json b/package.json index 4433d16..f9657b2 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,74 @@ { "name": "sortilune", - "version": "0.1.0", + "version": "0.2.0", "description": "Moon-cast lots: divination by physical randomness from the universe.", + "license": "MIT", "private": true, "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "scripts": { "dev": "vite", - "build": "vite build", + "test": "node --import tsx --test tests/*.test.js", + "fixtures:generate": "node scripts/archive-fixtures.mjs", + "fixtures:verify": "node --test tests/fixtures.test.js", + "benchmark:archive": "node --import tsx scripts/benchmark-archive.mjs", + "quality:licenses": "node scripts/dependency-license-census.mjs", + "quality:sizes": "node scripts/report-build-size.mjs", + "content:generate": "node scripts/generate-built-in-content.mjs", + "content:check": "node scripts/generate-built-in-content.mjs --check", + "schemas:generate": "node scripts/generate-validators.mjs", + "schemas:check": "node scripts/generate-validators.mjs --check", + "typecheck": "tsc --noEmit", + "test:renderer": "node scripts/run-wdio-browser.mjs", + "test:today": "node scripts/run-wdio-browser.mjs wdio.today.conf.mjs", + "test:archive": "node scripts/run-wdio-browser.mjs wdio.archive.conf.mjs", + "test:packs": "node scripts/run-wdio-browser.mjs wdio.packs.conf.mjs", + "test:projects": "node scripts/run-wdio-browser.mjs wdio.projects.conf.mjs", + "test:receipts": "node scripts/run-wdio-browser.mjs wdio.receipts.conf.mjs", + "test:practices": "node scripts/run-wdio-browser.mjs wdio.practices.conf.mjs", + "test:symphony": "node scripts/run-wdio-browser.mjs wdio.symphony.conf.mjs", + "test:journal": "node scripts/run-wdio-browser.mjs wdio.journal.conf.mjs", + "test:release": "node scripts/run-wdio-browser.mjs wdio.release.conf.mjs", + "test:desktop:build": "tauri build --debug --no-bundle --features wdio --config src-tauri/tauri.wdio.conf.json", + "test:desktop": "wdio run wdio.native.conf.mjs", + "build:wdio": "npm run content:generate && npm run schemas:generate && vite build --mode wdio", + "lint": "eslint src tests scripts eslint.config.js vite.config.js", + "build": "npm run content:generate && npm run schemas:generate && vite build", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", - "tauri:build": "tauri build", - "tauri:build:portable": "tauri build --no-bundle" + "tauri:build:portable": "tauri build --no-bundle", + "release:windows": "npm run tauri:build:portable && node scripts/package-windows-release.mjs" }, "devDependencies": { - "@tauri-apps/cli": "^2.1.0", - "vite": "^5.4.10" + "@axe-core/webdriverio": "4.12.1", + "@eslint/js": "^10.0.1", + "@tauri-apps/cli": "^2.11.4", + "@wdio/cli": "9.29.1", + "@wdio/local-runner": "9.29.1", + "@wdio/mocha-framework": "9.29.1", + "@wdio/spec-reporter": "9.29.1", + "@wdio/tauri-plugin": "1.2.0", + "@wdio/tauri-service": "1.2.0", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "eslint": "^10.7.0", + "globals": "^17.7.0", + "puppeteer-core": "24.43.1", + "tsx": "4.23.1", + "typescript": "7.0.2", + "vite": "^8.1.4" }, "dependencies": { - "@tauri-apps/api": "^2.1.1", + "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-fs": "^2.0.3", - "@tauri-apps/plugin-http": "^2.0.4" + "@tauri-apps/plugin-http": "^2.0.4", + "spdx-expression-parse": "3.0.1" + }, + "overrides": { + "@wdio/native-utils": "2.5.0", + "serialize-javascript": "7.0.5" } } diff --git a/screenshots/00-hero-oracle-intro.png b/screenshots/00-hero-oracle-intro.png deleted file mode 100644 index 3f12ee7..0000000 Binary files a/screenshots/00-hero-oracle-intro.png and /dev/null differ diff --git a/screenshots/02-oracle-tarot.png b/screenshots/02-oracle-tarot.png deleted file mode 100644 index 934ace7..0000000 Binary files a/screenshots/02-oracle-tarot.png and /dev/null differ diff --git a/screenshots/03-oracle-iching.png b/screenshots/03-oracle-iching.png deleted file mode 100644 index 015467c..0000000 Binary files a/screenshots/03-oracle-iching.png and /dev/null differ diff --git a/screenshots/05-lottery-dice.png b/screenshots/05-lottery-dice.png deleted file mode 100644 index 97eb807..0000000 Binary files a/screenshots/05-lottery-dice.png and /dev/null differ diff --git a/screenshots/06-lottery-wheel.png b/screenshots/06-lottery-wheel.png deleted file mode 100644 index f74a29b..0000000 Binary files a/screenshots/06-lottery-wheel.png and /dev/null differ diff --git a/screenshots/11-canvas-voronoi.png b/screenshots/11-canvas-voronoi.png deleted file mode 100644 index 282b576..0000000 Binary files a/screenshots/11-canvas-voronoi.png and /dev/null differ diff --git a/screenshots/12-canvas-particles.png b/screenshots/12-canvas-particles.png deleted file mode 100644 index 77eb09a..0000000 Binary files a/screenshots/12-canvas-particles.png and /dev/null differ diff --git a/screenshots/13-symphony.png b/screenshots/13-symphony.png deleted file mode 100644 index 4834361..0000000 Binary files a/screenshots/13-symphony.png and /dev/null differ diff --git a/screenshots/15-archive.png b/screenshots/15-archive.png deleted file mode 100644 index 1dfabde..0000000 Binary files a/screenshots/15-archive.png and /dev/null differ diff --git a/screenshots/16-settings.png b/screenshots/16-settings.png deleted file mode 100644 index b0421dd..0000000 Binary files a/screenshots/16-settings.png and /dev/null differ diff --git a/scripts/archive-fixtures.mjs b/scripts/archive-fixtures.mjs new file mode 100644 index 0000000..aa5fcda --- /dev/null +++ b/scripts/archive-fixtures.mjs @@ -0,0 +1,103 @@ +import { createHash } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const defaultRoot = join(workspaceRoot, '.tmp', 'archive-fixtures'); +const chamberTypes = [ + ['oracle', 'oracle-draw'], + ['decider', 'decider-decision'], + ['diary', 'diary-entry'], + ['constraint', 'constraint'], + ['canvas', 'canvas-work'], + ['symphony', 'symphony-session'], + ['beacon', 'beacon-entry'], + ['lottery', 'lottery-dice'], +]; + +function assertSafeOutputRoot(outputRoot) { + const absolute = resolve(outputRoot); + const allowedRoot = resolve(workspaceRoot, '.tmp', 'archive-fixtures'); + const inside = relative(allowedRoot, absolute); + if (isAbsolute(inside) || inside === '..' || inside.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new Error(`fixture output must remain inside ${allowedRoot}`); + } + return absolute; +} + +function stableId(index) { + return createHash('sha256').update(`sortilune-fixture-v1:${index}`).digest('hex').slice(0, 20); +} + +function timestampFor(index) { + const base = Date.UTC(2024, 0, 1, 0, 0, 0); + return new Date(base + index * 60_000).toISOString(); +} + +export function syntheticRecord(index) { + const [chamber, type] = chamberTypes[index % chamberTypes.length]; + const id = stableId(index); + const drawnAt = timestampFor(index); + return { + chamber, + filename: `${drawnAt.replaceAll(':', '-')}__fixture_${id}.json`, + value: { + human_summary: `Synthetic ${chamber} fixture ${index}`, + type, + id, + drawn_at: drawnAt, + synthetic_fixture: true, + fixture_index: index, + provenance: { + source_id: 'fixture-v1', + source_name: 'Deterministic archive fixture generator', + fetched_at: drawnAt, + raw: stableId(index + 100_000), + }, + }, + }; +} + +export async function generateArchive(outputRoot, count) { + if (!Number.isSafeInteger(count) || count < 1 || count > 10_000) { + throw new RangeError('count must be an integer from 1 through 10000'); + } + const root = assertSafeOutputRoot(outputRoot); + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + const digest = createHash('sha256'); + for (let index = 0; index < count; index += 1) { + const record = syntheticRecord(index); + const bytes = `${JSON.stringify(record.value, null, 2)}\n`; + const destination = join(root, 'archive', record.chamber, record.filename); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, bytes, 'utf8'); + digest.update(record.chamber).update('\0').update(record.filename).update('\0').update(bytes); + } + const manifest = { + schema: 'sortilune.synthetic-archive-manifest', + schema_version: 1, + generator: 'sortilune-fixture-v1', + count, + tree_digest_sha256: digest.digest('hex'), + }; + await writeFile(join(root, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + return manifest; +} + +export async function generateStandardArchives(outputRoot = defaultRoot) { + const root = assertSafeOutputRoot(outputRoot); + const results = []; + for (const count of [100, 1_000, 10_000]) { + results.push(await generateArchive(join(root, String(count)), count)); + } + return results; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const results = await generateStandardArchives(); + for (const manifest of results) { + console.log(`${manifest.count} records: ${manifest.tree_digest_sha256}`); + } +} diff --git a/scripts/benchmark-archive.mjs b/scripts/benchmark-archive.mjs new file mode 100644 index 0000000..b64b62d --- /dev/null +++ b/scripts/benchmark-archive.mjs @@ -0,0 +1,137 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import process from 'node:process'; +import { performance } from 'node:perf_hooks'; +import { ArchiveRepository } from '../src/archive/repository.ts'; +import { CompactArchiveIndex } from '../src/archive/compact-index.ts'; +import { generateArchive } from './archive-fixtures.mjs'; + +const root = resolve('.tmp', 'archive-fixtures', 'benchmark-10000'); +const archiveRoot = join(root, 'archive'); +const output = resolve('artifacts', 'quality', 'archive-index-benchmark.json'); +const gates = { cold_ms: 2_000, warm_ms: 300, search_p95_ms: 100, incremental_ms: 100 }; + +class DiskTransport { + async writeBatch() { throw new Error('benchmark transport is read-only'); } + async isAvailable() { return true; } + async revealArchive() { return archiveRoot; } + async listDir(path) { + const directory = join(root, ...path.split('/')); + try { + return (await readdir(directory, { withFileTypes: true })).map((entry) => ({ + name: entry.name, + isFile: entry.isFile(), + isDirectory: entry.isDirectory(), + })); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } + } + async readText(path) { + return readFile(join(root, ...path.split('/')), 'utf8'); + } +} + +await generateArchive(root, 10_000); +const digestBefore = await treeDigest(archiveRoot); +const repository = new ArchiveRepository(new DiskTransport()); + +const coldSamples = []; +let items = []; +let index = null; +for (let sample = 0; sample < 5; sample++) { + const started = performance.now(); + items = await repository.list(); + index = CompactArchiveIndex.fromItems(items); + coldSamples.push(performance.now() - started); + if (items.length !== 10_000 || index.size !== 10_000) throw new Error('benchmark index lost records'); +} +coldSamples.sort((left, right) => left - right); +const coldMs = percentile(coldSamples, 0.5); + +const serialized = JSON.stringify(index.snapshot()); +const warmStart = performance.now(); +const warmIndex = new CompactArchiveIndex(JSON.parse(serialized)); +const warmMs = performance.now() - warmStart; + +const searchSamples = []; +for (let iteration = 0; iteration < 200; iteration++) { + const started = performance.now(); + const result = warmIndex.search(iteration % 2 ? 'synthetic oracle' : `fixture ${iteration}`); + if (result.length === 0) throw new Error('benchmark query unexpectedly returned no records'); + searchSamples.push(performance.now() - started); +} +searchSamples.sort((left, right) => left - right); +const searchP95Ms = searchSamples[Math.floor(searchSamples.length * 0.95)] ?? Infinity; + +const changed = { ...warmIndex.snapshot()[0], summary: 'Incrementally changed fixture' }; +const incrementalStart = performance.now(); +warmIndex.upsert(changed); +const incrementalMs = performance.now() - incrementalStart; + +const cacheDirectory = join(archiveRoot, '_sortilune', 'cache'); +await mkdir(cacheDirectory, { recursive: true }); +await writeFile(join(cacheDirectory, 'archive-index.json'), '{corrupt', 'utf8'); +await rm(cacheDirectory, { recursive: true, force: true }); +const digestAfter = await treeDigest(archiveRoot); + +const measurements = { + schema: 'sortilune.archive-benchmark', + schema_version: 1, + generated_at: new Date().toISOString(), + environment: { platform: process.platform, arch: process.arch, node: process.version }, + fixture_records: items.length, + compact_snapshot_bytes: Buffer.byteLength(serialized), + measurements_ms: { + cold_indexed_view: round(coldMs), + cold_indexed_view_p95: round(percentile(coldSamples, 0.95)), + cold_indexed_view_maximum: round(coldSamples.at(-1) ?? Infinity), + cold_indexed_view_samples: coldSamples.map(round), + warm_open: round(warmMs), + search_p95: round(searchP95Ms), + incremental_update: round(incrementalMs), + }, + gates_ms: gates, + source_digest_before: digestBefore, + source_digest_after: digestAfter, + source_unchanged_after_cache_deletion: digestBefore === digestAfter, + decision: 'compact-json-and-in-memory-token-index', +}; +await mkdir(dirname(output), { recursive: true }); +await writeFile(output, `${JSON.stringify(measurements, null, 2)}\n`, 'utf8'); +console.log(JSON.stringify(measurements, null, 2)); + +if (items.length !== 10_000 || index.size !== 10_000 || warmIndex.size !== 10_000) throw new Error('benchmark index lost records'); +if (!measurements.source_unchanged_after_cache_deletion) throw new Error('deleting the cache altered source records'); +if (coldMs > gates.cold_ms || warmMs > gates.warm_ms || searchP95Ms > gates.search_p95_ms || incrementalMs > gates.incremental_ms) { + throw new Error('compact index missed a performance gate; evaluate Rust-owned SQLite FTS5'); +} + +async function treeDigest(directory) { + const hash = createHash('sha256'); + async function visit(current) { + const entries = (await readdir(current, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (entry.name === '_sortilune') continue; + const fullPath = join(current, entry.name); + if (entry.isDirectory()) await visit(fullPath); + else { + hash.update(relative(directory, fullPath).replaceAll('\\', '/')).update('\0'); + hash.update(await readFile(fullPath)); + } + } + } + await visit(directory); + return hash.digest('hex'); +} + +function round(value) { + return Math.round(value * 100) / 100; +} + +function percentile(sortedValues, fraction) { + const index = Math.min(sortedValues.length - 1, Math.ceil(sortedValues.length * fraction) - 1); + return sortedValues[index] ?? Infinity; +} diff --git a/scripts/dependency-license-census.mjs b/scripts/dependency-license-census.mjs new file mode 100644 index 0000000..88a9d44 --- /dev/null +++ b/scripts/dependency-license-census.mjs @@ -0,0 +1,105 @@ +/** Generate an npm and Cargo dependency license census for quality review. */ + +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const root = process.cwd(); +const outputPath = path.join(root, 'artifacts', 'quality', 'dependency-license-census.md'); +const maxBuffer = 64 * 1024 * 1024; + +function run(command, args) { + return execFileSync(command, args, { + cwd: root, + encoding: 'utf8', + maxBuffer, + windowsHide: true, + }); +} + +function uniquePackages(packages) { + const byIdentity = new Map(); + for (const packageInfo of packages) { + const name = String(packageInfo.name || '').trim(); + const version = String(packageInfo.version || '').trim(); + if (!name || !version) continue; + byIdentity.set(`${name}@${version}`, { + name, + version, + license: String(packageInfo.license || '(missing)').trim() || '(missing)', + }); + } + return [...byIdentity.values()].sort((left, right) => ( + left.name.localeCompare(right.name) || left.version.localeCompare(right.version) + )); +} + +function renderGroups(packages) { + const groups = new Map(); + for (const packageInfo of packages) { + const entries = groups.get(packageInfo.license) || []; + entries.push(packageInfo); + groups.set(packageInfo.license, entries); + } + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([license, entries]) => [ + `### ${license} (${entries.length})`, + '', + ...entries.map((entry) => `- \`${entry.name}@${entry.version}\``), + '', + ].join('\n')) + .join('\n'); +} + +const cargoCommand = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error('npm_execpath is unavailable; run this generator through npm run quality:licenses.'); +const npmPackages = uniquePackages(JSON.parse(run(process.execPath, [npmCli, 'query', '*', '--json']))); +const cargoMetadata = JSON.parse(run(cargoCommand, [ + 'metadata', + '--locked', + '--format-version', + '1', + '--manifest-path', + path.join('src-tauri', 'Cargo.toml'), + '--all-features', +])); +const cargoPackages = uniquePackages(cargoMetadata.packages || []); +const allPackages = [...npmPackages, ...cargoPackages]; +const missing = allPackages.filter((packageInfo) => packageInfo.license === '(missing)'); +const mandatoryCopyleft = allPackages.filter((packageInfo) => ( + /(?:^|\W)(?:AGPL|GPL)-(?:1\.0|2\.0|3\.0)(?:-only|-or-later)?(?:$|\W)/i.test(packageInfo.license) + && !/\bOR\b/i.test(packageInfo.license) +)); + +const markdown = [ + '# Dependency License Census', + '', + `Generated: ${new Date().toISOString().slice(0, 10)}`, + '', + 'This target-inclusive census is generated from the exact installed npm tree and Cargo.lock-resolved metadata. The lockfiles remain the authority for checksums and dependency graph. Re-run after every lockfile change. OR expressions permit selecting a listed license; this report does not replace review of license texts or distribution notices.', + '', + `## npm packages (${npmPackages.length})`, + '', + renderGroups(npmPackages), + `## Cargo packages, all locked targets (${cargoPackages.length})`, + '', + renderGroups(cargoPackages), + '## Review result', + '', + missing.length + ? `- Missing license metadata: ${missing.map((entry) => `\`${entry.name}@${entry.version}\``).join(', ')}.` + : '- No package has missing license metadata in this census; both Sortilune manifests declare MIT.', + mandatoryCopyleft.length + ? `- Mandatory GPL/AGPL licenses require review: ${mandatoryCopyleft.map((entry) => `\`${entry.name}@${entry.version}\``).join(', ')}.` + : '- No mandatory GPL or AGPL dependency was found.', + '- MPL-2.0 components retain their file-level obligations.', + '- Packages offering permissive alternatives alongside LGPL remain eligible for distribution under a listed permissive alternative.', + '', +].join('\n'); + +await mkdir(path.dirname(outputPath), { recursive: true }); +await writeFile(outputPath, markdown, 'utf8'); +console.log(`Wrote ${npmPackages.length} npm and ${cargoPackages.length} Cargo packages to ${outputPath}`); diff --git a/scripts/fixture-integrity.mjs b/scripts/fixture-integrity.mjs new file mode 100644 index 0000000..fb12a64 --- /dev/null +++ b/scripts/fixture-integrity.mjs @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto'; +import { cp, mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const fixtureArchive = join(workspaceRoot, 'tests', 'fixtures', 'archive', 'source', 'archive'); + +async function treeEntries(root, current = root) { + const entries = []; + for (const entry of await readdir(current, { withFileTypes: true })) { + const full = join(current, entry.name); + if (entry.isDirectory()) entries.push(...await treeEntries(root, full)); + else if (entry.isFile()) entries.push(relative(root, full).replaceAll('\\', '/')); + } + return entries.sort(); +} + +export async function hashTree(root) { + const digest = createHash('sha256'); + const entries = await treeEntries(root); + for (const entry of entries) { + digest.update(entry).update('\0').update(await readFile(join(root, entry))); + } + return { entries, digest: digest.digest('hex') }; +} + +export async function inspectCommittedFixtures() { + const tree = await hashTree(fixtureArchive); + const expected = [ + 'beacon/2024-01-07T00-00-00-000Z__entry_fixture.json', + 'canvas/2024-01-05T00-00-00-000Z__orphan_fixture.json', + 'canvas/2024-01-05T00-00-00-000Z__work_fixture.json', + 'canvas/2024-01-05T00-00-00-000Z__work_fixture.svg', + 'constraint/2024-01-03T00-00-00-000Z__constraint_fixture.json', + 'decider/2024-01-02T00-00-00-000Z__decision_duplicate-a.json', + 'decider/2024-01-02T00-01-00-000Z__decision_duplicate-b.json', + 'diary/2024-01-04.md', + 'lottery/2024-01-08T00-00-00-000Z__dice_fixture.json', + 'oracle/2024-01-01T00-00-00-000Z__draw_fixture.json', + 'oracle/corrupted.json', + 'oracle/truncated.json', + 'symphony/2024-01-06T00-00-00-000Z__session_fixture.json', + ]; + for (const path of expected) { + if (!tree.entries.includes(path)) throw new Error(`missing committed fixture: ${path}`); + } + + const duplicateA = JSON.parse(await readFile(join(fixtureArchive, expected[5]), 'utf8')); + const duplicateB = JSON.parse(await readFile(join(fixtureArchive, expected[6]), 'utf8')); + if (!duplicateA.id || duplicateA.id !== duplicateB.id) throw new Error('duplicate-ID fixtures do not share an ID'); + + const canvas = JSON.parse(await readFile(join(fixtureArchive, expected[2]), 'utf8')); + const canvasSvg = join(fixtureArchive, 'canvas', basename(canvas.svg_path)); + if (!(await stat(canvasSvg)).isFile()) throw new Error('paired Canvas SVG is missing'); + + const diary = await readFile(join(fixtureArchive, expected[7]), 'utf8'); + if (!diary.startsWith('---\n') || !diary.includes('\nprovenance:\n')) throw new Error('Diary frontmatter fixture is invalid'); + + for (const path of [expected[10], expected[11]]) { + let rejected = false; + try { JSON.parse(await readFile(join(fixtureArchive, path), 'utf8')); } catch { rejected = true; } + if (!rejected) throw new Error(`${path} unexpectedly parsed as JSON`); + } + return tree; +} + +export async function verifyBackupRestore(verifyRestored) { + const tempRoot = await mkdtemp(join(tmpdir(), 'sortilune-fixture-restore-')); + if (!resolve(tempRoot).startsWith(resolve(tmpdir()))) throw new Error('temporary restore root escaped the OS temp directory'); + const working = join(tempRoot, 'working'); + const backup = join(tempRoot, 'backup'); + const restored = join(tempRoot, 'restored'); + try { + await cp(fixtureArchive, working, { recursive: true, errorOnExist: true }); + const original = await hashTree(working); + await cp(working, backup, { recursive: true, errorOnExist: true }); + await cp(backup, restored, { recursive: true, errorOnExist: true }); + const roundTrip = await hashTree(restored); + if (original.digest !== roundTrip.digest) throw new Error('backup/restore changed fixture bytes'); + if (original.entries.join('\n') !== roundTrip.entries.join('\n')) throw new Error('backup/restore changed fixture paths'); + if (verifyRestored) await verifyRestored(restored); + return original; + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} diff --git a/scripts/generate-built-in-content.mjs b/scripts/generate-built-in-content.mjs new file mode 100644 index 0000000..6a625fa --- /dev/null +++ b/scripts/generate-built-in-content.mjs @@ -0,0 +1,171 @@ +/** + * Generate compact runtime modules from readable, reviewable built-in content. + * + * The source JSON remains authoritative. Repeated record keys and low-cardinality + * string fields are encoded once so shipping the complete decks does not spend + * the frontend budget on representation overhead. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { + assertCompactTokenCapacity, + assertNoCompactTokens, + COMPACT_TOKEN_START, +} from './lib/compact-content.mjs'; + +const root = process.cwd(); +const sourceDirectory = path.join(root, 'src', 'chambers', 'oracle', 'decks'); +const outputDirectory = path.join(sourceDirectory, 'generated'); +const deckNames = ['tarot', 'i-ching', 'runes', 'cosmic']; +const checking = process.argv.includes('--check'); +const phraseCandidates = [ + 'the superior person', 'superior person', ' invites you to ', ' suggests that ', + ' asks whether ', ' through the ', ' reminds you ', 'rather than', ' asks what ', + ' that the ', ' with the ', ' from the ', ' into the ', ' does not ', 'The card ', + ' and the ', ' what is ', ' you are ', ' through ', ' without ', ' between ', + ' of the ', ' in the ', ' to the ', ' is not ', ' which ', ' where ', ' that ', + ' with ', ' from ', ' into ', ' what ', ' your ', ' only ', ' when ', ' each ', + ' have ', ' the ', ' and ', ' its ', ' are ', ' has ', ' for ', ' but ', ' not ', + ' one ', ' all ', ' by ', ' on ', ' at ', ' it ', ' is ', ' to ', ' of ', ' in ', +].sort((left, right) => right.length - left.length || left.localeCompare(right)); + +assertCompactTokenCapacity(phraseCandidates.length); + +await mkdir(outputDirectory, { recursive: true }); + +const decks = await Promise.all(deckNames.map(async (deckName) => ({ + deckName, + records: JSON.parse(await readFile(path.join(sourceDirectory, `${deckName}.json`), 'utf8')), +}))); +const phrases = selectPhrases(decks.flatMap(({ records }) => collectStrings(records))); +await emit('phrases.generated.js', [ + '// Generated by scripts/generate-built-in-content.mjs. Do not edit by hand.', + `export const phrases=${JSON.stringify(phrases)};`, + '', +].join('\n')); + +for (const { deckName, records } of decks) { + const encodedRecords = mapStrings(records, (value) => encodePhrases(value, phrases)); + const { keys, dictionaries, rows } = compactRecords(encodedRecords, deckName); + const content = [ + '// Generated by scripts/generate-built-in-content.mjs. Do not edit by hand.', + "import { inflateDeck } from './inflate.js';", + `const keys=${JSON.stringify(keys)};`, + `const dictionaries=${JSON.stringify(dictionaries)};`, + `const rows=${JSON.stringify(rows)};`, + 'export default inflateDeck(keys,dictionaries,rows);', + '', + ].join('\n'); + await emit(`${deckName}.generated.js`, content); +} + +console.log(`${checking ? 'Validated' : 'Generated'} ${deckNames.length} compact built-in deck modules.`); + +async function emit(fileName, content) { + const outputPath = path.join(outputDirectory, fileName); + if (!checking) { + await writeFile(outputPath, content, 'utf8'); + return; + } + const existing = await readFile(outputPath, 'utf8').catch(() => ''); + if (existing.replaceAll('\r\n', '\n') !== content) { + throw new Error(`${path.relative(root, outputPath)} is stale. Run npm run content:generate.`); + } +} + +function selectPhrases(sourceStrings) { + assertNoCompactTokens(sourceStrings); + + let encoded = [...sourceStrings]; + const phrases = []; + for (const phrase of phraseCandidates) { + const occurrences = encoded.reduce((total, value) => total + countOccurrences(value, phrase), 0); + const estimatedSaving = occurrences * (Buffer.byteLength(phrase) - 3) + - Buffer.byteLength(JSON.stringify(phrase)) - 1; + if (estimatedSaving < 8) continue; + const token = String.fromCodePoint(COMPACT_TOKEN_START + phrases.length); + encoded = encoded.map((value) => value.split(phrase).join(token)); + phrases.push(phrase); + } + return phrases; +} + +function collectStrings(value, output = []) { + if (typeof value === 'string') output.push(value); + else if (Array.isArray(value)) value.forEach((entry) => collectStrings(entry, output)); + else if (value && typeof value === 'object') Object.values(value).forEach((entry) => collectStrings(entry, output)); + return output; +} + +function mapStrings(value, transform) { + if (typeof value === 'string') return transform(value); + if (Array.isArray(value)) return value.map((entry) => mapStrings(entry, transform)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, mapStrings(entry, transform)])); + } + return value; +} + +function encodePhrases(value, phrases) { + return phrases.reduce( + (encoded, phrase, index) => encoded.split(phrase).join(String.fromCodePoint(COMPACT_TOKEN_START + index)), + value, + ); +} + +function countOccurrences(value, phrase) { + let count = 0; + let offset = 0; + while ((offset = value.indexOf(phrase, offset)) >= 0) { + count += 1; + offset += phrase.length; + } + return count; +} + +function compactRecords(records, deckName) { + if (!Array.isArray(records) || records.length === 0) { + throw new TypeError(`${deckName}.json must contain a non-empty record array`); + } + if (records.some((record) => !record || typeof record !== 'object' || Array.isArray(record))) { + throw new TypeError(`${deckName}.json must contain objects only`); + } + + const keys = Object.keys(records[0]); + const expectedShape = keys.join('\0'); + for (const record of records) { + if (Object.keys(record).join('\0') !== expectedShape) { + throw new TypeError(`${deckName}.json records must use one stable field order and shape`); + } + } + + const dictionaries = {}; + const dictionaryIndexes = new Map(); + for (let fieldIndex = 0; fieldIndex < keys.length; fieldIndex += 1) { + const values = records.map((record) => record[keys[fieldIndex]]); + const strings = values.every((value) => typeof value === 'string') + ? values + : values.every((value) => Array.isArray(value) && value.every((item) => typeof item === 'string')) + ? values.flat() + : null; + if (!strings || strings.length < 2) continue; + + const unique = [...new Set(strings)]; + if (unique.length > 64 || unique.length * 2 > strings.length) continue; + dictionaries[fieldIndex] = unique; + dictionaryIndexes.set(fieldIndex, new Map(unique.map((value, index) => [value, index]))); + } + + const rows = records.map((record) => keys.map((key, fieldIndex) => { + const value = record[key]; + const indexes = dictionaryIndexes.get(fieldIndex); + if (!indexes) return value; + return Array.isArray(value) + ? value.map((item) => indexes.get(item)) + : indexes.get(value); + })); + + return { keys, dictionaries, rows }; +} diff --git a/scripts/generate-validators.mjs b/scripts/generate-validators.mjs new file mode 100644 index 0000000..2759a0d --- /dev/null +++ b/scripts/generate-validators.mjs @@ -0,0 +1,135 @@ +/** Generate CSP-safe ESM validators from trusted Draft 2020-12 schemas. */ + +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import standaloneCode from 'ajv/dist/standalone/index.js'; + +const root = process.cwd(); +const outputPath = path.join(root, 'src', 'schemas', 'generated', 'validators.generated.js'); +const packOutputPath = path.join(root, 'src', 'schemas', 'generated', 'pack-validator.generated.js'); +const schemaPaths = [ + ['common', 'src/schemas/v1/common.schema.json'], + ['provenance', 'src/schemas/v1/provenance.schema.json'], + ['relation', 'src/schemas/v1/relation.schema.json'], + ['assetReference', 'src/schemas/v1/asset-reference.schema.json'], + ['archiveRecord', 'src/schemas/v1/archive-record.schema.json'], + ['archiveAnnotationStore', 'src/schemas/v1/archive-annotations.schema.json'], + ['dailyRecord', 'src/schemas/v1/daily-record.schema.json'], + ['project', 'src/schemas/v1/project.schema.json'], + ['practiceStore', 'src/schemas/v1/practice-store.schema.json'], + ['symphonyScore', 'src/schemas/v1/symphony-score.schema.json'], + ['receipt', 'src/schemas/v1/receipt.schema.json'], + ['pack', 'src/schemas/v1/pack.schema.json'], + ['settingsV2', 'src/schemas/v2/settings.schema.json'], +]; + +const schemas = []; +for (const [name, relativePath] of schemaPaths) { + const source = await readFile(path.join(root, relativePath), 'utf8'); + const schema = JSON.parse(source); + if (schema.$schema !== 'https://json-schema.org/draft/2020-12/schema') { + throw new Error(`${relativePath} must declare JSON Schema Draft 2020-12`); + } + if (typeof schema.$id !== 'string' || !schema.$id.startsWith('https://sortilune.app/schemas/')) { + throw new Error(`${relativePath} must have a stable Sortilune schema $id`); + } + schemas.push({ name, relativePath, schema }); +} + +const mainOutput = generateStandalone(schemas.filter(({ name }) => name !== 'pack')); +const packOutput = generateStandalone(schemas.filter(({ name }) => name === 'common' || name === 'pack')); + +function generateStandalone(selectedSchemas) { + const ajv = new Ajv2020({ + // The app needs a precise rejecting error, not every possible follow-on + // error. Fail-fast standalone code materially reduces the shipped bundle. + allErrors: false, + inlineRefs: false, + messages: false, + unevaluated: false, + strict: true, + ownProperties: true, + unicodeRegExp: true, + code: { source: true, esm: true, optimize: 2, lines: true }, + }); + addFormats(ajv); + for (const { schema } of selectedSchemas) ajv.addSchema(schema); + const exportsByName = Object.fromEntries(selectedSchemas + .filter(({ name }) => name !== 'common') + .map(({ name, schema }) => [`validate${name[0].toUpperCase()}${name.slice(1)}`, schema.$id])); + const generated = inlineReferencedSchemaValues(standaloneCode(ajv, exportsByName) + .replaceAll('require("ajv/dist/runtime/ucs2length").default', 'ucs2LengthRuntime.default') + .replaceAll('require("ajv/dist/runtime/equal").default', 'equalRuntime.default') + .replaceAll('require("ajv-formats/dist/formats")', 'formatDefinitions')); + if (/\brequire\s*\(/.test(generated)) { + throw new Error('Generated validators contain an unsupported CommonJS runtime dependency'); + } + return { content: generatedContent(generated), count: Object.keys(exportsByName).length }; +} + +/** + * Ajv retains whole root schema objects when an error parameter needs only a + * small enum/type array. Inline those immutable values and discard the now + * unused schema literal. Validation branches and error parameters are kept + * exactly as generated; only constant representation changes. + */ +function inlineReferencedSchemaValues(source) { + const declarations = [...source.matchAll(/^const (schema\d+) = (\{.*\});$/gmu)]; + let optimized = source; + for (const declaration of declarations) { + const [, name, literal] = declaration; + const schema = JSON.parse(literal); + const propertyReference = new RegExp(`\\b${name}((?:\\.[A-Za-z_$][\\w$]*)+)`, 'gu'); + optimized = optimized.replace(propertyReference, (match, pathExpression) => { + const value = pathExpression.slice(1).split('.').reduce( + (current, key) => current && typeof current === 'object' ? current[key] : undefined, + schema, + ); + return value === undefined ? match : JSON.stringify(value); + }); + const remainingReferences = optimized.match(new RegExp(`\\b${name}\\b`, 'gu'))?.length ?? 0; + if (remainingReferences === 1) { + optimized = optimized.replace(new RegExp(`^const ${name} = .*;\\r?\\n`, 'mu'), ''); + } + } + return optimized; +} +function generatedContent(generated) { + const content = [ + '// Generated by scripts/generate-validators.mjs. Do not edit by hand.', + '// Ajv compilation runs only at build time; runtime helpers perform no schema compilation.', + 'import equalRuntime from "ajv/dist/runtime/equal.js";', + 'import ucs2LengthRuntime from "ajv/dist/runtime/ucs2length.js";', + 'import { isRfc3339Timestamp } from "../../domain/identifiers.js";', + 'function isAbsoluteUri(value) {', + ' if (typeof value !== "string" || value.length > 2048 || /[\\u0000-\\u0020]/u.test(value)) return false;', + ' try { return Boolean(new URL(value).protocol); } catch { return false; }', + '}', + 'const formatDefinitions = { fullFormats: { "date-time": { validate: isRfc3339Timestamp }, uri: isAbsoluteUri } };', + generated.trim(), + '', + ].join('\n'); + if (/\b(?:eval|Function)\s*\(/.test(content)) { + throw new Error('Generated validators contain dynamic code evaluation'); + } + return content; +} + +if (process.argv.includes('--check')) { + const existing = await readFile(outputPath, 'utf8').catch(() => ''); + const existingPack = await readFile(packOutputPath, 'utf8').catch(() => ''); + if (existing.replaceAll('\r\n', '\n') !== mainOutput.content + || existingPack.replaceAll('\r\n', '\n') !== packOutput.content) { + throw new Error('Generated validators are stale. Run npm run schemas:generate.'); + } + console.log(`Validated ${schemas.length} schemas; generated validators are current.`); +} else { + await Promise.all([ + writeFile(outputPath, mainOutput.content, 'utf8'), + writeFile(packOutputPath, packOutput.content, 'utf8'), + ]); + console.log(`Generated ${mainOutput.count + packOutput.count} standalone validators at ${path.dirname(outputPath)}`); +} diff --git a/scripts/lib/build-artifacts.mjs b/scripts/lib/build-artifacts.mjs new file mode 100644 index 0000000..931e238 --- /dev/null +++ b/scripts/lib/build-artifacts.mjs @@ -0,0 +1,23 @@ +import { readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; + +/** Inspect a build directory without trusting a stale or instrumented dist. */ +export async function inspectBuildDirectory(directory) { + let bytes = 0; + const sourceMapFiles = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + const nested = await inspectBuildDirectory(fullPath); + bytes += nested.bytes; + sourceMapFiles.push(...nested.sourceMapFiles); + continue; + } + if (!entry.isFile()) { + throw new TypeError(`build directory contains an unsupported filesystem entry: ${fullPath}`); + } + bytes += (await stat(fullPath)).size; + if (entry.name.endsWith('.map')) sourceMapFiles.push(fullPath); + } + return { bytes, sourceMapFiles }; +} diff --git a/scripts/lib/compact-content.mjs b/scripts/lib/compact-content.mjs new file mode 100644 index 0000000..703dcd8 --- /dev/null +++ b/scripts/lib/compact-content.mjs @@ -0,0 +1,25 @@ +export const COMPACT_TOKEN_START = 0xE000; +export const COMPACT_TOKEN_LIMIT = 0xE100; + +export function assertCompactTokenCapacity(tokenCount) { + const capacity = COMPACT_TOKEN_LIMIT - COMPACT_TOKEN_START; + if (!Number.isSafeInteger(tokenCount) || tokenCount < 0 || tokenCount > capacity) { + throw new RangeError(`compact-content token count must be an integer from 0 through ${capacity}`); + } +} + +/** + * The decoder recognizes the complete 256-code-point private-use token range. + * Reject that entire range in source content so an unassigned source character + * can never be mistaken for a generated phrase token. + */ +export function assertNoCompactTokens(sourceStrings) { + for (const value of sourceStrings) { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint >= COMPACT_TOKEN_START && codePoint < COMPACT_TOKEN_LIMIT) { + throw new TypeError('built-in content contains a reserved compact-content token'); + } + } + } +} diff --git a/scripts/measure-startup.ps1 b/scripts/measure-startup.ps1 new file mode 100644 index 0000000..2f0a6a9 --- /dev/null +++ b/scripts/measure-startup.ps1 @@ -0,0 +1,78 @@ +param( + [int]$Samples = 5, + [int]$TimeoutSeconds = 15, + [int]$IdleSeconds = 3, + [string]$ExecutablePath = '' +) + +$ErrorActionPreference = 'Stop' +$workspace = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$executable = if ($ExecutablePath) { + (Resolve-Path -LiteralPath $ExecutablePath).Path +} else { + Join-Path $workspace 'src-tauri\target\release\sortilune.exe' +} +if (-not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Release executable not found: $executable" +} +if ($Samples -lt 1 -or $Samples -gt 20) { + throw 'Samples must be between 1 and 20.' +} + +$results = @() +for ($sample = 1; $sample -le $Samples; $sample += 1) { + $watch = [Diagnostics.Stopwatch]::StartNew() + $process = Start-Process -FilePath $executable -PassThru -WindowStyle Hidden + try { + $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) + $windowReady = $false + while ([DateTime]::UtcNow -lt $deadline) { + if ($process.HasExited) { + throw "Sortilune exited before opening a window (exit code $($process.ExitCode))." + } + $process.Refresh() + if ($process.MainWindowHandle -ne 0) { + $windowReady = $true + break + } + Start-Sleep -Milliseconds 20 + } + $watch.Stop() + if (-not $windowReady) { + throw "Sortilune did not expose a main window within $TimeoutSeconds seconds." + } + Start-Sleep -Seconds $IdleSeconds + $process.Refresh() + $results += [pscustomobject]@{ + sample = $sample + classification = if ($sample -eq 1) { 'first-in-series' } else { 'warm' } + process_to_window_ms = [Math]::Round($watch.Elapsed.TotalMilliseconds, 2) + working_set_bytes = $process.WorkingSet64 + private_memory_bytes = $process.PrivateMemorySize64 + } + } finally { + if (-not $process.HasExited) { + Stop-Process -Id $process.Id -Force + $process.WaitForExit() + } + } +} + +$warm = @($results | Where-Object classification -eq 'warm') +$summary = [pscustomobject]@{ + schema = 'sortilune.startup-measurement' + schema_version = 2 + measured_at_utc = [DateTime]::UtcNow.ToString('o') + executable = $executable + executable_bytes = (Get-Item -LiteralPath $executable).Length + timeout_seconds = $TimeoutSeconds + idle_seconds = $IdleSeconds + samples = $results + warm_process_to_window_average_ms = if ($warm.Count) { [Math]::Round(($warm.process_to_window_ms | Measure-Object -Average).Average, 2) } else { $null } + warm_process_to_window_max_ms = if ($warm.Count) { [Math]::Round(($warm.process_to_window_ms | Measure-Object -Maximum).Maximum, 2) } else { $null } + idle_working_set_average_bytes = [Math]::Round(($results.working_set_bytes | Measure-Object -Average).Average) + idle_private_memory_average_bytes = [Math]::Round(($results.private_memory_bytes | Measure-Object -Average).Average) + measurement_semantics = "Process start until MainWindowHandle became non-zero; memory sampled after $IdleSeconds idle seconds. This is not a visual-ready metric." +} + +$summary | ConvertTo-Json -Depth 5 diff --git a/scripts/package-windows-release.mjs b/scripts/package-windows-release.mjs new file mode 100644 index 0000000..253949f --- /dev/null +++ b/scripts/package-windows-release.mjs @@ -0,0 +1,35 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { copyFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const root = process.cwd(); +const packageInfo = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); +const source = path.join(root, 'src-tauri', 'target', 'release', 'sortilune.exe'); +const releaseDirectory = path.join(root, 'artifacts', 'release'); +const releaseName = `Sortilune-${packageInfo.version}-windows-x64.exe`; +const destination = path.join(releaseDirectory, releaseName); + +await stat(source); +await mkdir(releaseDirectory, { recursive: true }); +await copyFile(source, destination); + +const hash = createHash('sha256'); +for await (const chunk of createReadStream(destination)) hash.update(chunk); +const sha256 = hash.digest('hex').toUpperCase(); +const releaseStat = await stat(destination); +const checksumName = `${releaseName}.sha256`; +await writeFile(path.join(releaseDirectory, checksumName), `${sha256} ${releaseName}\n`, 'utf8'); +await writeFile(path.join(releaseDirectory, 'release-manifest.json'), `${JSON.stringify({ + product: 'Sortilune', + version: packageInfo.version, + platform: 'windows', + architecture: 'x64', + portable: true, + file: releaseName, + bytes: releaseStat.size, + sha256, +}, null, 2)}\n`, 'utf8'); + +console.log(`Prepared ${destination}`); +console.log(`SHA-256 ${sha256}`); diff --git a/scripts/report-build-size.mjs b/scripts/report-build-size.mjs new file mode 100644 index 0000000..d0b1ce4 --- /dev/null +++ b/scripts/report-build-size.mjs @@ -0,0 +1,62 @@ +import { mkdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { inspectBuildDirectory } from './lib/build-artifacts.mjs'; + +const root = process.cwd(); +// The release frontend is bounded at 864 KiB and the portable Windows binary +// at 7 MiB. These budgets catch accidental dependency or asset growth. +const frontendBudget = 864 * 1024; +const windowsBinaryBudget = 7 * 1024 * 1024; +const frontendOnly = process.argv.includes('--frontend-only'); +const binaryOnly = process.argv.includes('--binary-only'); + +const metrics = { + generated_at: new Date().toISOString(), + budgets: { + frontend_bytes: frontendBudget, + windows_executable_bytes: windowsBinaryBudget, + }, +}; + +if (!binaryOnly) { + const frontendBuild = await inspectBuildDirectory(path.join(root, 'dist')); + if (frontendBuild.sourceMapFiles.length) { + throw new Error('frontend size report requires a production dist without source maps; run npm run build'); + } + const frontendBytes = frontendBuild.bytes; + metrics.frontend = { + path: 'dist', + bytes: frontendBytes, + within_budget: frontendBytes <= frontendBudget, + }; +} + +if (!frontendOnly) { + const executable = path.join(root, 'src-tauri', 'target', 'release', 'sortilune.exe'); + const executableBytes = (await stat(executable)).size; + metrics.windows_executable = { + path: path.relative(root, executable).replaceAll('\\', '/'), + bytes: executableBytes, + within_budget: executableBytes <= windowsBinaryBudget, + }; +} + +const outputDirectory = path.join(root, 'artifacts', 'quality'); +await mkdir(outputDirectory, { recursive: true }); +const suffix = frontendOnly ? 'frontend' : binaryOnly ? 'windows-binary' : 'complete'; +const outputPath = path.join(outputDirectory, `size-report-${suffix}.json`); +await writeFile(outputPath, `${JSON.stringify(metrics, null, 2)}\n`, 'utf8'); + +const lines = ['## Sortilune size report', '']; +if (metrics.frontend) lines.push(`- Frontend: ${metrics.frontend.bytes.toLocaleString('en-US')} / ${frontendBudget.toLocaleString('en-US')} bytes`); +if (metrics.windows_executable) lines.push(`- Windows executable: ${metrics.windows_executable.bytes.toLocaleString('en-US')} / ${windowsBinaryBudget.toLocaleString('en-US')} bytes`); +lines.push(''); +if (process.env.GITHUB_STEP_SUMMARY) { + const { appendFile } = await import('node:fs/promises'); + await appendFile(process.env.GITHUB_STEP_SUMMARY, `${lines.join('\n')}\n`, 'utf8'); +} +console.log(lines.join('\n')); + +if (metrics.frontend && !metrics.frontend.within_budget) throw new Error('frontend size budget exceeded'); +if (metrics.windows_executable && !metrics.windows_executable.within_budget) throw new Error('Windows executable size budget exceeded'); diff --git a/scripts/run-wdio-browser.mjs b/scripts/run-wdio-browser.mjs new file mode 100644 index 0000000..4fa08df --- /dev/null +++ b/scripts/run-wdio-browser.mjs @@ -0,0 +1,70 @@ +import { spawn, spawnSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const root = process.cwd(); +const viteEntry = fileURLToPath(new URL('../node_modules/vite/bin/vite.js', import.meta.url)); +const wdioEntry = fileURLToPath(new URL('../node_modules/@wdio/cli/bin/wdio.js', import.meta.url)); +const children = new Set(); +mkdirSync('artifacts/screenshots', { recursive: true }); + +function launch(args) { + const child = spawn(process.execPath, args, { + cwd: root, + env: { ...process.env, TAURI_DEV_HOST: '127.0.0.1' }, + stdio: 'inherit', + windowsHide: true, + detached: process.platform !== 'win32', + }); + children.add(child); + child.once('exit', () => children.delete(child)); + return child; +} + +async function stop(child) { + if (!child?.pid || child.exitCode !== null) return; + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }); + } else { + try { process.kill(-child.pid, 'SIGTERM'); } catch { /* already stopped */ } + } +} + +async function waitForServer(url, child, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Vite exited before becoming ready (${child.exitCode})`); + try { + const response = await fetch(url, { signal: AbortSignal.timeout(1_000) }); + if (response.ok) return; + } catch { + // Keep polling within the bounded readiness window. + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`Vite did not become ready within ${timeoutMs} ms`); +} + +let interrupted = false; +for (const signal of ['SIGINT', 'SIGTERM']) { + process.once(signal, async () => { + interrupted = true; + await Promise.all([...children].map(stop)); + process.exitCode = 130; + }); +} + +const vite = launch([viteEntry, '--host', '127.0.0.1']); +try { + await waitForServer('http://127.0.0.1:1420', vite); + const configPath = process.argv[2] || process.env.SORTILUNE_WDIO_CONFIG || 'wdio.browser.conf.mjs'; + const wdio = launch([wdioEntry, 'run', configPath]); + const exitCode = await new Promise((resolve, reject) => { + wdio.once('error', reject); + wdio.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); + if (!interrupted) process.exitCode = exitCode; +} finally { + await stop(vite); +} diff --git a/scripts/screenshot-beacon-only.mjs b/scripts/screenshot-beacon-only.mjs deleted file mode 100644 index 5d7c62e..0000000 --- a/scripts/screenshot-beacon-only.mjs +++ /dev/null @@ -1,99 +0,0 @@ -import puppeteer from 'puppeteer-core'; -import { spawn } from 'node:child_process'; -import path from 'node:path'; -import http from 'node:http'; - -const EXE = 'E:/Sortilune/sortilune.exe'; -const OUT = 'E:/Sortilune/wiki-screenshots/14-beacon.png'; - -function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } - -async function waitForDebugger(port, timeoutMs = 20000) { - const t0 = Date.now(); - while (Date.now() - t0 < timeoutMs) { - const ok = await new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${port}/json/version`, (res) => resolve(res.statusCode === 200)); - req.on('error', () => resolve(false)); - req.setTimeout(800, () => { req.destroy(); resolve(false); }); - }); - if (ok) return true; - await sleep(300); - } - return false; -} - -const env = { ...process.env, WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: '--remote-debugging-port=9222' }; -console.log('Launching sortilune.exe…'); -const child = spawn(EXE, [], { env, detached: false, stdio: 'ignore' }); -const ready = await waitForDebugger(9222); -if (!ready) { console.error('No debug port'); child.kill(); process.exit(2); } - -const browser = await puppeteer.connect({ browserURL: 'http://127.0.0.1:9222', defaultViewport: null }); -const pages = await browser.pages(); -const page = pages.find((p) => /tauri\.localhost|sortilune/i.test(p.url())) || pages[0]; -await page.setViewport({ width: 1600, height: 1100 }); -await sleep(800); -await page.addStyleTag({ content: 'body{overflow:visible !important;} .chamber-container{overflow:visible !important;height:auto !important;}' }); - -try { - // Warm up NIST by hitting Lottery coin first (which uses preferred chain = NIST first) - console.log('Warming NIST through coin flip…'); - await page.evaluate(() => document.querySelector('.tab[data-id="lottery"]').click()); - await sleep(1500); - // Try a few coin flips to populate cache - for (let i = 0; i < 2; i++) { - await page.evaluate(() => { - const b = document.querySelector('.coin-picker .btn-primary'); - if (b && !b.disabled) b.click(); - }); - await sleep(4000); - } - - // Navigate to Beacon - console.log('Beacon…'); - await page.evaluate(() => document.querySelector('.tab[data-id="beacon"]').click()); - await sleep(1200); - await page.evaluate(() => document.querySelectorAll('details.chamber-intro').forEach((d) => d.open = false)); - await sleep(300); - - // Type the entry - await page.evaluate(() => { - const ta = document.querySelector('.beacon-textarea'); - if (!ta) return; - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; - setter.call(ta, - "I predict that the migration will succeed without rollback, that the\n" + - "memory footprint will land within 6% of the existing baseline, and that\n" + - "the team will not need to extend Friday — though we'll come close.\n\n" + - "If this is true, this entry was sealed before the result was known."); - ta.dispatchEvent(new Event('input', { bubbles: true })); - }); - await sleep(500); - - // Click Seal - await page.evaluate(() => { - for (const b of document.querySelectorAll('.beacon-panel .btn-primary.btn-big')) { - if (b.textContent.trim().toLowerCase().includes('seal')) { b.click(); return; } - } - }); - console.log('Sealing (up to 90 s)…'); - const sealed = await page.waitForSelector('.sealed-badge', { timeout: 90000 }).catch((e) => { console.error(' seal wait:', e.message); return null; }); - if (sealed) { - console.log(' sealed.'); - await sleep(2500); - } else { - console.log(' did not seal — capturing current state anyway.'); - } - - await page.screenshot({ path: OUT, fullPage: true }); - console.log(' →', OUT); -} catch (err) { - console.error('FAILED:', err); - await page.screenshot({ path: OUT, fullPage: true }).catch(() => {}); - process.exitCode = 1; -} finally { - try { browser.disconnect(); } catch {} - await sleep(400); - try { child.kill(); } catch {} - setTimeout(() => process.exit(process.exitCode || 0), 500); -} diff --git a/scripts/screenshot-wiki.mjs b/scripts/screenshot-wiki.mjs deleted file mode 100644 index fcd9486..0000000 --- a/scripts/screenshot-wiki.mjs +++ /dev/null @@ -1,441 +0,0 @@ -/** - * Drives the running Sortilune .exe via CDP and captures wiki-quality - * screenshots of every chamber with realistic populated state. - * - * Run with: $env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = '--remote-debugging-port=9222' - * node scripts/screenshot-wiki.mjs - */ - -import puppeteer from 'puppeteer-core'; -import { spawn } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import http from 'node:http'; - -const ROOT = 'E:/Sortilune'; -const EXE = path.join(ROOT, 'sortilune.exe'); -const OUT = path.join(ROOT, 'wiki-screenshots'); -fs.mkdirSync(OUT, { recursive: true }); - -const VIEWPORT = { width: 1600, height: 1100 }; - -function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } - -async function waitForDebugger(port, timeoutMs = 20_000) { - const t0 = Date.now(); - while (Date.now() - t0 < timeoutMs) { - const ok = await new Promise((resolve) => { - const req = http.get(`http://127.0.0.1:${port}/json/version`, (res) => { - let d = ''; - res.on('data', (c) => d += c); - res.on('end', () => resolve(res.statusCode === 200)); - }); - req.on('error', () => resolve(false)); - req.setTimeout(800, () => { req.destroy(); resolve(false); }); - }); - if (ok) return true; - await sleep(300); - } - return false; -} - -const env = { - ...process.env, - WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: '--remote-debugging-port=9222', -}; - -console.log('Launching sortilune.exe with remote debugging…'); -const child = spawn(EXE, [], { env, detached: false, stdio: 'ignore' }); - -const ready = await waitForDebugger(9222); -if (!ready) { - console.error('Could not reach debug port. WebView2 may not be honoring the env var.'); - try { child.kill(); } catch {} - process.exit(2); -} -console.log('Debug port live.'); - -const browser = await puppeteer.connect({ - browserURL: 'http://127.0.0.1:9222', - defaultViewport: null, -}); - -const pages = await browser.pages(); -const page = pages.find((p) => /tauri\.localhost|sortilune/i.test(p.url())) || pages[0]; -await page.setViewport(VIEWPORT); -console.log('Connected to:', page.url()); -await sleep(600); - -// Inject a stylesheet that lets the page itself grow vertically so full-page -// screenshots capture the entire chamber instead of getting clipped by the -// chamber-container's own scroll. -await page.addStyleTag({ - content: ` - body { overflow: visible !important; } - .chamber-container { overflow: visible !important; height: auto !important; } - `, -}); - -async function shoot(name, opts = {}) { - const file = path.join(OUT, `${name}.png`); - await page.screenshot({ path: file, fullPage: !!opts.fullPage }); - console.log(' →', name); -} - -async function click(sel, delay = 250) { - await page.waitForSelector(sel, { timeout: 8000 }); - await page.click(sel); - await sleep(delay); -} - -async function tab(id) { - // Click the tab and wait for the chamber-changed CustomEvent. - await page.evaluate((targetId) => { - return new Promise((resolve) => { - const handler = (e) => { - if (e.detail?.id === targetId) { - document.removeEventListener('chamber-changed', handler); - resolve(); - } - }; - document.addEventListener('chamber-changed', handler); - const btn = document.querySelector(`.tab[data-id="${targetId}"]`); - if (btn) btn.click(); - // safety timeout - setTimeout(() => resolve(), 4000); - }); - }, id); - await sleep(700); -} - -async function collapseIntros() { - await page.evaluate(() => { - document.querySelectorAll('details.chamber-intro').forEach((d) => { d.open = false; }); - }); - await sleep(150); -} - -async function setText(sel, text) { - await page.waitForSelector(sel); - await page.evaluate((s, t) => { - const el = document.querySelector(s); - if (!el) return; - const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; - const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; - setter.call(el, t); - el.dispatchEvent(new Event('input', { bubbles: true })); - }, sel, text); -} - -async function clickByText(rootSel, text) { - await page.evaluate((r, t) => { - const root = document.querySelector(r) || document; - const candidates = root.querySelectorAll('button'); - for (const b of candidates) { - if (b.textContent.trim().toLowerCase().includes(t.toLowerCase())) { - b.click(); - return true; - } - } - return false; - }, rootSel, text); - await sleep(300); -} - -// Warm-up: hit NIST once at startup so the cache is primed and downstream -// chambers don't see a cold-start delay. -async function warmNist() { - console.log('Warming NIST cache…'); - await tab('lottery'); - await sleep(400); - // Click coin → flip with preferred chain (NIST first) - await click('.coin-picker .btn-primary'); - await page.waitForSelector('.coin-result-big', { timeout: 30000 }).catch(() => null); - await sleep(700); -} - -try { - await warmNist(); - - // === 1. ORACLE — Cosmic single draw ================================= - console.log('Oracle (Cosmic single)…'); - await tab('oracle'); - await collapseIntros(); - await page.evaluate(() => { - for (const p of document.querySelectorAll('.oracle-deck-pill')) - if (p.textContent.toLowerCase().includes('cosmic') && p.getAttribute('aria-current') !== 'true') p.click(); - for (const b of document.querySelectorAll('.oracle-row .segmented-opt')) - if (b.textContent.trim().toLowerCase() === 'one card' && b.getAttribute('aria-pressed') !== 'true') b.click(); - }); - await sleep(400); - await click('.oracle-draw-btn'); - await page.waitForSelector('.oracle-result .oracle-card', { timeout: 25000 }); - await sleep(2000); - await shoot('01-oracle-cosmic', { fullPage: true }); - - // === 2. ORACLE — Tarot single draw (different motif/palette) ======== - console.log('Oracle (Tarot single)…'); - await clickByText('.oracle-result', 'Back'); - await sleep(400); - await page.evaluate(() => { - for (const p of document.querySelectorAll('.oracle-deck-pill')) - if (p.textContent.toLowerCase().includes('tarot') && p.getAttribute('aria-current') !== 'true') p.click(); - }); - await sleep(400); - await click('.oracle-draw-btn'); - await page.waitForSelector('.oracle-result .oracle-card', { timeout: 25000 }); - await sleep(2000); - await shoot('02-oracle-tarot', { fullPage: true }); - - // === 3. ORACLE — I-Ching single draw ================================ - console.log('Oracle (I-Ching single)…'); - await clickByText('.oracle-result', 'Back'); - await sleep(400); - await page.evaluate(() => { - for (const p of document.querySelectorAll('.oracle-deck-pill')) - if (p.textContent.toLowerCase().includes('i-ching') && p.getAttribute('aria-current') !== 'true') p.click(); - }); - await sleep(400); - await click('.oracle-draw-btn'); - await page.waitForSelector('.oracle-result .oracle-card', { timeout: 25000 }); - await sleep(2000); - await shoot('03-oracle-iching', { fullPage: true }); - - // === 4. LOTTERY — coin flip ========================================= - console.log('Lottery (coin)…'); - await tab('lottery'); - await click('.lottery-subtab[data-id="coin"]'); - await sleep(500); - await click('.coin-picker .btn-primary'); - await page.waitForSelector('.coin-result-big', { timeout: 15000 }); - await sleep(2000); - await shoot('04-lottery-coin', { fullPage: true }); - - // === 5. LOTTERY — dice 4d6 ========================================== - console.log('Lottery (dice)…'); - await click('.lottery-subtab[data-id="dice"]'); - await sleep(500); - await page.evaluate(() => { - const inp = document.querySelector('.dice-controls input[type="number"]'); - if (inp) { - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; - setter.call(inp, '4'); - inp.dispatchEvent(new Event('input', { bubbles: true })); - } - }); - await sleep(500); - await click('.dice-picker .btn-primary'); - await page.waitForSelector('.dice-result-row', { timeout: 15000 }); - await sleep(2000); - await shoot('05-lottery-dice', { fullPage: true }); - - // === 6. LOTTERY — wheel ============================================= - console.log('Lottery (wheel)…'); - await click('.lottery-subtab[data-id="wheel"]'); - await sleep(500); - await page.evaluate(() => { - const ta = document.querySelector('.wheel-picker textarea'); - if (ta) { - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; - setter.call(ta, 'Yes\nNo\nAsk again later\nGo for a walk first'); - ta.dispatchEvent(new Event('input', { bubbles: true })); - } - }); - await sleep(400); - await click('.wheel-picker .btn-primary'); - await page.waitForSelector('.wheel-winner-big', { timeout: 15000 }); - await sleep(2500); - await shoot('06-lottery-wheel', { fullPage: true }); - - // === 7. DECIDER — high-stakes ======================================== - console.log('Decider…'); - await tab('decider'); - await setText('.decider-input textarea.textarea', 'Should the team ship the migration today, or wait one more sprint?'); - await page.evaluate(() => { - const opts = document.querySelectorAll('.option-row input[type="text"]'); - const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; - if (opts[0]) { setter.call(opts[0], 'Ship today — momentum carries.'); opts[0].dispatchEvent(new Event('input', { bubbles: true })); } - if (opts[1]) { setter.call(opts[1], 'Wait one sprint — close the loose ends.'); opts[1].dispatchEvent(new Event('input', { bubbles: true })); } - for (const b of document.querySelectorAll('.decider-input .segmented .segmented-opt')) - if (b.textContent.trim().toLowerCase() === 'high stakes' && b.getAttribute('aria-pressed') !== 'true') b.click(); - }); - await sleep(500); - await click('.decider-input .btn-primary'); - await page.waitForSelector('.cert', { timeout: 25000 }); - await sleep(2000); - await shoot('07-decider', { fullPage: true }); - - // === 8. DIARY ======================================================= - console.log('Diary…'); - await tab('diary'); - await collapseIntros(); - const hasPrompt = await page.evaluate(() => !!document.querySelector('.diary-prompt-card')); - if (!hasPrompt) { - await click('.diary-no-prompt .btn-primary'); - await page.waitForSelector('.diary-prompt-card', { timeout: 25000 }); - await sleep(1500); - } - await page.waitForSelector('.diary-textarea', { timeout: 8000 }); - await setText('.diary-textarea', - 'The light through the kitchen window today was that early-November color — \n' + - 'soft amber, no shadow with crisp edges. I noticed the wood floorboards have\n' + - 'a grain that runs perpendicular to the way I always assumed.\n\n' + - 'Three things I almost overlooked:\n' + - '- the dust on the highest shelf, suspended in that one beam of light\n' + - '- a tiny crack along the doorway lintel\n' + - '- how quiet the dishwasher actually is in its last cycle\n'); - await sleep(500); - await shoot('08-diary', { fullPage: true }); - - // === 9. CONSTRAINT ================================================== - console.log('Constraint…'); - await tab('constraint'); - await collapseIntros(); - await sleep(300); - // Find the "Draw a constraint" big button anywhere in the chamber frame. - const drewConstraint = await page.evaluate(() => { - for (const b of document.querySelectorAll('.chamber-frame .btn-primary.btn-big')) { - if (b.textContent.toLowerCase().includes('draw a constraint')) { b.click(); return true; } - } - return false; - }); - console.log(' draw button clicked:', drewConstraint); - await page.waitForSelector('.constraint-result', { timeout: 20000 }); - await sleep(2000); - await shoot('09-constraint', { fullPage: true }); - - // === 10. CANVAS — Constellation ==================================== - console.log('Canvas (constellation)…'); - await tab('canvas'); - await sleep(400); - await page.evaluate(() => { - for (const pill of document.querySelectorAll('.canvas-type-pill')) - if (pill.textContent.toLowerCase().includes('constellation')) pill.click(); - }); - await sleep(400); - await click('.canvas-config .btn-primary'); - await page.waitForSelector('.canvas-art .canvas-svg', { timeout: 25000 }); - await sleep(2500); - await shoot('10-canvas-constellation', { fullPage: true }); - - // === 11. CANVAS — Voronoi ========================================== - console.log('Canvas (voronoi)…'); - await page.evaluate(() => { - for (const pill of document.querySelectorAll('.canvas-type-pill')) - if (pill.textContent.toLowerCase().includes('voronoi')) pill.click(); - }); - await sleep(400); - await click('.canvas-config .btn-primary'); - // Wait for SVG to update (the canvas-art innerHTML is replaced) - await sleep(3500); - await shoot('11-canvas-voronoi', { fullPage: true }); - - // === 12. CANVAS — Particle traces ================================== - console.log('Canvas (particles)…'); - await page.evaluate(() => { - for (const pill of document.querySelectorAll('.canvas-type-pill')) - if (pill.textContent.toLowerCase().includes('particle')) pill.click(); - }); - await sleep(400); - await click('.canvas-config .btn-primary'); - await sleep(3500); - await shoot('12-canvas-particles', { fullPage: true }); - - // === 13. SYMPHONY =================================================== - console.log('Symphony (playing)…'); - await tab('symphony'); - await collapseIntros(); - await sleep(500); - // Click the big primary button in the controls panel - await page.evaluate(() => { - for (const b of document.querySelectorAll('.symphony-controls .btn-primary')) { - if (b.textContent.trim().toLowerCase() === 'play') { b.click(); return; } - } - }); - console.log(' waiting for opening invocation + planet to populate (~16s)…'); - await sleep(16000); - await shoot('13-symphony', { fullPage: true }); - // Stop it so audio doesn't keep going - await page.evaluate(() => { - for (const b of document.querySelectorAll('.symphony-controls .btn-primary')) { - if (b.textContent.trim().toLowerCase() === 'stop') b.click(); - } - }); - await sleep(800); - - // === 14. BEACON ==================================================== - console.log('Beacon (sealing)…'); - await tab('beacon'); - await collapseIntros(); - await sleep(300); - await setText('.beacon-textarea', - "I predict that the migration will succeed without rollback, that the\n" + - "memory footprint will land within 6% of the existing baseline, and that\n" + - "the team will not need to extend Friday — though we'll come close.\n\n" + - "If this is true, this entry was sealed before the result was known."); - await sleep(500); - // Click Seal button (big primary inside beacon-panel) - await page.evaluate(() => { - for (const b of document.querySelectorAll('.beacon-panel .btn-primary.btn-big')) { - if (b.textContent.trim().toLowerCase().includes('seal')) { b.click(); return; } - } - }); - // Longer wait — NIST can be slow - const sealed = await page.waitForSelector('.sealed-badge', { timeout: 45000 }).catch(() => null); - if (!sealed) { - console.warn(' WARNING: Beacon seal did not complete in 45s — screenshotting current state'); - } else { - await sleep(2000); - } - await shoot('14-beacon', { fullPage: true }); - - // === 15. ARCHIVE =================================================== - console.log('Archive…'); - await page.evaluate(() => { - for (const b of document.querySelectorAll('.topbar-right button')) if (b.title === 'Archive') b.click(); - }); - await sleep(2500); - await shoot('15-archive', { fullPage: true }); - - // === 16. SETTINGS — Test sources =================================== - console.log('Settings…'); - await page.evaluate(() => { - for (const b of document.querySelectorAll('.topbar-right button')) if (b.title === 'Settings') b.click(); - }); - await sleep(900); - await page.evaluate(() => { - for (const b of document.querySelectorAll('.settings-panel button')) - if (b.textContent.trim().toLowerCase().includes('test all sources')) b.click(); - }); - console.log(' waiting for source tests (~22s)…'); - await sleep(22000); - await shoot('16-settings', { fullPage: false }); // fullPage on overlay would capture huge backdrop - - // === 17. HERO — Oracle with intro panel open (good landing card) === - console.log('Hero (Oracle landing with intro)…'); - await page.evaluate(() => { document.querySelector('.settings-overlay')?.remove(); }); - await sleep(300); - await tab('oracle'); - // Make sure the back button is clicked if there's a result panel - await page.evaluate(() => { - const back = [...document.querySelectorAll('.oracle-result button')].find(b => /back/i.test(b.textContent)); - if (back) back.click(); - }); - await sleep(500); - await page.evaluate(() => { - document.querySelectorAll('details.chamber-intro').forEach((d) => { d.open = true; }); - }); - await sleep(400); - await shoot('00-hero-oracle-intro', { fullPage: true }); - - console.log('\nAll screenshots written to:', OUT); -} catch (err) { - console.error('FAILED:', err); - try { await page.screenshot({ path: path.join(OUT, '_failure.png'), fullPage: false }); } catch {} - process.exitCode = 1; -} finally { - try { browser.disconnect(); } catch {} - await sleep(400); - try { child.kill(); } catch {} - setTimeout(() => process.exit(process.exitCode || 0), 500); -} diff --git a/scripts/simplify-land.mjs b/scripts/simplify-land.mjs index bd8d080..d3b095f 100644 --- a/scripts/simplify-land.mjs +++ b/scripts/simplify-land.mjs @@ -1,9 +1,12 @@ // One-off: simplify the Natural Earth 110m land GeoJSON into a compact // list-of-rings array for inlining in the Symphony planet display. import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const input = 'E:/Sortilune/src/chambers/symphony/_world-land.json'; -const output = 'E:/Sortilune/src/chambers/symphony/_land.js'; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const input = path.join(root, 'src', 'chambers', 'symphony', '_world-land.json'); +const output = path.join(root, 'src', 'chambers', 'symphony', '_land.js'); const TOL = 0.5; // degrees — coarse enough to drop most jagged coast points, fine enough to keep peninsulas function distSq(p, a, b) { @@ -42,7 +45,7 @@ function ringArea(ring) { return Math.abs(a / 2); } -const raw = fs.readFileSync(input, 'utf8').replace(/^/, ''); +const raw = fs.readFileSync(input, 'utf8').replace(/^\uFEFF/, ''); const data = JSON.parse(raw); const rings = []; let total = 0; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36893ed..94b5803 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -59,6 +59,17 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atk" version = "0.18.2" @@ -66,7 +77,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" dependencies = [ "atk-sys", - "glib", + "glib 0.18.5", "libc", ] @@ -76,10 +87,10 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -94,6 +105,58 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.13.1" @@ -236,7 +299,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ "bitflags 2.11.1", "cairo-sys-rs", - "glib", + "glib 0.18.5", "libc", "once_cell", "thiserror 1.0.69", @@ -248,9 +311,9 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" dependencies = [ - "glib-sys", + "glib-sys 0.18.1", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -329,7 +392,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.12.16", +] + +[[package]] +name = "cfg-expr" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" +dependencies = [ + "smallvec", + "target-lexicon 0.13.5", ] [[package]] @@ -833,6 +906,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -858,6 +941,15 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -928,6 +1020,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1009,7 +1110,7 @@ dependencies = [ "gdk-pixbuf", "gdk-sys", "gio", - "glib", + "glib 0.18.5", "libc", "pango", ] @@ -1022,7 +1123,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" dependencies = [ "gdk-pixbuf-sys", "gio", - "glib", + "glib 0.18.5", "libc", "once_cell", ] @@ -1033,11 +1134,11 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1048,13 +1149,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "pango-sys", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1064,11 +1165,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" dependencies = [ "gdk-sys", - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "pkg-config", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1080,7 +1181,7 @@ dependencies = [ "gdk", "gdkx11-sys", "gio", - "glib", + "glib 0.18.5", "libc", "x11", ] @@ -1092,9 +1193,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" dependencies = [ "gdk-sys", - "glib-sys", + "glib-sys 0.18.1", "libc", - "system-deps", + "system-deps 6.2.2", "x11", ] @@ -1169,8 +1270,8 @@ dependencies = [ "futures-core", "futures-io", "futures-util", - "gio-sys", - "glib", + "gio-sys 0.18.1", + "glib 0.18.5", "libc", "once_cell", "pin-project-lite", @@ -1184,13 +1285,26 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", "winapi", ] +[[package]] +name = "gio-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22" +dependencies = [ + "glib-sys 0.21.5", + "gobject-sys 0.21.5", + "libc", + "system-deps 7.0.8", + "windows-sys 0.61.2", +] + [[package]] name = "glib" version = "0.18.5" @@ -1203,10 +1317,10 @@ dependencies = [ "futures-executor", "futures-task", "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-macros 0.18.5", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "memchr", "once_cell", @@ -1214,6 +1328,27 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "glib" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys 0.21.5", + "glib-macros 0.21.5", + "glib-sys 0.21.5", + "gobject-sys 0.21.5", + "libc", + "memchr", + "smallvec", +] + [[package]] name = "glib-macros" version = "0.18.5" @@ -1228,6 +1363,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "glib-macros" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17" +dependencies = [ + "heck 0.5.0", + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "glib-sys" version = "0.18.1" @@ -1235,7 +1383,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" dependencies = [ "libc", - "system-deps", + "system-deps 6.2.2", +] + +[[package]] +name = "glib-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c" +dependencies = [ + "libc", + "system-deps 7.0.8", ] [[package]] @@ -1250,9 +1408,20 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" dependencies = [ - "glib-sys", + "glib-sys 0.18.1", + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "gobject-sys" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294" +dependencies = [ + "glib-sys 0.21.5", "libc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -1268,7 +1437,7 @@ dependencies = [ "gdk", "gdk-pixbuf", "gio", - "glib", + "glib 0.18.5", "gtk-sys", "gtk3-macros", "libc", @@ -1286,12 +1455,12 @@ dependencies = [ "cairo-sys-rs", "gdk-pixbuf-sys", "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", "pango-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1414,6 +1583,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.9.0" @@ -1428,6 +1603,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1657,6 +1833,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1676,7 +1872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" dependencies = [ "bitflags 1.3.2", - "glib", + "glib 0.18.5", "javascriptcore-rs-sys", ] @@ -1686,10 +1882,10 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -1781,6 +1977,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "285efcf12ef41bec907b3000d5ffaeb54191d4d9d83c0d6157e6cbc2db255e64" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1793,7 +2009,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" dependencies = [ - "glib", + "glib 0.18.5", "gtk", "gtk-sys", "libappindicator-sys", @@ -1845,6 +2061,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1889,6 +2111,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1927,6 +2155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -1982,6 +2211,47 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", + "serde", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -2037,9 +2307,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", "objc2-foundation", + "objc2-quartz-core", ] [[package]] @@ -2059,6 +2337,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ + "bitflags 2.11.1", "objc2", "objc2-foundation", ] @@ -2119,6 +2398,19 @@ dependencies = [ "objc2-core-graphics", ] +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2158,6 +2450,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2170,6 +2472,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -2213,6 +2526,8 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-javascript-core", + "objc2-security", ] [[package]] @@ -2234,7 +2549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" dependencies = [ "gio", - "glib", + "glib 0.18.5", "libc", "once_cell", "pango-sys", @@ -2246,10 +2561,10 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" dependencies = [ - "glib-sys", - "gobject-sys", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -2786,6 +3101,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -2833,6 +3172,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -3041,6 +3393,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -3220,15 +3583,19 @@ dependencies = [ [[package]] name = "sortilune" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", + "tauri-plugin-wdio", + "tauri-plugin-wdio-webdriver", "wallpaper", + "windows-sys 0.61.2", ] [[package]] @@ -3239,7 +3606,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" dependencies = [ "futures-channel", "gio", - "glib", + "glib 0.18.5", "libc", "soup3-sys", ] @@ -3250,11 +3617,11 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "libc", - "system-deps", + "system-deps 6.2.2", ] [[package]] @@ -3378,13 +3745,26 @@ version = "6.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" dependencies = [ - "cfg-expr", + "cfg-expr 0.15.8", "heck 0.5.0", "pkg-config", "toml 0.8.2", "version-compare", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr 0.20.8", + "heck 0.5.0", + "pkg-config", + "toml 1.1.2+spec-1.1.0", + "version-compare", +] + [[package]] name = "tao" version = "0.35.2" @@ -3442,6 +3822,12 @@ version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tauri" version = "2.11.1" @@ -3571,6 +3957,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.5.1" @@ -3581,6 +3985,8 @@ dependencies = [ "dunce", "glob", "log", + "notify", + "notify-debouncer-full", "objc2-foundation", "percent-encoding", "schemars 0.8.22", @@ -3619,6 +4025,56 @@ dependencies = [ "urlpattern", ] +[[package]] +name = "tauri-plugin-wdio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aa9b559f110924b9897793767a3bb4880c195db75838030eb224f94d21565d9" +dependencies = [ + "log", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin", + "thiserror 1.0.69", + "tokio", + "uuid", +] + +[[package]] +name = "tauri-plugin-wdio-webdriver" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c5bffe978c41b06ad44a5f4b5b543405918cf316b98756c678a6431061f2e9" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "block2", + "cairo-rs", + "glib 0.21.5", + "gtk", + "javascriptcore-rs", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-web-kit", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", + "webkit2gtk", + "webview2-com", + "windows", + "windows-core 0.61.2", +] + [[package]] name = "tauri-runtime" version = "2.11.1" @@ -3719,6 +4175,19 @@ dependencies = [ "toml 1.1.2+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.0" @@ -4007,6 +4476,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -4045,10 +4515,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4451,10 +4934,10 @@ dependencies = [ "gdk", "gdk-sys", "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib 0.18.5", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "gtk", "gtk-sys", "javascriptcore-rs", @@ -4473,15 +4956,15 @@ dependencies = [ "bitflags 1.3.2", "cairo-sys-rs", "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", + "gio-sys 0.18.1", + "glib-sys 0.18.1", + "gobject-sys 0.18.0", "gtk-sys", "javascriptcore-rs-sys", "libc", "pkg-config", "soup3-sys", - "system-deps", + "system-deps 6.2.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 530eb07..f4f3731 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,9 @@ [package] name = "sortilune" -version = "0.1.0" +version = "0.2.0" description = "Moon-cast lots: divination by physical randomness." authors = ["Sortilune"] +license = "MIT" edition = "2021" [lib] @@ -13,16 +14,23 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["devtools"] } +tauri = { version = "2", features = [] } tauri-plugin-http = "2" -tauri-plugin-fs = "2" +tauri-plugin-fs = { version = "2", features = ["watch"] } +tauri-plugin-dialog = "2.7.1" serde = { version = "1", features = ["derive"] } serde_json = "1" wallpaper = "3" +tauri-plugin-wdio = { version = "1.2.0", optional = true } +tauri-plugin-wdio-webdriver = { version = "1.2.0", optional = true } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } [features] # Default feature set; "custom-protocol" is enabled for production by tauri-build. custom-protocol = ["tauri/custom-protocol"] +wdio = ["dep:tauri-plugin-wdio", "dep:tauri-plugin-wdio-webdriver"] [profile.release] panic = "abort" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e..1e1d2f7 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,29 @@ fn main() { - tauri_build::build() + const COMMANDS: &[&str] = &[ + "clear_archive_cache", + "delete_pack_content", + "export_pack_file", + "list_projects", + "read_practice_store", + "write_practice_store", + "read_project", + "write_project", + "delete_project", + "read_pack_content", + "read_pack_registry", + "reveal_archive", + "select_pack_file", + "set_wallpaper", + "write_archive_annotations", + "write_archive_cache", + "write_archive", + "write_archive_batch", + "write_pack_content", + "write_pack_registry", + ]; + tauri_build::try_build( + tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(COMMANDS)), + ) + .expect("failed to build Sortilune's Tauri command manifest"); } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1cefd83..c049381 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,7 +11,27 @@ "core:event:default", "core:path:default", - "http:default", + "allow-reveal-archive", + "allow-clear-archive-cache", + "allow-set-wallpaper", + "allow-write-archive-annotations", + "allow-write-archive-cache", + "allow-write-archive", + "allow-write-archive-batch", + "allow-select-pack-file", + "allow-read-pack-registry", + "allow-write-pack-registry", + "allow-read-pack-content", + "allow-write-pack-content", + "allow-delete-pack-content", + "allow-export-pack-file", + "allow-list-projects", + "allow-read-project", + "allow-write-project", + "allow-delete-project", + "allow-read-practice-store", + "allow-write-practice-store", + { "identifier": "http:default", "allow": [ @@ -23,21 +43,18 @@ ] }, - "fs:default", - "fs:allow-app-read-recursive", - "fs:allow-app-write-recursive", - "fs:allow-app-meta-recursive", - "fs:allow-appdata-read-recursive", - "fs:allow-appdata-write-recursive", - "fs:allow-appdata-meta-recursive", - "fs:allow-mkdir", - "fs:allow-create", "fs:allow-read-file", "fs:allow-read-text-file", "fs:allow-read-dir", - "fs:allow-write-file", - "fs:allow-write-text-file", "fs:allow-exists", - "fs:allow-remove" + "fs:allow-watch", + "fs:allow-unwatch", + { + "identifier": "fs:scope", + "allow": [ + "$APPDATA/archive", + "$APPDATA/archive/**/*" + ] + } ] } diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png deleted file mode 100644 index 0cc054b..0000000 Binary files a/src-tauri/icons/64x64.png and /dev/null differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index e4121ce..0000000 Binary files a/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index 367ec26..0000000 Binary files a/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 317b330..0000000 Binary files a/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index 5bd11f1..0000000 Binary files a/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index d9af3e2..0000000 Binary files a/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 763bf89..0000000 Binary files a/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index c746fc1..0000000 Binary files a/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index 7d34836..0000000 Binary files a/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index a707e6c..0000000 Binary files a/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png deleted file mode 100644 index 31b305e..0000000 Binary files a/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2ffbf24..0000000 --- a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 6030b65..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index fa3f267..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index f84189d..0000000 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 419cb9d..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 8074860..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 66cd5ae..0000000 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 421f683..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index 6755175..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 33b8dd1..0000000 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index a710d65..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index eab3957..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 8f10a24..0000000 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index c37a60f..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 0de57b3..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index bff76ad..0000000 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml deleted file mode 100644 index ea9c223..0000000 --- a/src-tauri/icons/android/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #fff - \ No newline at end of file diff --git a/src-tauri/icons/app-icon.png b/src-tauri/icons/app-icon.png deleted file mode 100644 index 56adb0f..0000000 Binary files a/src-tauri/icons/app-icon.png and /dev/null differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns deleted file mode 100644 index 2fe3e9f..0000000 Binary files a/src-tauri/icons/icon.icns and /dev/null differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png deleted file mode 100644 index 61589a4..0000000 Binary files a/src-tauri/icons/icon.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index c3918ca..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index bd9f6d4..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index bd9f6d4..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index 521c874..0000000 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index edb63a0..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index 5caa6fb..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index 5caa6fb..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index ef45963..0000000 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index bd9f6d4..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index ee73b80..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index ee73b80..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 78cd092..0000000 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 020ff7f..0000000 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 78cd092..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index fa57bbb..0000000 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index 790f5f0..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index b746bca..0000000 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index 5cadf27..0000000 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/src-tauri/permissions/autogenerated/clear_archive_cache.toml b/src-tauri/permissions/autogenerated/clear_archive_cache.toml new file mode 100644 index 0000000..e3e9c6e --- /dev/null +++ b/src-tauri/permissions/autogenerated/clear_archive_cache.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-clear-archive-cache" +description = "Enables the clear_archive_cache command without any pre-configured scope." +commands.allow = ["clear_archive_cache"] + +[[permission]] +identifier = "deny-clear-archive-cache" +description = "Denies the clear_archive_cache command without any pre-configured scope." +commands.deny = ["clear_archive_cache"] diff --git a/src-tauri/permissions/autogenerated/delete_pack_content.toml b/src-tauri/permissions/autogenerated/delete_pack_content.toml new file mode 100644 index 0000000..f42b570 --- /dev/null +++ b/src-tauri/permissions/autogenerated/delete_pack_content.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-delete-pack-content" +description = "Enables the delete_pack_content command without any pre-configured scope." +commands.allow = ["delete_pack_content"] + +[[permission]] +identifier = "deny-delete-pack-content" +description = "Denies the delete_pack_content command without any pre-configured scope." +commands.deny = ["delete_pack_content"] diff --git a/src-tauri/permissions/autogenerated/delete_project.toml b/src-tauri/permissions/autogenerated/delete_project.toml new file mode 100644 index 0000000..7ed910a --- /dev/null +++ b/src-tauri/permissions/autogenerated/delete_project.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-delete-project" +description = "Enables the delete_project command without any pre-configured scope." +commands.allow = ["delete_project"] + +[[permission]] +identifier = "deny-delete-project" +description = "Denies the delete_project command without any pre-configured scope." +commands.deny = ["delete_project"] diff --git a/src-tauri/permissions/autogenerated/export_pack_file.toml b/src-tauri/permissions/autogenerated/export_pack_file.toml new file mode 100644 index 0000000..9f0835a --- /dev/null +++ b/src-tauri/permissions/autogenerated/export_pack_file.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-export-pack-file" +description = "Enables the export_pack_file command without any pre-configured scope." +commands.allow = ["export_pack_file"] + +[[permission]] +identifier = "deny-export-pack-file" +description = "Denies the export_pack_file command without any pre-configured scope." +commands.deny = ["export_pack_file"] diff --git a/src-tauri/permissions/autogenerated/list_projects.toml b/src-tauri/permissions/autogenerated/list_projects.toml new file mode 100644 index 0000000..7075df1 --- /dev/null +++ b/src-tauri/permissions/autogenerated/list_projects.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-list-projects" +description = "Enables the list_projects command without any pre-configured scope." +commands.allow = ["list_projects"] + +[[permission]] +identifier = "deny-list-projects" +description = "Denies the list_projects command without any pre-configured scope." +commands.deny = ["list_projects"] diff --git a/src-tauri/permissions/autogenerated/read_pack_content.toml b/src-tauri/permissions/autogenerated/read_pack_content.toml new file mode 100644 index 0000000..b0f5e27 --- /dev/null +++ b/src-tauri/permissions/autogenerated/read_pack_content.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-read-pack-content" +description = "Enables the read_pack_content command without any pre-configured scope." +commands.allow = ["read_pack_content"] + +[[permission]] +identifier = "deny-read-pack-content" +description = "Denies the read_pack_content command without any pre-configured scope." +commands.deny = ["read_pack_content"] diff --git a/src-tauri/permissions/autogenerated/read_pack_registry.toml b/src-tauri/permissions/autogenerated/read_pack_registry.toml new file mode 100644 index 0000000..2c2feaa --- /dev/null +++ b/src-tauri/permissions/autogenerated/read_pack_registry.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-read-pack-registry" +description = "Enables the read_pack_registry command without any pre-configured scope." +commands.allow = ["read_pack_registry"] + +[[permission]] +identifier = "deny-read-pack-registry" +description = "Denies the read_pack_registry command without any pre-configured scope." +commands.deny = ["read_pack_registry"] diff --git a/src-tauri/permissions/autogenerated/read_practice_store.toml b/src-tauri/permissions/autogenerated/read_practice_store.toml new file mode 100644 index 0000000..7c2ab9f --- /dev/null +++ b/src-tauri/permissions/autogenerated/read_practice_store.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-read-practice-store" +description = "Enables the read_practice_store command without any pre-configured scope." +commands.allow = ["read_practice_store"] + +[[permission]] +identifier = "deny-read-practice-store" +description = "Denies the read_practice_store command without any pre-configured scope." +commands.deny = ["read_practice_store"] diff --git a/src-tauri/permissions/autogenerated/read_project.toml b/src-tauri/permissions/autogenerated/read_project.toml new file mode 100644 index 0000000..259f528 --- /dev/null +++ b/src-tauri/permissions/autogenerated/read_project.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-read-project" +description = "Enables the read_project command without any pre-configured scope." +commands.allow = ["read_project"] + +[[permission]] +identifier = "deny-read-project" +description = "Denies the read_project command without any pre-configured scope." +commands.deny = ["read_project"] diff --git a/src-tauri/permissions/autogenerated/reveal_archive.toml b/src-tauri/permissions/autogenerated/reveal_archive.toml new file mode 100644 index 0000000..7e8187b --- /dev/null +++ b/src-tauri/permissions/autogenerated/reveal_archive.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-reveal-archive" +description = "Enables the reveal_archive command without any pre-configured scope." +commands.allow = ["reveal_archive"] + +[[permission]] +identifier = "deny-reveal-archive" +description = "Denies the reveal_archive command without any pre-configured scope." +commands.deny = ["reveal_archive"] diff --git a/src-tauri/permissions/autogenerated/select_pack_file.toml b/src-tauri/permissions/autogenerated/select_pack_file.toml new file mode 100644 index 0000000..ba32dab --- /dev/null +++ b/src-tauri/permissions/autogenerated/select_pack_file.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-select-pack-file" +description = "Enables the select_pack_file command without any pre-configured scope." +commands.allow = ["select_pack_file"] + +[[permission]] +identifier = "deny-select-pack-file" +description = "Denies the select_pack_file command without any pre-configured scope." +commands.deny = ["select_pack_file"] diff --git a/src-tauri/permissions/autogenerated/set_wallpaper.toml b/src-tauri/permissions/autogenerated/set_wallpaper.toml new file mode 100644 index 0000000..eb4e77c --- /dev/null +++ b/src-tauri/permissions/autogenerated/set_wallpaper.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-set-wallpaper" +description = "Enables the set_wallpaper command without any pre-configured scope." +commands.allow = ["set_wallpaper"] + +[[permission]] +identifier = "deny-set-wallpaper" +description = "Denies the set_wallpaper command without any pre-configured scope." +commands.deny = ["set_wallpaper"] diff --git a/src-tauri/permissions/autogenerated/write_archive.toml b/src-tauri/permissions/autogenerated/write_archive.toml new file mode 100644 index 0000000..6702627 --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_archive.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-archive" +description = "Enables the write_archive command without any pre-configured scope." +commands.allow = ["write_archive"] + +[[permission]] +identifier = "deny-write-archive" +description = "Denies the write_archive command without any pre-configured scope." +commands.deny = ["write_archive"] diff --git a/src-tauri/permissions/autogenerated/write_archive_annotations.toml b/src-tauri/permissions/autogenerated/write_archive_annotations.toml new file mode 100644 index 0000000..539d413 --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_archive_annotations.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-archive-annotations" +description = "Enables the write_archive_annotations command without any pre-configured scope." +commands.allow = ["write_archive_annotations"] + +[[permission]] +identifier = "deny-write-archive-annotations" +description = "Denies the write_archive_annotations command without any pre-configured scope." +commands.deny = ["write_archive_annotations"] diff --git a/src-tauri/permissions/autogenerated/write_archive_batch.toml b/src-tauri/permissions/autogenerated/write_archive_batch.toml new file mode 100644 index 0000000..7cabb30 --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_archive_batch.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-archive-batch" +description = "Enables the write_archive_batch command without any pre-configured scope." +commands.allow = ["write_archive_batch"] + +[[permission]] +identifier = "deny-write-archive-batch" +description = "Denies the write_archive_batch command without any pre-configured scope." +commands.deny = ["write_archive_batch"] diff --git a/src-tauri/permissions/autogenerated/write_archive_cache.toml b/src-tauri/permissions/autogenerated/write_archive_cache.toml new file mode 100644 index 0000000..45d2e78 --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_archive_cache.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-archive-cache" +description = "Enables the write_archive_cache command without any pre-configured scope." +commands.allow = ["write_archive_cache"] + +[[permission]] +identifier = "deny-write-archive-cache" +description = "Denies the write_archive_cache command without any pre-configured scope." +commands.deny = ["write_archive_cache"] diff --git a/src-tauri/permissions/autogenerated/write_pack_content.toml b/src-tauri/permissions/autogenerated/write_pack_content.toml new file mode 100644 index 0000000..9d6b580 --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_pack_content.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-pack-content" +description = "Enables the write_pack_content command without any pre-configured scope." +commands.allow = ["write_pack_content"] + +[[permission]] +identifier = "deny-write-pack-content" +description = "Denies the write_pack_content command without any pre-configured scope." +commands.deny = ["write_pack_content"] diff --git a/src-tauri/permissions/autogenerated/write_pack_registry.toml b/src-tauri/permissions/autogenerated/write_pack_registry.toml new file mode 100644 index 0000000..05328ce --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_pack_registry.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-pack-registry" +description = "Enables the write_pack_registry command without any pre-configured scope." +commands.allow = ["write_pack_registry"] + +[[permission]] +identifier = "deny-write-pack-registry" +description = "Denies the write_pack_registry command without any pre-configured scope." +commands.deny = ["write_pack_registry"] diff --git a/src-tauri/permissions/autogenerated/write_practice_store.toml b/src-tauri/permissions/autogenerated/write_practice_store.toml new file mode 100644 index 0000000..d37c459 --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_practice_store.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-practice-store" +description = "Enables the write_practice_store command without any pre-configured scope." +commands.allow = ["write_practice_store"] + +[[permission]] +identifier = "deny-write-practice-store" +description = "Denies the write_practice_store command without any pre-configured scope." +commands.deny = ["write_practice_store"] diff --git a/src-tauri/permissions/autogenerated/write_project.toml b/src-tauri/permissions/autogenerated/write_project.toml new file mode 100644 index 0000000..9f81528 --- /dev/null +++ b/src-tauri/permissions/autogenerated/write_project.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-write-project" +description = "Enables the write_project command without any pre-configured scope." +commands.allow = ["write_project"] + +[[permission]] +identifier = "deny-write-project" +description = "Denies the write_project command without any pre-configured scope." +commands.deny = ["write_project"] diff --git a/src-tauri/src/archive.rs b/src-tauri/src/archive.rs new file mode 100644 index 0000000..c8776ab --- /dev/null +++ b/src-tauri/src/archive.rs @@ -0,0 +1,1295 @@ +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::Deserialize; +use std::collections::HashSet; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Component, Path}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use tauri::Manager; + +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); +const MAX_TEXT_ARCHIVE_BYTES: usize = 10 * 1024 * 1024; +const MAX_PNG_ARCHIVE_BYTES: usize = 25 * 1024 * 1024; +const MAX_BATCH_FILES: usize = 64; +const MAX_BATCH_BYTES: usize = 128 * 1024 * 1024; +const MAX_ARCHIVE_PATH_BYTES: usize = 520; +const MAX_ARCHIVE_DEPTH: usize = 8; +const MAX_JSON_DEPTH: usize = 64; +const MAX_ANNOTATION_BYTES: usize = 10 * 1024 * 1024; +const MAX_CACHE_BYTES: usize = 10 * 1024 * 1024; +const TEMP_PREFIX: &str = ".sortilune-tmp-"; + +#[derive(Clone, Debug, Deserialize)] +pub struct ArchiveWrite { + rel: String, + bytes: Vec, +} + +#[tauri::command] +pub fn write_archive(app: tauri::AppHandle, rel: String, bytes: Vec) -> Result { + let relative = validate_archive_path(&rel)?; + validate_archive_size(relative, bytes.len())?; + validate_archive_content(relative, &bytes)?; + let root = app + .path() + .app_data_dir() + .map_err(|e| format!("could not resolve app data dir: {e}"))?; + let destination = secure_archive_destination(&root, relative)?; + write_atomic(&destination, &bytes)?; + Ok(rel) +} + +#[tauri::command] +pub fn write_archive_batch( + app: tauri::AppHandle, + files: Vec, +) -> Result, String> { + let root = app + .path() + .app_data_dir() + .map_err(|e| format!("could not resolve app data dir: {e}"))?; + write_batch_at(&root, &files, None) +} + +#[tauri::command] +pub fn write_archive_annotations(app: tauri::AppHandle, bytes: Vec) -> Result { + if bytes.len() > MAX_ANNOTATION_BYTES { + return Err("archive annotations exceed the 10 MB limit".to_string()); + } + let text = std::str::from_utf8(&bytes) + .map_err(|_| "archive annotations must contain valid UTF-8".to_string())?; + validate_json(text)?; + let value: serde_json::Value = serde_json::from_str(text) + .map_err(|error| format!("archive annotations are invalid: {error}"))?; + validate_annotation_envelope(&value)?; + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve app data dir: {error}"))?; + let relative = Path::new("archive/_sortilune/annotations.json"); + let destination = secure_archive_destination(&root, relative)?; + write_atomic(&destination, &bytes)?; + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +#[tauri::command] +pub fn write_archive_cache( + app: tauri::AppHandle, + name: String, + bytes: Vec, +) -> Result { + if bytes.len() > MAX_CACHE_BYTES { + return Err("archive cache file exceeds the 10 MB limit".to_string()); + } + let cache_name = validate_cache_name(&name)?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| "archive cache JSON must contain valid UTF-8".to_string())?; + validate_json(text)?; + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve app data dir: {error}"))?; + let relative = Path::new("archive/_sortilune/cache").join(cache_name); + let destination = secure_archive_destination(&root, &relative)?; + write_atomic(&destination, &bytes)?; + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +#[tauri::command] +pub fn clear_archive_cache(app: tauri::AppHandle) -> Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve app data dir: {error}"))?; + clear_archive_cache_at(&root) +} + +fn validate_archive_size(path: &Path, size: usize) -> Result<(), String> { + let maximum = match path.extension().and_then(|extension| extension.to_str()) { + Some("png" | "wav") => MAX_PNG_ARCHIVE_BYTES, + _ => MAX_TEXT_ARCHIVE_BYTES, + }; + if size > maximum { + return Err(format!( + "archive file exceeds the {} MB limit", + maximum / 1024 / 1024 + )); + } + Ok(()) +} + +#[tauri::command] +pub fn reveal_archive(app: tauri::AppHandle) -> Result { + let path = app + .path() + .app_data_dir() + .map_err(|e| format!("could not resolve app data dir: {e}"))? + .join("archive"); + std::fs::create_dir_all(&path).map_err(|e| format!("could not create archive: {e}"))?; + open_directory(&path)?; + Ok(path.to_string_lossy().into_owned()) +} + +#[cfg(target_os = "windows")] +fn open_directory(path: &Path) -> Result<(), String> { + Command::new("explorer") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("could not open Explorer: {e}")) +} + +#[cfg(target_os = "macos")] +fn open_directory(path: &Path) -> Result<(), String> { + Command::new("open") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("could not open Finder: {e}")) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn open_directory(path: &Path) -> Result<(), String> { + Command::new("xdg-open") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("could not open the file manager: {e}")) +} + +fn validate_archive_path(rel: &str) -> Result<&Path, String> { + if rel.len() > MAX_ARCHIVE_PATH_BYTES { + return Err("archive path is too long".to_string()); + } + let path = Path::new(rel); + if path.is_absolute() { + return Err("archive path must be relative".to_string()); + } + let mut components = path.components(); + if components.next() != Some(Component::Normal("archive".as_ref())) { + return Err("archive path must start with archive/".to_string()); + } + let mut depth = 1; + for part in components { + if !matches!(part, Component::Normal(_)) { + return Err("archive path contains an unsafe component".to_string()); + } + validate_portable_component(part.as_os_str())?; + depth += 1; + } + if depth < 3 || path.file_name().is_none() { + return Err("archive path must name a file".to_string()); + } + if depth > MAX_ARCHIVE_DEPTH { + return Err("archive path is nested too deeply".to_string()); + } + match path.extension().and_then(|extension| extension.to_str()) { + Some("json" | "md" | "svg" | "png" | "wav") => {} + _ => return Err("archive file type is not allowed".to_string()), + } + Ok(path) +} + +fn validate_cache_name(name: &str) -> Result<&Path, String> { + if name.is_empty() || name.len() > 260 { + return Err("archive cache name has an invalid length".to_string()); + } + let path = Path::new(name); + if path.is_absolute() { + return Err("archive cache name must be relative".to_string()); + } + let components = path.components().collect::>(); + if components.is_empty() || components.len() > 2 || path.file_name().is_none() { + return Err("archive cache name is nested too deeply".to_string()); + } + for component in components { + let Component::Normal(value) = component else { + return Err("archive cache name contains an unsafe component".to_string()); + }; + validate_portable_component(value)?; + } + match path.extension().and_then(|extension| extension.to_str()) { + Some("json") => Ok(path), + _ => Err("archive cache file type is not allowed".to_string()), + } +} + +fn clear_archive_cache_at(root: &Path) -> Result { + let cache = root.join("archive/_sortilune/cache"); + let metadata = match std::fs::symlink_metadata(&cache) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(format!("could not inspect archive cache: {error}")), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("archive cache is not a safe directory".to_string()); + } + std::fs::create_dir_all(root) + .map_err(|error| format!("could not create application data directory: {error}"))?; + let canonical_root = std::fs::canonicalize(root) + .map_err(|error| format!("could not resolve application data directory: {error}"))?; + validate_cache_ancestors(root, &canonical_root)?; + let canonical_cache = std::fs::canonicalize(&cache) + .map_err(|error| format!("could not resolve archive cache: {error}"))?; + if !canonical_cache.starts_with(&canonical_root) { + return Err("archive cache resolves outside application data".to_string()); + } + let count = count_regular_files_without_links(&cache, 0)?; + std::fs::remove_dir_all(&cache) + .map_err(|error| format!("could not clear archive cache: {error}"))?; + Ok(count) +} + +fn validate_cache_ancestors(root: &Path, canonical_root: &Path) -> Result<(), String> { + for relative in ["archive", "archive/_sortilune"] { + let path = root.join(relative); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("could not inspect archive cache parent: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("archive cache parent is not a safe directory".to_string()); + } + let canonical = std::fs::canonicalize(&path) + .map_err(|error| format!("could not resolve archive cache parent: {error}"))?; + if canonical != canonical_root.join(relative) { + return Err("archive cache parent resolves through a link or junction".to_string()); + } + } + Ok(()) +} + +fn validate_annotation_envelope(value: &serde_json::Value) -> Result<(), String> { + let object = value + .as_object() + .ok_or_else(|| "archive annotations must be a JSON object".to_string())?; + let allowed = [ + "schema", + "schema_version", + "updated_at", + "records", + "collections", + ]; + if object.len() != allowed.len() || object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err("archive annotations contain missing or unsupported root fields".to_string()); + } + if object.get("schema").and_then(serde_json::Value::as_str) + != Some("sortilune.archive-annotations") + || object + .get("schema_version") + .and_then(serde_json::Value::as_u64) + != Some(1) + { + return Err("archive annotations have an unsupported schema".to_string()); + } + require_bounded_string(object.get("updated_at"), 1, 64, "annotation timestamp")?; + let records = object + .get("records") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "archive annotation records must be an object".to_string())?; + if records.len() > 25_000 { + return Err("archive annotations contain too many records".to_string()); + } + for (id, annotation) in records { + if !is_uuid(id) { + return Err("archive annotation record ID is invalid".to_string()); + } + validate_annotation(id, annotation)?; + } + let collections = object + .get("collections") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "archive annotation collections must be an object".to_string())?; + if collections.len() > 1_000 { + return Err("archive annotations contain too many collections".to_string()); + } + for (id, collection) in collections { + if !is_uuid(id) { + return Err("archive collection ID is invalid".to_string()); + } + validate_collection(id, collection)?; + } + Ok(()) +} + +fn validate_annotation(_id: &str, value: &serde_json::Value) -> Result<(), String> { + let object = value + .as_object() + .ok_or_else(|| "archive annotation must be an object".to_string())?; + let allowed = [ + "title", + "tags", + "favorite", + "hidden", + "collections", + "updated_at", + ]; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err("archive annotation contains unsupported fields".to_string()); + } + for required in ["tags", "favorite", "hidden", "collections", "updated_at"] { + if !object.contains_key(required) { + return Err(format!("archive annotation is missing {required}")); + } + } + if object.contains_key("title") { + require_bounded_string(object.get("title"), 1, 240, "annotation title")?; + } + validate_string_array(object.get("tags"), 32, 64, false, "annotation tags")?; + if object + .get("favorite") + .and_then(serde_json::Value::as_bool) + .is_none() + || object + .get("hidden") + .and_then(serde_json::Value::as_bool) + .is_none() + { + return Err("archive annotation flags must be booleans".to_string()); + } + validate_string_array( + object.get("collections"), + 32, + 36, + true, + "annotation collection IDs", + )?; + require_bounded_string(object.get("updated_at"), 1, 64, "annotation timestamp")?; + Ok(()) +} + +fn validate_collection(id: &str, value: &serde_json::Value) -> Result<(), String> { + let object = value + .as_object() + .ok_or_else(|| "archive collection must be an object".to_string())?; + let allowed = ["id", "name", "created_at", "updated_at"]; + if object.len() != allowed.len() || object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err("archive collection contains missing or unsupported fields".to_string()); + } + if object.get("id").and_then(serde_json::Value::as_str) != Some(id) { + return Err("archive collection key and ID do not match".to_string()); + } + require_bounded_string(object.get("name"), 1, 120, "collection name")?; + require_bounded_string( + object.get("created_at"), + 1, + 64, + "collection creation timestamp", + )?; + require_bounded_string( + object.get("updated_at"), + 1, + 64, + "collection update timestamp", + )?; + Ok(()) +} + +fn validate_string_array( + value: Option<&serde_json::Value>, + maximum_items: usize, + maximum_length: usize, + require_uuid: bool, + label: &str, +) -> Result<(), String> { + let values = value + .and_then(serde_json::Value::as_array) + .ok_or_else(|| format!("{label} must be an array"))?; + if values.len() > maximum_items { + return Err(format!("{label} contain too many values")); + } + let mut unique = HashSet::new(); + for value in values { + let text = value + .as_str() + .ok_or_else(|| format!("{label} must contain strings"))?; + if text.is_empty() || text.chars().count() > maximum_length { + return Err(format!("{label} contain an invalid string length")); + } + if require_uuid && !is_uuid(text) { + return Err(format!("{label} contain an invalid ID")); + } + if !unique.insert(text) { + return Err(format!("{label} must be unique")); + } + } + Ok(()) +} + +fn require_bounded_string( + value: Option<&serde_json::Value>, + minimum: usize, + maximum: usize, + label: &str, +) -> Result<(), String> { + let text = value + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("{label} must be a string"))?; + let length = text.chars().count(); + if length < minimum || length > maximum { + return Err(format!("{label} has an invalid length")); + } + Ok(()) +} + +fn is_uuid(value: &str) -> bool { + value.len() == 36 + && value.bytes().enumerate().all(|(index, byte)| { + if [8, 13, 18, 23].contains(&index) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +fn count_regular_files_without_links(directory: &Path, depth: usize) -> Result { + if depth > 2 { + return Err("archive cache is nested too deeply".to_string()); + } + let mut count = 0; + for entry in std::fs::read_dir(directory) + .map_err(|error| format!("could not inspect archive cache: {error}"))? + { + let entry = entry.map_err(|error| format!("could not inspect archive cache: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("could not inspect archive cache entry: {error}"))?; + if file_type.is_symlink() { + return Err("archive cache contains a symbolic link or junction".to_string()); + } + if file_type.is_dir() { + count += count_regular_files_without_links(&entry.path(), depth + 1)?; + } else if file_type.is_file() { + count += 1; + } else { + return Err("archive cache contains an unsupported entry".to_string()); + } + } + Ok(count) +} + +fn validate_portable_component(component: &std::ffi::OsStr) -> Result<(), String> { + let value = component + .to_str() + .ok_or_else(|| "archive path component is not valid UTF-8".to_string())?; + if value.chars().count() > 128 { + return Err("archive path component is too long".to_string()); + } + if value.ends_with(['.', ' ']) + || value.chars().any(|character| { + character.is_control() || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*') + }) + { + return Err("archive path contains a non-portable component".to_string()); + } + let stem = value + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let is_device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || stem + .strip_prefix("COM") + .or_else(|| stem.strip_prefix("LPT")) + .is_some_and(|suffix| { + matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + }); + if is_device { + return Err("archive path contains a reserved device name".to_string()); + } + Ok(()) +} + +fn validate_archive_content(path: &Path, bytes: &[u8]) -> Result<(), String> { + match path.extension().and_then(|extension| extension.to_str()) { + Some("json") => { + let text = std::str::from_utf8(bytes) + .map_err(|_| "JSON archive file must contain valid UTF-8".to_string())?; + validate_json(text) + } + Some("md" | "svg") => std::str::from_utf8(bytes) + .map(|_| ()) + .map_err(|_| "text archive file must contain valid UTF-8".to_string()), + Some("png" | "wav") => Ok(()), + _ => Err("archive file type is not allowed".to_string()), + } +} + +fn validate_json(text: &str) -> Result<(), String> { + let mut deserializer = serde_json::Deserializer::from_str(text); + CheckedJsonSeed { depth: 0 } + .deserialize(&mut deserializer) + .map_err(|error| format!("invalid JSON archive file: {error}"))?; + deserializer + .end() + .map_err(|error| format!("invalid JSON archive file: {error}")) +} + +struct CheckedJson; +struct CheckedJsonSeed { + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for CheckedJsonSeed { + type Value = CheckedJson; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + if self.depth > MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON nesting exceeds 64 levels")); + } + deserializer.deserialize_any(CheckedJsonVisitor { depth: self.depth }) + } +} + +struct CheckedJsonVisitor { + depth: usize, +} + +impl<'de> Visitor<'de> for CheckedJsonVisitor { + type Value = CheckedJson; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_bool(self, _value: bool) -> Result { + Ok(CheckedJson) + } + fn visit_i64(self, _value: i64) -> Result { + Ok(CheckedJson) + } + fn visit_u64(self, _value: u64) -> Result { + Ok(CheckedJson) + } + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + if value.is_finite() { + Ok(CheckedJson) + } else { + Err(E::custom("JSON number must be finite")) + } + } + fn visit_str(self, _value: &str) -> Result { + Ok(CheckedJson) + } + fn visit_string(self, _value: String) -> Result { + Ok(CheckedJson) + } + fn visit_none(self) -> Result { + Ok(CheckedJson) + } + fn visit_unit(self) -> Result { + Ok(CheckedJson) + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + while sequence + .next_element_seed(CheckedJsonSeed { + depth: self.depth + 1, + })? + .is_some() + {} + Ok(CheckedJson) + } + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = HashSet::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key.clone()) { + return Err(de::Error::custom(format!("duplicate JSON key: {key}"))); + } + map.next_value_seed(CheckedJsonSeed { + depth: self.depth + 1, + })?; + } + Ok(CheckedJson) + } +} + +fn write_batch_at( + root: &Path, + files: &[ArchiveWrite], + fail_after_commits: Option, +) -> Result, String> { + if files.is_empty() || files.len() > MAX_BATCH_FILES { + return Err(format!( + "archive batch must contain between 1 and {MAX_BATCH_FILES} files" + )); + } + validate_batch_total(files.iter().map(|file| file.bytes.len()))?; + + let mut validated = Vec::with_capacity(files.len()); + let mut seen = HashSet::new(); + for file in files { + let relative = validate_archive_path(&file.rel)?; + validate_archive_size(relative, file.bytes.len())?; + validate_archive_content(relative, &file.bytes)?; + if !seen.insert(file.rel.to_lowercase()) { + return Err("archive batch contains a duplicate destination".to_string()); + } + validated.push(relative.to_path_buf()); + } + + // Do every side-effect-free validation before creating archive directories. + // Invalid batches therefore cannot leave even empty directory remnants. + let mut destinations = Vec::with_capacity(files.len()); + for (file, relative) in files.iter().zip(&validated) { + let destination = secure_archive_destination(root, relative)?; + if destination.exists() { + return Err(format!("archive destination already exists: {}", file.rel)); + } + destinations.push(destination); + } + + let mut staged = Vec::with_capacity(files.len()); + for (file, destination) in files.iter().zip(&destinations) { + let stage_result = (|| -> Result { + let parent = destination + .parent() + .ok_or_else(|| "archive destination has no parent directory".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("could not create archive directory: {error}"))?; + let filename = destination + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "archive filename is not valid UTF-8".to_string())?; + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!( + "{TEMP_PREFIX}{}-{sequence}-{filename}", + std::process::id() + )); + let mut handle = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|error| format!("could not create staged archive file: {error}"))?; + if let Err(error) = handle + .write_all(&file.bytes) + .and_then(|_| handle.sync_all()) + { + let _ = std::fs::remove_file(&temporary); + return Err(format!("could not stage archive file: {error}")); + } + Ok(temporary) + })(); + match stage_result { + Ok(path) => staged.push(path), + Err(error) => { + cleanup_paths(&staged); + return Err(error); + } + } + } + + let mut committed = Vec::with_capacity(files.len()); + for (index, (temporary, destination)) in staged.iter().zip(&destinations).enumerate() { + let result = if fail_after_commits == Some(index) { + Err("injected archive batch interruption".to_string()) + } else { + commit_new_file(temporary, destination) + }; + if let Err(error) = result { + cleanup_paths(&committed); + cleanup_paths(&staged[index..]); + return Err(format!("archive batch was rolled back: {error}")); + } + committed.push(destination.clone()); + } + + if let Err(error) = sync_parent_directories(&destinations) { + cleanup_paths(&committed); + return Err(format!("archive batch was rolled back: {error}")); + } + Ok(files.iter().map(|file| file.rel.clone()).collect()) +} + +fn validate_batch_total(sizes: impl IntoIterator) -> Result<(), String> { + let total = sizes.into_iter().try_fold(0usize, |sum, size| { + sum.checked_add(size) + .ok_or_else(|| "archive batch byte size overflowed".to_string()) + })?; + if total > MAX_BATCH_BYTES { + return Err(format!( + "archive batch exceeds the {MAX_BATCH_BYTES} byte total limit" + )); + } + Ok(()) +} + +fn secure_archive_destination(root: &Path, relative: &Path) -> Result { + std::fs::create_dir_all(root) + .map_err(|error| format!("could not create application data directory: {error}"))?; + let canonical_root = std::fs::canonicalize(root) + .map_err(|error| format!("could not resolve application data directory: {error}"))?; + let components = relative.components().collect::>(); + let mut parent = root.to_path_buf(); + for component in components.iter().take(components.len().saturating_sub(1)) { + let Component::Normal(name) = component else { + return Err("archive path contains an unsafe component".to_string()); + }; + parent.push(name); + match std::fs::symlink_metadata(&parent) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err("archive path traverses a symbolic link or junction".to_string()) + } + Ok(metadata) if !metadata.is_dir() => { + return Err("archive path parent is not a directory".to_string()) + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir(&parent) + .map_err(|error| format!("could not create archive directory: {error}"))?; + let metadata = std::fs::symlink_metadata(&parent) + .map_err(|error| format!("could not inspect archive directory: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("archive directory was replaced by an unsafe entry".to_string()); + } + } + Err(error) => return Err(format!("could not inspect archive directory: {error}")), + } + } + let canonical_parent = std::fs::canonicalize(&parent) + .map_err(|error| format!("could not resolve archive directory: {error}"))?; + if !canonical_parent.starts_with(&canonical_root) { + return Err("archive path resolves outside application data".to_string()); + } + let filename = relative + .file_name() + .ok_or_else(|| "archive path must name a file".to_string())?; + let destination = parent.join(filename); + match std::fs::symlink_metadata(&destination) { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err("archive destination cannot be a symbolic link".to_string()) + } + Ok(metadata) if !metadata.is_file() => { + Err("archive destination is not a regular file".to_string()) + } + Ok(_) => Ok(destination), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(destination), + Err(error) => Err(format!("could not inspect archive destination: {error}")), + } +} + +fn cleanup_paths(paths: &[std::path::PathBuf]) { + for path in paths { + let _ = std::fs::remove_file(path); + } +} + +#[cfg(windows)] +fn commit_new_file(from: &Path, to: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_WRITE_THROUGH}; + + let from_wide: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); + let to_wide: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); + let result = + unsafe { MoveFileExW(from_wide.as_ptr(), to_wide.as_ptr(), MOVEFILE_WRITE_THROUGH) }; + if result == 0 { + Err(format!( + "could not commit archive file: {}", + std::io::Error::last_os_error() + )) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +fn commit_new_file(from: &Path, to: &Path) -> Result<(), String> { + std::fs::rename(from, to).map_err(|error| format!("could not commit archive file: {error}")) +} + +#[cfg(unix)] +fn sync_parent_directories(destinations: &[std::path::PathBuf]) -> Result<(), String> { + let mut synced = HashSet::new(); + for parent in destinations.iter().filter_map(|path| path.parent()) { + if synced.insert(parent.to_path_buf()) { + std::fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("could not sync archive directory: {error}"))?; + } + } + Ok(()) +} + +#[cfg(not(unix))] +fn sync_parent_directories(_destinations: &[std::path::PathBuf]) -> Result<(), String> { + Ok(()) +} + +pub fn cleanup_abandoned_temps(app: &tauri::AppHandle) -> Result { + let archive = app + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve app data dir: {error}"))? + .join("archive"); + cleanup_temps_at(&archive, 0) +} + +fn cleanup_temps_at(directory: &Path, depth: usize) -> Result { + if depth > MAX_ARCHIVE_DEPTH { + return Ok(0); + } + match std::fs::symlink_metadata(directory) { + Ok(metadata) if metadata.file_type().is_symlink() => return Ok(0), + Ok(metadata) if !metadata.is_dir() => return Ok(0), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(format!("could not inspect archive temp root: {error}")), + } + let mut removed = 0; + for entry in std::fs::read_dir(directory) + .map_err(|error| format!("could not inspect archive temp files: {error}"))? + { + let entry = + entry.map_err(|error| format!("could not inspect archive temp entry: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("could not inspect archive temp type: {error}"))?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + removed += cleanup_temps_at(&entry.path(), depth + 1)?; + } else if file_type.is_file() + && entry.file_name().to_string_lossy().starts_with(TEMP_PREFIX) + { + std::fs::remove_file(entry.path()) + .map_err(|error| format!("could not remove abandoned archive temp: {error}"))?; + removed += 1; + } + } + Ok(removed) +} + +pub(crate) fn write_atomic(destination: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = destination + .parent() + .ok_or_else(|| "archive destination has no parent directory".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("could not create archive directory: {e}"))?; + let filename = destination + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "archive filename is not valid UTF-8".to_string())?; + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!( + "{TEMP_PREFIX}{}-{sequence}-{filename}", + std::process::id() + )); + + let write_result = (|| -> Result<(), String> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|e| format!("could not create temporary archive file: {e}"))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|e| format!("could not write temporary archive file: {e}"))?; + replace_file(&temporary, destination) + })(); + + if write_result.is_err() { + let _ = std::fs::remove_file(&temporary); + } + write_result +} + +#[cfg(windows)] +fn replace_file(from: &Path, to: &Path) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let from_wide: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); + let to_wide: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); + let result = unsafe { + MoveFileExW( + from_wide.as_ptr(), + to_wide.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + Err(format!( + "could not replace archive file: {}", + std::io::Error::last_os_error() + )) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +fn replace_file(from: &Path, to: &Path) -> Result<(), String> { + std::fs::rename(from, to).map_err(|e| format!("could not replace archive file: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_paths_are_confined() { + assert!(validate_archive_path("archive/diary/entry.md").is_ok()); + assert!(validate_archive_path( + "archive/today/certificates/sha512-5501e3d72bc42f3b96e16de4dcadcb16768e109662bd16d667d5fd9aee585af31.json" + ) + .is_ok()); + assert!(validate_archive_path("archive").is_err()); + assert!(validate_archive_path("archive/entry.json").is_err()); + assert!(validate_archive_path("archive/../outside.txt").is_err()); + assert!(validate_archive_path("outside/file.txt").is_err()); + assert!(validate_archive_path("C:\\outside.txt").is_err()); + assert!(validate_archive_path("archive/a/b/c/d/e/f/g/file.json").is_err()); + assert!(validate_archive_path("archive/diary/file.exe").is_err()); + assert!(validate_archive_path("archive/oracle/file:stream.json").is_err()); + assert!(validate_archive_path("archive/oracle/CON.json").is_err()); + assert!(validate_archive_path("archive/oracle/trailing./entry.json").is_err()); + assert!(validate_archive_path(&format!("archive/diary/{}.json", "a".repeat(500))).is_err()); + } + + #[test] + fn cache_names_are_confined_and_allowlisted() { + assert!(validate_cache_name("archive-index-v1.json").is_ok()); + assert!(validate_cache_name("segments/fixture.json").is_ok()); + assert!(validate_cache_name("thumbnails/fixture.svg").is_err()); + assert!(validate_cache_name("../annotations.json").is_err()); + assert!(validate_cache_name("nested/too/deep.json").is_err()); + assert!(validate_cache_name("C:\\outside.json").is_err()); + assert!(validate_cache_name("payload.html").is_err()); + assert!(validate_cache_name("CON.json").is_err()); + } + + #[test] + fn cache_clear_removes_only_the_disposable_subtree() { + let root = test_root("cache-clear"); + let source = root.join("archive/oracle/source.json"); + let annotations = root.join("archive/_sortilune/annotations.json"); + let cache = root.join("archive/_sortilune/cache/thumbnails"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(&source, b"{}").unwrap(); + std::fs::write(&annotations, b"{}").unwrap(); + std::fs::write(cache.join("one.json"), b"{}").unwrap(); + std::fs::write(cache.join("two.json"), b"{}").unwrap(); + assert_eq!(clear_archive_cache_at(&root).unwrap(), 2); + assert!(source.exists()); + assert!(annotations.exists()); + assert!(!root.join("archive/_sortilune/cache").exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(windows)] + #[test] + fn cache_clear_refuses_links() { + use std::os::windows::fs::symlink_file; + + let root = test_root("cache-clear-link"); + let cache = root.join("archive/_sortilune/cache"); + let outside = test_root("cache-clear-outside").join("outside.svg"); + std::fs::create_dir_all(&cache).unwrap(); + std::fs::write(&outside, b"outside").unwrap(); + let link = cache.join("linked.svg"); + if symlink_file(&outside, &link).is_ok() { + assert!(clear_archive_cache_at(&root) + .unwrap_err() + .contains("symbolic link")); + assert!(outside.exists()); + } + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); + } + + #[cfg(windows)] + #[test] + fn cache_clear_refuses_a_linked_parent_even_when_it_points_inside_root() { + use std::os::windows::fs::symlink_dir; + + let root = test_root("cache-clear-parent-link"); + let redirected = root.join("redirected"); + let archive_link = root.join("archive"); + std::fs::create_dir_all(redirected.join("_sortilune/cache")).unwrap(); + std::fs::write(redirected.join("_sortilune/cache/keep.json"), b"{}").unwrap(); + if symlink_dir(&redirected, &archive_link).is_ok() { + assert!(clear_archive_cache_at(&root).is_err()); + assert!(redirected.join("_sortilune/cache/keep.json").exists()); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn annotation_envelope_rejects_unknown_fields_and_malformed_entries() { + let valid = serde_json::json!({ + "schema": "sortilune.archive-annotations", + "schema_version": 1, + "updated_at": "2026-07-13T12:00:00.000Z", + "records": { + "11111111-1111-4111-8111-111111111111": { + "tags": ["moon"], + "favorite": true, + "hidden": false, + "collections": [], + "updated_at": "2026-07-13T12:00:00.000Z" + } + }, + "collections": {} + }); + assert!(validate_annotation_envelope(&valid).is_ok()); + + let mut unknown = valid.clone(); + unknown + .as_object_mut() + .unwrap() + .insert("unknown".into(), true.into()); + assert!(validate_annotation_envelope(&unknown).is_err()); + + let mut malformed = valid; + malformed["records"]["11111111-1111-4111-8111-111111111111"]["favorite"] = "yes".into(); + assert!(validate_annotation_envelope(&malformed).is_err()); + } + + #[test] + fn archive_sizes_are_bounded_by_type() { + assert!( + validate_archive_size(Path::new("archive/diary/entry.md"), MAX_TEXT_ARCHIVE_BYTES) + .is_ok() + ); + assert!(validate_archive_size( + Path::new("archive/diary/entry.md"), + MAX_TEXT_ARCHIVE_BYTES + 1 + ) + .is_err()); + assert!(validate_archive_size( + Path::new("archive/canvas/export.png"), + MAX_PNG_ARCHIVE_BYTES + ) + .is_ok()); + assert!(validate_archive_size( + Path::new("archive/canvas/export.png"), + MAX_PNG_ARCHIVE_BYTES + 1 + ) + .is_err()); + } + + #[test] + fn atomic_write_replaces_existing_file() { + let dir = std::env::temp_dir().join(format!( + "sortilune-archive-test-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + let destination = dir.join("entry.txt"); + write_atomic(&destination, b"first").expect("first write should succeed"); + write_atomic(&destination, b"second").expect("replacement should succeed"); + assert_eq!(std::fs::read(&destination).unwrap(), b"second"); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn text_and_json_content_validation_is_strict() { + assert!(validate_archive_content(Path::new("archive/diary/entry.md"), &[0xff]).is_err()); + assert!(validate_archive_content( + Path::new("archive/oracle/entry.json"), + br#"{"first":1,"first":2}"# + ) + .unwrap_err() + .contains("duplicate JSON key")); + assert!( + validate_archive_content(Path::new("archive/oracle/entry.json"), b"{broken").is_err() + ); + assert!(validate_archive_content(Path::new("archive/canvas/art.svg"), b"").is_ok()); + assert!(validate_archive_content(Path::new("archive/canvas/art.png"), &[0xff]).is_ok()); + } + + #[test] + fn json_depth_is_bounded() { + let at_limit = format!( + "{}0{}", + "[".repeat(MAX_JSON_DEPTH), + "]".repeat(MAX_JSON_DEPTH) + ); + let too_deep = format!( + "{}0{}", + "[".repeat(MAX_JSON_DEPTH + 2), + "]".repeat(MAX_JSON_DEPTH + 2) + ); + assert!(validate_json(&at_limit).is_ok()); + assert!(validate_json(&too_deep).is_err()); + } + + #[test] + fn transactional_batch_commits_every_file() { + let root = test_root("batch-commit"); + let files = vec![ + archive_write("archive/canvas/work.svg", b""), + archive_write("archive/canvas/work.json", br#"{"schema_version":1}"#), + ]; + let paths = write_batch_at(&root, &files, None).expect("batch should commit"); + assert_eq!(paths.len(), 2); + assert_eq!(std::fs::read(root.join(&paths[0])).unwrap(), b""); + assert_eq!( + std::fs::read(root.join(&paths[1])).unwrap(), + br#"{"schema_version":1}"# + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn transactional_batch_rolls_back_an_interruption() { + let root = test_root("batch-interruption"); + let files = vec![ + archive_write("archive/canvas/work.svg", b""), + archive_write("archive/canvas/work.json", br#"{"schema_version":1}"#), + ]; + assert!(write_batch_at(&root, &files, Some(1)) + .unwrap_err() + .contains("rolled back")); + assert!(!root.join("archive/canvas/work.svg").exists()); + assert!(!root.join("archive/canvas/work.json").exists()); + let leftovers = std::fs::read_dir(root.join("archive/canvas")) + .unwrap() + .collect::, _>>() + .unwrap(); + assert!(leftovers.is_empty()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn batch_rejects_duplicate_destinations_before_writing() { + let root = test_root("batch-duplicate"); + let files = vec![ + archive_write("archive/oracle/Draw.json", b"{}"), + archive_write("archive/oracle/draw.json", b"{}"), + ]; + assert!(write_batch_at(&root, &files, None) + .unwrap_err() + .contains("duplicate destination")); + assert!(!root.join("archive").exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn transactional_batch_has_a_total_size_limit() { + assert!(validate_batch_total([MAX_BATCH_BYTES]).is_ok()); + assert!(validate_batch_total([MAX_BATCH_BYTES, 1]) + .unwrap_err() + .contains("total limit")); + assert!(validate_batch_total([usize::MAX, 1]) + .unwrap_err() + .contains("overflowed")); + } + + #[test] + fn archive_destination_rejects_a_regular_file_as_parent() { + let root = test_root("parent-file"); + std::fs::create_dir(root.join("archive")).unwrap(); + std::fs::write(root.join("archive/oracle"), b"not a directory").unwrap(); + let error = + secure_archive_destination(&root, Path::new("archive/oracle/entry.json")).unwrap_err(); + assert!(error.contains("parent is not a directory")); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(windows)] + #[test] + fn archive_destination_rejects_a_directory_symlink() { + use std::os::windows::fs::symlink_dir; + + let root = test_root("parent-symlink"); + let outside = test_root("outside-symlink"); + std::fs::create_dir(root.join("archive")).unwrap(); + let link = root.join("archive/oracle"); + if symlink_dir(&outside, &link).is_ok() { + let error = secure_archive_destination(&root, Path::new("archive/oracle/entry.json")) + .unwrap_err(); + assert!(error.contains("symbolic link or junction")); + } + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); + } + + #[cfg(windows)] + #[test] + fn archive_destination_rejects_a_file_symlink() { + use std::os::windows::fs::symlink_file; + + let root = test_root("destination-symlink"); + let outside = test_root("outside-file-symlink").join("outside.json"); + std::fs::create_dir_all(root.join("archive/oracle")).unwrap(); + std::fs::write(&outside, b"{}").unwrap(); + let link = root.join("archive/oracle/entry.json"); + if symlink_file(&outside, &link).is_ok() { + let error = secure_archive_destination(&root, Path::new("archive/oracle/entry.json")) + .unwrap_err(); + assert!(error.contains("destination cannot be a symbolic link")); + } + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); + } + + #[test] + fn startup_cleanup_removes_only_owned_temps() { + let root = test_root("temp-cleanup"); + let archive = root.join("archive/oracle"); + std::fs::create_dir_all(&archive).unwrap(); + std::fs::write( + archive.join(format!("{TEMP_PREFIX}123-1-record.json")), + b"partial", + ) + .unwrap(); + std::fs::write(archive.join(".foreign.tmp"), b"keep").unwrap(); + std::fs::write(archive.join("record.json"), b"{}").unwrap(); + assert_eq!(cleanup_temps_at(&root.join("archive"), 0).unwrap(), 1); + assert!(archive.join(".foreign.tmp").exists()); + assert!(archive.join("record.json").exists()); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(windows)] + #[test] + fn startup_cleanup_never_follows_a_directory_symlink() { + use std::os::windows::fs::symlink_dir; + + let root = test_root("cleanup-symlink"); + let outside = test_root("cleanup-outside"); + let owned_name = format!("{TEMP_PREFIX}123-1-record.json"); + std::fs::write(outside.join(&owned_name), b"outside").unwrap(); + let archive = root.join("archive"); + if symlink_dir(&outside, &archive).is_ok() { + assert_eq!(cleanup_temps_at(&archive, 0).unwrap(), 0); + assert!(outside.join(&owned_name).exists()); + } + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(outside).unwrap(); + } + + fn archive_write(rel: &str, bytes: &[u8]) -> ArchiveWrite { + ArchiveWrite { + rel: rel.to_string(), + bytes: bytes.to_vec(), + } + } + + fn test_root(label: &str) -> std::path::PathBuf { + let root = std::env::temp_dir().join(format!( + "sortilune-archive-{label}-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + root + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bed5b7b..8796a2b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,49 @@ +mod archive; +mod packs; +mod practices; +mod projects; mod wallpaper; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let builder = tauri::Builder::default() .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_fs::init()) - .invoke_handler(tauri::generate_handler![wallpaper::set_wallpaper]) + .plugin(tauri_plugin_dialog::init()); + #[cfg(feature = "wdio")] + let builder = builder + .plugin(tauri_plugin_wdio::init()) + .plugin(tauri_plugin_wdio_webdriver::init()); + builder + .setup(|app| { + archive::cleanup_abandoned_temps(app.handle()).map_err(std::io::Error::other)?; + packs::cleanup_abandoned_temps(app.handle()).map_err(std::io::Error::other)?; + projects::cleanup_abandoned_temps(app.handle()).map_err(std::io::Error::other)?; + practices::cleanup_abandoned_temps(app.handle()).map_err(std::io::Error::other)?; + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + archive::reveal_archive, + archive::clear_archive_cache, + archive::write_archive_annotations, + archive::write_archive_cache, + archive::write_archive, + archive::write_archive_batch, + packs::select_pack_file, + packs::read_pack_registry, + packs::write_pack_registry, + packs::read_pack_content, + packs::write_pack_content, + packs::delete_pack_content, + packs::export_pack_file, + projects::list_projects, + projects::read_project, + projects::write_project, + projects::delete_project, + practices::read_practice_store, + practices::write_practice_store, + wallpaper::set_wallpaper + ]) .run(tauri::generate_context!()) .expect("error while running Sortilune"); } diff --git a/src-tauri/src/packs.rs b/src-tauri/src/packs.rs new file mode 100644 index 0000000..e5d73f1 --- /dev/null +++ b/src-tauri/src/packs.rs @@ -0,0 +1,610 @@ +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde::Serialize; +use std::collections::HashSet; +use std::fs::OpenOptions; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tauri::Manager; +use tauri_plugin_dialog::DialogExt; + +const MAX_PACK_BYTES: usize = 5 * 1024 * 1024; +const MAX_REGISTRY_BYTES: usize = 10 * 1024 * 1024; +const MAX_JSON_DEPTH: usize = 32; +const PACK_SUFFIX: &str = ".sortilune-pack.json"; +const TEMP_PREFIX: &str = ".sortilune-tmp-"; + +#[derive(Debug, Serialize)] +pub struct PackCommandError { + code: &'static str, + path: String, + message: String, +} + +impl PackCommandError { + fn new(code: &'static str, path: impl Into, message: impl Into) -> Self { + Self { + code, + path: path.into(), + message: message.into(), + } + } +} + +#[derive(Debug, Serialize)] +pub struct SelectedPackFile { + source_name: String, + json_text: String, + bytes: usize, +} + +#[tauri::command] +pub async fn select_pack_file( + app: tauri::AppHandle, +) -> Result, PackCommandError> { + let selected = app + .dialog() + .file() + .add_filter("Sortilune pack", &["json"]) + .blocking_pick_file(); + let Some(selected) = selected else { + return Ok(None); + }; + let path = selected.into_path().map_err(|error| { + PackCommandError::new( + "file.path", + "$", + format!("Could not use selected file: {error}"), + ) + })?; + inspect_pack_path(&path).map(Some) +} + +#[tauri::command] +pub fn read_pack_registry(app: tauri::AppHandle) -> Result, PackCommandError> { + read_bounded_optional(&pack_root(&app)?.join("registry.json"), MAX_REGISTRY_BYTES) +} + +#[tauri::command] +pub fn write_pack_registry( + app: tauri::AppHandle, + json_text: String, +) -> Result { + if json_text.len() > MAX_REGISTRY_BYTES { + return Err(PackCommandError::new( + "registry.size", + "$", + "Pack registry exceeds the 10 MiB limit.", + )); + } + validate_checked_json(&json_text).map_err(|message| json_error("registry.json", message))?; + validate_registry_envelope(&json_text)?; + let destination = pack_root(&app)?.join("registry.json"); + crate::archive::write_atomic(&destination, json_text.as_bytes()) + .map_err(|message| PackCommandError::new("registry.write", "$", message))?; + Ok("packs/registry.json".to_string()) +} + +#[tauri::command] +pub fn read_pack_content( + app: tauri::AppHandle, + storage_key: String, +) -> Result, PackCommandError> { + let key = validate_storage_key(&storage_key)?; + read_bounded_optional( + &pack_root(&app)?.join("content").join(format!("{key}.json")), + MAX_PACK_BYTES, + ) +} + +#[tauri::command] +pub fn write_pack_content( + app: tauri::AppHandle, + storage_key: String, + json_text: String, +) -> Result { + let key = validate_storage_key(&storage_key)?; + if json_text.len() > MAX_PACK_BYTES { + return Err(PackCommandError::new( + "content.size", + "$", + "Pack content exceeds the 5 MiB limit.", + )); + } + validate_checked_json(&json_text).map_err(|message| json_error("content", message))?; + let destination = pack_root(&app)?.join("content").join(format!("{key}.json")); + if let Some(existing) = read_bounded_optional(&destination, MAX_PACK_BYTES)? { + if existing == json_text { + return Ok(format!("packs/content/{key}.json")); + } + return Err(PackCommandError::new( + "content.conflict", + "$", + "This pack identity already has different content.", + )); + } + crate::archive::write_atomic(&destination, json_text.as_bytes()) + .map_err(|message| PackCommandError::new("content.write", "$", message))?; + Ok(format!("packs/content/{key}.json")) +} + +#[tauri::command] +pub fn delete_pack_content( + app: tauri::AppHandle, + storage_key: String, +) -> Result { + let key = validate_storage_key(&storage_key)?; + let destination = pack_root(&app)?.join("content").join(format!("{key}.json")); + match std::fs::remove_file(destination) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(PackCommandError::new( + "content.delete", + "$", + format!("Could not remove pack content: {error}"), + )), + } +} + +#[tauri::command] +pub async fn export_pack_file( + app: tauri::AppHandle, + suggested_name: String, + json_text: String, +) -> Result, PackCommandError> { + if json_text.len() > MAX_PACK_BYTES { + return Err(PackCommandError::new( + "export.size", + "$", + "Pack export exceeds the 5 MiB limit.", + )); + } + validate_checked_json(&json_text).map_err(|message| json_error("export", message))?; + let safe_name = safe_suggested_name(&suggested_name); + let selected = app + .dialog() + .file() + .add_filter("Sortilune pack", &["json"]) + .set_file_name(format!("{safe_name}{PACK_SUFFIX}")) + .blocking_save_file(); + let Some(selected) = selected else { + return Ok(None); + }; + let mut path = selected.into_path().map_err(|error| { + PackCommandError::new( + "export.path", + "$", + format!("Could not use save path: {error}"), + ) + })?; + if !path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|name| name.to_ascii_lowercase().ends_with(PACK_SUFFIX)) + { + let current = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(&safe_name); + path.set_file_name(format!("{current}{PACK_SUFFIX}")); + } + crate::archive::write_atomic(&path, format!("{json_text}\n").as_bytes()) + .map_err(|message| PackCommandError::new("export.write", "$", message))?; + Ok(Some(path.to_string_lossy().into_owned())) +} + +fn inspect_pack_path(path: &Path) -> Result { + let source_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + PackCommandError::new("file.name", "$", "Selected filename is not valid Unicode.") + })? + .to_string(); + if !source_name.to_ascii_lowercase().ends_with(PACK_SUFFIX) { + return Err(PackCommandError::new( + "file.extension", + "$", + format!("Choose a file ending in {PACK_SUFFIX}."), + )); + } + let file = OpenOptions::new().read(true).open(path).map_err(|error| { + PackCommandError::new( + "file.read", + "$", + format!("Could not open selected pack: {error}"), + ) + })?; + let metadata = file.metadata().map_err(|error| { + PackCommandError::new( + "file.metadata", + "$", + format!("Could not inspect selected pack: {error}"), + ) + })?; + if !metadata.is_file() { + return Err(PackCommandError::new( + "file.type", + "$", + "Selected pack must be a regular file.", + )); + } + if metadata.len() > MAX_PACK_BYTES as u64 { + return Err(PackCommandError::new( + "file.size", + "$", + "Selected pack exceeds the 5 MiB limit.", + )); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_PACK_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| { + PackCommandError::new( + "file.read", + "$", + format!("Could not read selected pack: {error}"), + ) + })?; + if bytes.len() > MAX_PACK_BYTES { + return Err(PackCommandError::new( + "file.size", + "$", + "Selected pack exceeds the 5 MiB limit.", + )); + } + let text = String::from_utf8(bytes).map_err(|_| { + PackCommandError::new("file.utf8", "$", "Selected pack is not valid UTF-8 text.") + })?; + validate_checked_json(&text).map_err(|message| json_error(&source_name, message))?; + Ok(SelectedPackFile { + source_name, + bytes: text.len(), + json_text: text, + }) +} + +fn pack_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|path| path.join("packs")) + .map_err(|error| { + PackCommandError::new( + "storage.root", + "$", + format!("Could not resolve pack storage: {error}"), + ) + }) +} + +fn read_bounded_optional(path: &Path, maximum: usize) -> Result, PackCommandError> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(PackCommandError::new( + "storage.read", + "$", + format!("Could not read pack storage: {error}"), + )) + } + }; + if bytes.len() > maximum { + return Err(PackCommandError::new( + "storage.size", + "$", + "Stored pack data is unexpectedly large.", + )); + } + String::from_utf8(bytes).map(Some).map_err(|_| { + PackCommandError::new("storage.utf8", "$", "Stored pack data is not valid UTF-8.") + }) +} + +fn validate_storage_key(value: &str) -> Result<&str, PackCommandError> { + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(value) + } else { + Err(PackCommandError::new( + "storage.key", + "$", + "Pack storage key is invalid.", + )) + } +} + +fn validate_registry_envelope(text: &str) -> Result<(), PackCommandError> { + let value: serde_json::Value = serde_json::from_str(text).map_err(|error| { + PackCommandError::new( + "registry.json", + "$", + format!("Pack registry is invalid: {error}"), + ) + })?; + let object = value.as_object().ok_or_else(|| { + PackCommandError::new("registry.shape", "$", "Pack registry must be an object.") + })?; + if object.get("schema").and_then(serde_json::Value::as_str) != Some("sortilune.pack-registry") + || object + .get("schema_version") + .and_then(serde_json::Value::as_u64) + != Some(1) + || !object + .get("updated_at") + .is_some_and(serde_json::Value::is_string) + { + return Err(PackCommandError::new( + "registry.shape", + "$", + "Pack registry has an unsupported format.", + )); + } + let entries = object + .get("entries") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + PackCommandError::new( + "registry.shape", + "/entries", + "Pack registry entries must be an array.", + ) + })?; + if entries.len() > 1000 { + return Err(PackCommandError::new( + "registry.entries", + "/entries", + "Pack registry contains too many entries.", + )); + } + Ok(()) +} + +fn safe_suggested_name(value: &str) -> String { + let safe = value + .chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .take(80) + .collect::(); + if safe.is_empty() { + "sortilune-starter".to_string() + } else { + safe + } +} + +fn json_error(path: &str, message: String) -> PackCommandError { + let code = if message.contains("duplicate JSON key") { + "json.duplicate-key" + } else if message.contains("nesting exceeds") { + "json.depth" + } else { + "json.syntax" + }; + PackCommandError::new(code, path, message) +} + +struct CheckedJson; +struct CheckedJsonSeed { + depth: usize, +} + +fn validate_checked_json(text: &str) -> Result<(), String> { + let mut deserializer = serde_json::Deserializer::from_str(text); + CheckedJsonSeed { depth: 0 } + .deserialize(&mut deserializer) + .map_err(|error| format!("Pack JSON is invalid: {error}"))?; + deserializer + .end() + .map_err(|error| format!("Pack JSON is invalid: {error}")) +} + +impl<'de> DeserializeSeed<'de> for CheckedJsonSeed { + type Value = CheckedJson; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + if self.depth > MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON nesting exceeds 32 levels")); + } + deserializer.deserialize_any(CheckedJsonVisitor { depth: self.depth }) + } +} + +struct CheckedJsonVisitor { + depth: usize, +} + +impl<'de> Visitor<'de> for CheckedJsonVisitor { + type Value = CheckedJson; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_bool(self, _value: bool) -> Result { + Ok(CheckedJson) + } + fn visit_i64(self, _value: i64) -> Result { + Ok(CheckedJson) + } + fn visit_u64(self, _value: u64) -> Result { + Ok(CheckedJson) + } + fn visit_f64(self, value: f64) -> Result + where + E: de::Error, + { + if value.is_finite() { + Ok(CheckedJson) + } else { + Err(E::custom("JSON number must be finite")) + } + } + fn visit_str(self, _value: &str) -> Result { + Ok(CheckedJson) + } + fn visit_string(self, _value: String) -> Result { + Ok(CheckedJson) + } + fn visit_none(self) -> Result { + Ok(CheckedJson) + } + fn visit_unit(self) -> Result { + Ok(CheckedJson) + } + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + while sequence + .next_element_seed(CheckedJsonSeed { + depth: self.depth + 1, + })? + .is_some() + {} + Ok(CheckedJson) + } + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut keys = HashSet::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key.clone()) { + return Err(de::Error::custom(format!("duplicate JSON key: {key}"))); + } + map.next_value_seed(CheckedJsonSeed { + depth: self.depth + 1, + })?; + } + Ok(CheckedJson) + } +} + +pub fn cleanup_abandoned_temps(app: &tauri::AppHandle) -> Result { + let root = app + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve app data dir: {error}"))? + .join("packs"); + cleanup_temps_at(&root, 0) +} + +fn cleanup_temps_at(directory: &Path, depth: usize) -> Result { + if depth > 2 { + return Ok(0); + } + let entries = match std::fs::read_dir(directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(format!("could not inspect pack temp files: {error}")), + }; + let mut removed = 0; + for entry in entries { + let entry = entry.map_err(|error| format!("could not inspect pack temp entry: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("could not inspect pack temp type: {error}"))?; + if file_type.is_dir() { + removed += cleanup_temps_at(&entry.path(), depth + 1)?; + } else if file_type.is_file() + && entry.file_name().to_string_lossy().starts_with(TEMP_PREFIX) + { + std::fs::remove_file(entry.path()) + .map_err(|error| format!("could not remove abandoned pack temp: {error}"))?; + removed += 1; + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn test_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!("sortilune-pack-{label}-{}", std::process::id())) + } + + #[test] + fn checked_json_rejects_duplicate_keys_and_depth() { + assert!(validate_checked_json(r#"{"a":1,"a":2}"#) + .unwrap_err() + .contains("duplicate JSON key")); + let accepted = format!( + "{}0{}", + "[".repeat(MAX_JSON_DEPTH), + "]".repeat(MAX_JSON_DEPTH) + ); + let rejected = format!( + "{}0{}", + "[".repeat(MAX_JSON_DEPTH + 2), + "]".repeat(MAX_JSON_DEPTH + 2) + ); + assert!(validate_checked_json(&accepted).is_ok()); + assert!(validate_checked_json(&rejected) + .unwrap_err() + .contains("nesting exceeds")); + } + + #[test] + fn selected_file_checks_suffix_size_utf8_and_json() { + let root = test_root("inspect"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let valid = root.join("valid.sortilune-pack.json"); + std::fs::write(&valid, br#"{"schema":"sortilune.pack"}"#).unwrap(); + assert_eq!(inspect_pack_path(&valid).unwrap().bytes, 27); + let wrong = root.join("valid.json"); + std::fs::write(&wrong, b"{}").unwrap(); + assert_eq!( + inspect_pack_path(&wrong).unwrap_err().code, + "file.extension" + ); + let malformed = root.join("bad.sortilune-pack.json"); + std::fs::write(&malformed, [0xff, 0xfe]).unwrap(); + assert_eq!(inspect_pack_path(&malformed).unwrap_err().code, "file.utf8"); + let duplicate = root.join("duplicate.sortilune-pack.json"); + std::fs::write(&duplicate, br#"{"a":1,"a":2}"#).unwrap(); + assert_eq!( + inspect_pack_path(&duplicate).unwrap_err().code, + "json.duplicate-key" + ); + let oversized = root.join("large.sortilune-pack.json"); + let mut handle = std::fs::File::create(&oversized).unwrap(); + handle.set_len(MAX_PACK_BYTES as u64 + 1).unwrap(); + handle.flush().unwrap(); + assert_eq!(inspect_pack_path(&oversized).unwrap_err().code, "file.size"); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn storage_keys_and_registry_envelopes_are_bounded() { + assert!(validate_storage_key(&"a".repeat(64)).is_ok()); + assert!(validate_storage_key("../outside").is_err()); + assert!(validate_registry_envelope(r#"{"schema":"sortilune.pack-registry","schema_version":1,"updated_at":"2026-07-13T00:00:00Z","entries":[]}"#).is_ok()); + assert!(validate_registry_envelope( + r#"{"schema":"wrong","schema_version":1,"updated_at":"x","entries":[]}"# + ) + .is_err()); + } + + #[test] + fn temp_cleanup_removes_only_pack_temps() { + let root = test_root("cleanup"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("content")).unwrap(); + std::fs::write(root.join(".sortilune-tmp-one"), b"temp").unwrap(); + std::fs::write(root.join("content/.sortilune-tmp-two"), b"temp").unwrap(); + std::fs::write(root.join("content/keep.json"), b"{}").unwrap(); + assert_eq!(cleanup_temps_at(&root, 0).unwrap(), 2); + assert!(root.join("content/keep.json").exists()); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/practices.rs b/src-tauri/src/practices.rs new file mode 100644 index 0000000..f5a57a8 --- /dev/null +++ b/src-tauri/src/practices.rs @@ -0,0 +1,121 @@ +use std::path::{Path, PathBuf}; +use tauri::Manager; + +const MAX_BYTES: usize = 1024 * 1024; +const TEMP_PREFIX: &str = ".sortilune-tmp-"; + +#[tauri::command] +pub fn read_practice_store(app: tauri::AppHandle) -> Result, String> { + read_at(&store_path(&app)?) +} + +#[tauri::command] +pub fn write_practice_store(app: tauri::AppHandle, json_text: String) -> Result { + let path = store_path(&app)?; + write_at(&path, &json_text)?; + Ok("practices/plans.json".to_string()) +} + +fn store_path(app: &tauri::AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve app data dir: {error}"))? + .join("practices") + .join("plans.json")) +} + +fn read_at(path: &Path) -> Result, String> { + let metadata = match std::fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("could not inspect practice store: {error}")), + }; + if !metadata.is_file() || metadata.len() > MAX_BYTES as u64 { + return Err("practice store is not a regular bounded file".to_string()); + } + let text = std::fs::read_to_string(path) + .map_err(|error| format!("could not read practice store as UTF-8 JSON: {error}"))?; + validate(&text)?; + Ok(Some(text)) +} + +fn write_at(path: &Path, json_text: &str) -> Result<(), String> { + if json_text.len() > MAX_BYTES { + return Err("practice store exceeds the 1 MiB limit".to_string()); + } + validate(json_text)?; + crate::archive::write_atomic(path, json_text.as_bytes()) +} + +fn validate(json_text: &str) -> Result<(), String> { + let value: serde_json::Value = serde_json::from_str(json_text) + .map_err(|error| format!("practice store JSON is invalid: {error}"))?; + let object = value + .as_object() + .ok_or_else(|| "practice store must be an object".to_string())?; + if object.get("schema").and_then(serde_json::Value::as_str) != Some("sortilune.practice-store") + || object + .get("schema_version") + .and_then(serde_json::Value::as_u64) + != Some(1) + || !object.get("plans").is_some_and(serde_json::Value::is_array) + { + return Err("practice store must use sortilune.practice-store v1".to_string()); + } + Ok(()) +} + +pub fn cleanup_abandoned_temps(app: &tauri::AppHandle) -> Result { + let path = store_path(app)?; + cleanup_at( + path.parent() + .ok_or_else(|| "practice directory is missing".to_string())?, + ) +} + +fn cleanup_at(root: &Path) -> Result { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(format!("could not inspect practice temp files: {error}")), + }; + let mut removed = 0; + for entry in entries { + let entry = + entry.map_err(|error| format!("could not inspect practice temp entry: {error}"))?; + if entry + .file_type() + .map_err(|error| error.to_string())? + .is_file() + && entry.file_name().to_string_lossy().starts_with(TEMP_PREFIX) + { + std::fs::remove_file(entry.path()).map_err(|error| error.to_string())?; + removed += 1; + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir() -> PathBuf { + let path = std::env::temp_dir().join(format!("sortilune-practices-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn round_trip_and_invalid_envelope() { + let root = temp_dir(); + let path = root.join("plans.json"); + let json = r#"{"schema":"sortilune.practice-store","schema_version":1,"updated_at":"2026-07-13T00:00:00Z","plans":[]}"#; + write_at(&path, json).unwrap(); + assert_eq!(read_at(&path).unwrap().as_deref(), Some(json)); + assert!(write_at(&path, "{}").is_err()); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/projects.rs b/src-tauri/src/projects.rs new file mode 100644 index 0000000..ce0bd28 --- /dev/null +++ b/src-tauri/src/projects.rs @@ -0,0 +1,252 @@ +use std::path::{Path, PathBuf}; +use tauri::Manager; + +const MAX_PROJECT_BYTES: usize = 1024 * 1024; +const TEMP_PREFIX: &str = ".sortilune-tmp-"; + +#[tauri::command] +pub fn list_projects(app: tauri::AppHandle) -> Result, String> { + list_projects_at(&project_root(&app)?) +} + +#[tauri::command] +pub fn read_project(app: tauri::AppHandle, project_id: String) -> Result, String> { + let id = validate_project_id(&project_id)?; + read_project_at(&project_root(&app)?, id) +} + +#[tauri::command] +pub fn write_project( + app: tauri::AppHandle, + project_id: String, + json_text: String, +) -> Result { + let id = validate_project_id(&project_id)?; + write_project_at(&project_root(&app)?, id, &json_text) +} + +#[tauri::command] +pub fn delete_project(app: tauri::AppHandle, project_id: String) -> Result { + let id = validate_project_id(&project_id)?; + delete_project_at(&project_root(&app)?, id) +} + +fn project_root(app: &tauri::AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|error| format!("could not resolve app data dir: {error}"))? + .join("projects")) +} + +fn list_projects_at(root: &Path) -> Result, String> { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("could not list projects: {error}")), + }; + let mut ids = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| format!("could not read project entry: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("could not inspect project entry: {error}"))?; + if !file_type.is_file() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let Some(id) = name.strip_suffix(".json") else { + continue; + }; + if validate_project_id(id).is_ok() { + ids.push(id.to_string()); + } + } + ids.sort(); + Ok(ids) +} + +fn read_project_at(root: &Path, project_id: &str) -> Result, String> { + let path = project_path(root, project_id); + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("could not inspect project: {error}")), + }; + if !metadata.is_file() { + return Err("project path is not a regular file".to_string()); + } + if metadata.len() > MAX_PROJECT_BYTES as u64 { + return Err("project file exceeds the 1 MiB limit".to_string()); + } + let text = std::fs::read_to_string(path) + .map_err(|error| format!("could not read project as UTF-8 JSON: {error}"))?; + validate_project_envelope(&text, project_id)?; + Ok(Some(text)) +} + +fn write_project_at(root: &Path, project_id: &str, json_text: &str) -> Result { + if json_text.len() > MAX_PROJECT_BYTES { + return Err("project file exceeds the 1 MiB limit".to_string()); + } + validate_project_envelope(json_text, project_id)?; + let destination = project_path(root, project_id); + crate::archive::write_atomic(&destination, json_text.as_bytes())?; + Ok(format!("projects/{project_id}.json")) +} + +fn delete_project_at(root: &Path, project_id: &str) -> Result { + match std::fs::remove_file(project_path(root, project_id)) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("could not delete project: {error}")), + } +} + +fn validate_project_envelope(json_text: &str, project_id: &str) -> Result<(), String> { + let value: serde_json::Value = serde_json::from_str(json_text) + .map_err(|error| format!("project JSON is invalid: {error}"))?; + let object = value + .as_object() + .ok_or_else(|| "project JSON must be an object".to_string())?; + if object.get("schema").and_then(serde_json::Value::as_str) != Some("sortilune.project") + || object + .get("schema_version") + .and_then(serde_json::Value::as_u64) + != Some(1) + { + return Err("project JSON must use sortilune.project v1".to_string()); + } + if object.get("id").and_then(serde_json::Value::as_str) != Some(project_id) { + return Err("project document ID must match its filename".to_string()); + } + Ok(()) +} + +fn project_path(root: &Path, project_id: &str) -> PathBuf { + root.join(format!("{project_id}.json")) +} + +fn validate_project_id(value: &str) -> Result<&str, String> { + let bytes = value.as_bytes(); + if bytes.len() != 36 + || bytes[8] != b'-' + || bytes[13] != b'-' + || bytes[18] != b'-' + || bytes[23] != b'-' + || !bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 8 | 13 | 18 | 23) || byte.is_ascii_hexdigit()) + || !matches!(bytes[14].to_ascii_lowercase(), b'1'..=b'8') + || !matches!(bytes[19].to_ascii_lowercase(), b'8' | b'9' | b'a' | b'b') + { + return Err("project ID must be a valid UUID".to_string()); + } + Ok(value) +} + +pub fn cleanup_abandoned_temps(app: &tauri::AppHandle) -> Result { + cleanup_temps_at(&project_root(app)?) +} + +fn cleanup_temps_at(root: &Path) -> Result { + let entries = match std::fs::read_dir(root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(format!("could not inspect project temp files: {error}")), + }; + let mut removed = 0; + for entry in entries { + let entry = + entry.map_err(|error| format!("could not inspect project temp entry: {error}"))?; + if entry + .file_type() + .map_err(|error| format!("could not inspect project temp type: {error}"))? + .is_file() + && entry.file_name().to_string_lossy().starts_with(TEMP_PREFIX) + { + std::fs::remove_file(entry.path()) + .map_err(|error| format!("could not remove abandoned project temp: {error}"))?; + removed += 1; + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TEST_SEQUENCE: AtomicUsize = AtomicUsize::new(0); + const ID: &str = "11111111-1111-4111-8111-111111111111"; + + fn test_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "sortilune-project-{label}-{}-{}", + std::process::id(), + TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )) + } + + fn project_json(id: &str, name: &str) -> String { + serde_json::json!({ + "schema": "sortilune.project", + "schema_version": 1, + "id": id, + "name": name + }) + .to_string() + } + + #[test] + fn ids_and_envelopes_are_checked() { + assert!(validate_project_id(ID).is_ok()); + assert!(validate_project_id("../outside").is_err()); + assert!(validate_project_id("11111111-1111-0111-8111-111111111111").is_err()); + assert!(validate_project_envelope(&project_json(ID, "Moon"), ID).is_ok()); + assert!(validate_project_envelope( + &project_json(ID, "Moon"), + "22222222-2222-4222-8222-222222222222" + ) + .is_err()); + assert!(validate_project_envelope("[]", ID).is_err()); + } + + #[test] + fn write_read_list_replace_and_delete_round_trip() { + let root = test_root("roundtrip"); + let _ = std::fs::remove_dir_all(&root); + write_project_at(&root, ID, &project_json(ID, "First")).unwrap(); + assert_eq!(list_projects_at(&root).unwrap(), vec![ID]); + assert!(read_project_at(&root, ID) + .unwrap() + .unwrap() + .contains("First")); + write_project_at(&root, ID, &project_json(ID, "Second")).unwrap(); + assert!(read_project_at(&root, ID) + .unwrap() + .unwrap() + .contains("Second")); + assert!(delete_project_at(&root, ID).unwrap()); + assert!(!delete_project_at(&root, ID).unwrap()); + assert!(list_projects_at(&root).unwrap().is_empty()); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn list_ignores_unrelated_files_and_cleanup_removes_only_temps() { + let root = test_root("cleanup"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("notes.txt"), b"keep").unwrap(); + std::fs::write(root.join("not-a-project.json"), b"{}").unwrap(); + std::fs::write(root.join(".sortilune-tmp-stale"), b"temp").unwrap(); + assert!(list_projects_at(&root).unwrap().is_empty()); + assert_eq!(cleanup_temps_at(&root).unwrap(), 1); + assert!(root.join("notes.txt").exists()); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/wallpaper.rs b/src-tauri/src/wallpaper.rs index 999eca3..785705f 100644 --- a/src-tauri/src/wallpaper.rs +++ b/src-tauri/src/wallpaper.rs @@ -1,21 +1,22 @@ -use tauri::Manager; use ::wallpaper as wp; +use tauri::Manager; + +const MAX_WALLPAPER_BYTES: usize = 25 * 1024 * 1024; +const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; +const MAX_WALLPAPER_DIMENSION: u32 = 16_384; /// Write the provided PNG bytes to the app data dir and set the desktop wallpaper. /// Cross-platform via the `wallpaper` crate. #[tauri::command] pub fn set_wallpaper(app: tauri::AppHandle, bytes: Vec) -> Result { + validate_wallpaper(&bytes)?; let dir = app .path() .app_data_dir() .map_err(|e| format!("could not resolve app data dir: {e}"))?; let wp_dir = dir.join("wallpaper"); std::fs::create_dir_all(&wp_dir).map_err(|e| format!("mkdir failed: {e}"))?; - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - let path = wp_dir.join(format!("sortilune-{}.png", stamp)); + let path = wp_dir.join("sortilune-current.png"); std::fs::write(&path, &bytes).map_err(|e| format!("write failed: {e}"))?; let path_str = path .to_str() @@ -24,3 +25,66 @@ pub fn set_wallpaper(app: tauri::AppHandle, bytes: Vec) -> Result Result<(), String> { + if bytes.len() > MAX_WALLPAPER_BYTES { + return Err("wallpaper PNG exceeds the 25 MB limit".to_string()); + } + if !bytes.starts_with(PNG_SIGNATURE) { + return Err("wallpaper payload is not a PNG file".to_string()); + } + if bytes.len() < 33 || &bytes[12..16] != b"IHDR" { + return Err("wallpaper PNG is missing its IHDR header".to_string()); + } + let width = u32::from_be_bytes(bytes[16..20].try_into().expect("four-byte width")); + let height = u32::from_be_bytes(bytes[20..24].try_into().expect("four-byte height")); + if width == 0 + || height == 0 + || width > MAX_WALLPAPER_DIMENSION + || height > MAX_WALLPAPER_DIMENSION + { + return Err("wallpaper PNG dimensions are invalid or too large".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_png_header() { + assert!(validate_wallpaper(&png_header(1920, 1080)).is_ok()); + } + + #[test] + fn rejects_non_png_payload() { + assert!(validate_wallpaper(b"not a png").is_err()); + } + + #[test] + fn rejects_oversized_payload() { + let mut bytes = vec![0; MAX_WALLPAPER_BYTES + 1]; + bytes[..PNG_SIGNATURE.len()].copy_from_slice(PNG_SIGNATURE); + assert!(validate_wallpaper(&bytes).is_err()); + } + + #[test] + fn rejects_missing_header_and_unsafe_dimensions() { + assert!(validate_wallpaper(PNG_SIGNATURE).is_err()); + assert!(validate_wallpaper(&png_header(0, 1080)).is_err()); + assert!(validate_wallpaper(&png_header(MAX_WALLPAPER_DIMENSION + 1, 1080)).is_err()); + } + + fn png_header(width: u32, height: u32) -> Vec { + let mut bytes = vec![0; 33]; + bytes[..PNG_SIGNATURE.len()].copy_from_slice(PNG_SIGNATURE); + bytes[8..12].copy_from_slice(&13u32.to_be_bytes()); + bytes[12..16].copy_from_slice(b"IHDR"); + bytes[16..20].copy_from_slice(&width.to_be_bytes()); + bytes[20..24].copy_from_slice(&height.to_be_bytes()); + bytes[24] = 8; + bytes[25] = 6; + bytes + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index adbb955..d58bdb9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Sortilune", - "version": "0.1.0", + "version": "0.2.0", "identifier": "com.sortilune.desktop", "build": { "frontendDist": "../dist", @@ -27,32 +27,21 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self' customprotocol: asset:; connect-src ipc: http://ipc.localhost https://ipc.localhost; img-src 'self' asset: http://asset.localhost blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'", "capabilities": ["default"] } }, "bundle": { - "active": true, - "targets": ["nsis"], + "active": false, "icon": [ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", - "icons/icon.icns", "icons/icon.ico" ], "category": "Utility", "shortDescription": "Moon-cast lots: divination by physical randomness.", - "longDescription": "A desktop application for consulting, deciding, journaling, and creating with physical randomness drawn from atmospheric noise, quantum vacuum fluctuations, atomic clocks, public-beacon timestamps, planetary weather, and seismic activity. Every result is traceable to a real-world source.", - "publisher": "Sortilune", - "windows": { - "webviewInstallMode": { - "type": "downloadBootstrapper" - }, - "nsis": { - "installMode": "perMachine", - "displayLanguageSelector": false - } - } + "longDescription": "A desktop application for consulting, deciding, journaling, and creating with physical randomness drawn from atmospheric noise, quantum vacuum fluctuations, public-beacon pulses, planetary weather, seismic activity, or clearly labeled on-device randomness. Every result records its source provenance.", + "publisher": "Sortilune" } } diff --git a/src-tauri/tauri.wdio.conf.json b/src-tauri/tauri.wdio.conf.json new file mode 100644 index 0000000..f2fcac7 --- /dev/null +++ b/src-tauri/tauri.wdio.conf.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "com.sortilune.desktop.wdio", + "build": { + "beforeBuildCommand": "npm run build:wdio" + }, + "app": { + "withGlobalTauri": true, + "security": { + "capabilities": [ + "default", + { + "identifier": "wdio", + "description": "Test-only permissions for the embedded WebDriver build.", + "windows": ["main"], + "permissions": [ + "wdio:allow-execute", + "wdio:allow-get-window-states", + "wdio-webdriver:default" + ] + } + ] + } + }, + "bundle": { + "active": false + } +} diff --git a/src/app/navigation.ts b/src/app/navigation.ts new file mode 100644 index 0000000..853e899 --- /dev/null +++ b/src/app/navigation.ts @@ -0,0 +1,95 @@ +import { CHAMBER_BY_ID, BRAND_ICON } from '../chambers/manifest.js'; + +export interface NavigationItem { + id: string; + label: string; + description: string; + icon: string; + keywords: string[]; +} + +export interface NavigationGroup { + id: string; + label: string; + items: NavigationItem[]; +} + +const stroke = 'fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"'; +const todayIcon = ``; +const projectIcon = ``; +const practiceIcon = ``; +const archiveIcon = ``; +const journalIcon = ``; + +function chamber(id: keyof typeof CHAMBER_BY_ID, keywords: string[] = []): NavigationItem { + const value = CHAMBER_BY_ID[id]; + if (!value) throw new Error(`Unknown chamber navigation destination: ${String(id)}`); + return { + id: value.id, + label: value.displayName.replace(/^The\s+/u, ''), + description: value.tagline, + icon: value.icon, + keywords: [value.id, value.displayName, value.tagline, ...keywords], + }; +} + +export const NAVIGATION_GROUPS: NavigationGroup[] = [ + { + id: 'home', + label: 'Home', + items: [{ + id: 'today', + label: 'Today', + description: 'Your daily constellation.', + icon: todayIcon, + keywords: ['home', 'daily', 'constellation', 'ritual'], + }], + }, + { id: 'consult', label: 'Consult', items: [chamber('oracle', ['cards', 'draw']), chamber('constraint', ['prompt', 'discipline'])] }, + { id: 'decide', label: 'Decide', items: [chamber('decider', ['choice', 'options']), chamber('lottery', ['coin', 'dice', 'picker'])] }, + { id: 'create', label: 'Create', items: [chamber('diary', ['journal', 'write']), chamber('canvas', ['art', 'wallpaper'])] }, + { id: 'observe', label: 'Observe', items: [chamber('symphony', ['audio', 'planet', 'live'])] }, + { id: 'verify', label: 'Verify', items: [chamber('beacon', ['checksum', 'nist', 'pulse'])] }, + { + id: 'workspace', + label: 'Workspace', + items: [{ + id: 'projects', + label: 'Projects', + description: 'Multi-step creative workspaces.', + icon: projectIcon, + keywords: ['workflow', 'workspace', 'creative session'], + }, { + id: 'practices', + label: 'Practices', + description: 'Gentle optional creative prompts.', + icon: practiceIcon, + keywords: ['practice', 'prompt', 'creative', 'observation', 'break'], + }], + }, + { + id: 'archive', + label: 'Memory', + items: [{ + id: 'archive', + label: 'Archive', + description: 'Browse saved records.', + icon: archiveIcon, + keywords: ['history', 'saved', 'records', 'memory'], + }, { + id: 'journal', + label: 'Journal Export', + description: 'Preview and print selected memories.', + icon: journalIcon, + keywords: ['export', 'print', 'pdf', 'html', 'journal'], + }], + }, +]; + +export const NAVIGATION_ITEMS = NAVIGATION_GROUPS.flatMap((group) => group.items); +export const NAVIGATION_BY_ID = new Map(NAVIGATION_ITEMS.map((item) => [item.id, item])); +export const APP_MARK = BRAND_ICON; + +export function groupForDestination(destination: string): NavigationGroup | undefined { + return NAVIGATION_GROUPS.find((group) => group.items.some((item) => item.id === destination)); +} diff --git a/src/archive/annotations.ts b/src/archive/annotations.ts new file mode 100644 index 0000000..19c4285 --- /dev/null +++ b/src/archive/annotations.ts @@ -0,0 +1,217 @@ +import type { + ArchiveAnnotation, + ArchiveAnnotationStore, + ArchiveCollection, +} from '../domain/archive-annotations.js'; +import { createStableId, nowRfc3339 } from '../domain/identifiers.js'; +import * as storage from '../lib/fs.js'; +import { validateArchiveAnnotationStore } from '../schemas/validate.js'; + +export const ANNOTATION_PATH = 'archive/_sortilune/annotations.json'; + +interface AnnotationTransport { + readText(rel: string): Promise; + writeArchiveAnnotations(bytes: Uint8Array): Promise; +} + +export type ArchiveAnnotationPatch = Partial>; + +export class ArchiveAnnotationRepository { + readonly #transport: AnnotationTransport; + #writeTail: Promise = Promise.resolve(); + + constructor(transport: AnnotationTransport = storage) { + this.#transport = transport; + } + + async load(): Promise { + const text = await this.#transport.readText(ANNOTATION_PATH); + if (text == null) return emptyAnnotationStore(); + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new Error(`Archive annotations are not valid JSON: ${message(error)}`); + } + if (!validateArchiveAnnotationStore(value)) { + throw new Error(`Archive annotations failed validation: ${formatErrors(validateArchiveAnnotationStore.errors)}`); + } + return cloneStore(value as ArchiveAnnotationStore); + } + + async save(store: ArchiveAnnotationStore): Promise { + return this.#enqueue(() => this.#saveDirect(store)); + } + + async #saveDirect(store: ArchiveAnnotationStore): Promise { + const normalized = normalizeStore(store); + if (!validateArchiveAnnotationStore(normalized)) { + throw new TypeError(`Archive annotations failed validation: ${formatErrors(validateArchiveAnnotationStore.errors)}`); + } + const bytes = new TextEncoder().encode(`${JSON.stringify(normalized, null, 2)}\n`); + await this.#transport.writeArchiveAnnotations(bytes); + return cloneStore(normalized); + } + + async update(recordId: string, patch: ArchiveAnnotationPatch): Promise { + return this.#enqueue(async () => { + const store = await this.load(); + applyAnnotationPatch(store, recordId, patch); + return this.#saveDirect(store); + }); + } + + async updateWithNewCollection( + recordId: string, + patch: ArchiveAnnotationPatch, + name: string, + ): Promise<{ store: ArchiveAnnotationStore; collection: ArchiveCollection }> { + return this.#enqueue(async () => { + const store = await this.load(); + const collection = newCollection(name); + store.collections[collection.id] = collection; + const currentCollections = Object.hasOwn(patch, 'collections') + ? patch.collections + : store.records[recordId]?.collections; + applyAnnotationPatch(store, recordId, { + ...patch, + collections: [...cleanIds(currentCollections, 31), collection.id], + }); + return { store: await this.#saveDirect(store), collection }; + }); + } + + async createCollection(name: string): Promise<{ store: ArchiveAnnotationStore; collection: ArchiveCollection }> { + return this.#enqueue(async () => { + const store = await this.load(); + const collection = newCollection(name); + store.collections[collection.id] = collection; + return { store: await this.#saveDirect(store), collection }; + }); + } + + #enqueue(task: () => Promise): Promise { + const result = this.#writeTail.then(task, task); + this.#writeTail = result.catch(() => undefined); + return result; + } +} + +function applyAnnotationPatch( + store: ArchiveAnnotationStore, + recordId: string, + patch: ArchiveAnnotationPatch, +): void { + const before = store.records[recordId] ?? emptyAnnotation(); + const next: ArchiveAnnotation = { + ...before, + ...(Object.hasOwn(patch, 'tags') ? { tags: cleanLabels(patch.tags, 32, 64) } : {}), + ...(Object.hasOwn(patch, 'favorite') ? { favorite: Boolean(patch.favorite) } : {}), + ...(Object.hasOwn(patch, 'hidden') ? { hidden: Boolean(patch.hidden) } : {}), + ...(Object.hasOwn(patch, 'collections') ? { collections: cleanIds(patch.collections, 32) } : {}), + updated_at: nowRfc3339(), + }; + if (Object.hasOwn(patch, 'title')) { + const title = cleanOptionalText(patch.title, 240); + if (title) next.title = title; + else delete next.title; + } + store.records[recordId] = next; +} + +function newCollection(name: string): ArchiveCollection { + const timestamp = nowRfc3339(); + return { + id: createStableId(), + name: cleanRequiredText(name, 120, 'collection name'), + created_at: timestamp, + updated_at: timestamp, + }; +} + +export function emptyAnnotationStore(timestamp = nowRfc3339()): ArchiveAnnotationStore { + return { + schema: 'sortilune.archive-annotations', + schema_version: 1, + updated_at: timestamp, + records: {}, + collections: {}, + }; +} + +export function emptyAnnotation(timestamp = nowRfc3339()): ArchiveAnnotation { + return { tags: [], favorite: false, hidden: false, collections: [], updated_at: timestamp }; +} + +function normalizeStore(store: ArchiveAnnotationStore): ArchiveAnnotationStore { + const updatedAt = nowRfc3339(); + const records = Object.fromEntries(Object.entries(store.records).map(([id, annotation]) => { + const value: ArchiveAnnotation = { + tags: cleanLabels(annotation.tags, 32, 64), + favorite: Boolean(annotation.favorite), + hidden: Boolean(annotation.hidden), + collections: cleanIds(annotation.collections, 32), + updated_at: annotation.updated_at, + }; + const title = cleanOptionalText(annotation.title, 240); + if (title) value.title = title; + return [id, value]; + })); + const collections = Object.fromEntries(Object.entries(store.collections).map(([id, collection]) => [id, { + ...collection, + id, + name: cleanRequiredText(collection.name, 120, 'collection name'), + }])); + return { + schema: 'sortilune.archive-annotations', + schema_version: 1, + updated_at: updatedAt, + records, + collections, + }; +} + +function cleanLabels(values: unknown, maximum: number, length: number): string[] { + if (!Array.isArray(values)) return []; + return [...new Set(values + .map((value) => String(value).normalize('NFC').trim().slice(0, length)) + .filter(Boolean))] + .slice(0, maximum); +} + +function cleanIds(values: unknown, maximum: number): string[] { + if (!Array.isArray(values)) return []; + return [...new Set(values.filter((value): value is string => typeof value === 'string'))].slice(0, maximum); +} + +function cleanOptionalText(value: unknown, maximum: number): string | undefined { + if (typeof value !== 'string') return undefined; + return value.normalize('NFC').trim().slice(0, maximum) || undefined; +} + +function cleanRequiredText(value: unknown, maximum: number, label: string): string { + const result = cleanOptionalText(value, maximum); + if (!result) throw new TypeError(`${label} is required`); + return result; +} + +function cloneStore(store: ArchiveAnnotationStore): ArchiveAnnotationStore { + return structuredClone(store); +} + +function formatErrors(errors: unknown): string { + if (!Array.isArray(errors)) return 'unknown schema error'; + return errors.slice(0, 4).map((error) => { + const item = error as { instancePath?: string; keyword?: string }; + return `${item.instancePath || '/'} ${item.keyword || 'invalid'}`; + }).join('; '); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const archiveAnnotationRepository = new ArchiveAnnotationRepository(); diff --git a/src/archive/browser.js b/src/archive/browser.js index 083bcfa..2274cec 100644 --- a/src/archive/browser.js +++ b/src/archive/browser.js @@ -1,189 +1,997 @@ -/** - * Unified Archive browser. Walks every chamber subfolder under archive/, - * reads the JSON / Markdown front matter, builds a single chronological - * timeline, and lets the user filter. - */ +/** Archive v2: indexed, annotatable, relationship-aware local history. */ import { h, clear } from '../lib/dom.js'; -import { listDir, readJSON, readText, archiveRoot, isAvailable } from '../lib/fs.js'; -import { humanDate, shortHash } from '../lib/format.js'; +import { humanDate } from '../lib/format.js'; +import { go } from '../lib/nav.js'; +import { toast } from '../lib/notify.js'; +import { createReceipt, downloadReceipt } from '../receipts/receipt.ts'; +import { getState } from '../lib/state.js'; +import { Dialog, EmptyState, Skeleton } from '../ui/primitives.js'; +import { ARCHIVE_CHAMBERS } from './repository.js'; +import { archiveThumbnail } from './thumbnail.js'; +import { archiveWorkspace } from './workspace.js'; -const CHAMBER_DIRS = ['oracle', 'decider', 'diary', 'constraint', 'canvas', 'symphony', 'beacon', 'lottery']; +export const id = 'archive'; + +const VIEWS = ['timeline', 'calendar', 'gallery', 'collections']; +const PAGE_SIZE = 100; let _rootEl = null; -let _items = []; +let _context = null; +let _unsubscribe = null; +let _routeRecordId = ''; +let _routeRecordPath = ''; +let _view = 'timeline'; +let _search = ''; let _filterChamber = 'all'; -let _loading = false; -let _selectedItem = null; +let _filterSource = 'all'; +let _filterType = 'all'; +let _filterPack = 'all'; +let _filterProject = 'all'; +let _filterCollection = 'all'; +let _favoriteOnly = false; +let _errorsOnly = false; +let _includeHidden = false; +let _dateFrom = ''; +let _dateTo = ''; +let _selectedPath = null; +let _selectedPaths = new Set(); +let _page = 0; +let _calendarMonth = localIsoDate().slice(0, 7); +let _calendarSelectedDay = ''; +const _assetState = new Map(); -export async function mount(rootEl) { +export async function mount(rootEl, context, route) { _rootEl = rootEl; - await load(); + _context = context; + _routeRecordId = route?.params?.record_id || ''; + _routeRecordPath = route?.params?.record_path || ''; + if (route?.params?.project_id) _filterProject = route.params.project_id; + archiveWorkspace.setSearchDiaryBody(getState().searchDiaryBody); + _unsubscribe = archiveWorkspace.subscribe(() => render()); + render(); + await archiveWorkspace.load(); + selectRouteRecord(); render(); } export function unmount() { + _unsubscribe?.(); + _unsubscribe = null; + archiveWorkspace.stop(); _rootEl = null; - _items = []; + _context = null; + _selectedPath = null; + _selectedPaths = new Set(); + _assetState.clear(); } -async function load() { - _loading = true; - render(); - _items = []; - if (!(await isAvailable())) { - _loading = false; - render(); - return; - } - const root = await archiveRoot(); - for (const dir of CHAMBER_DIRS) { - try { - const entries = await listDir(`${root}/${dir}`); - for (const e of entries) { - const fullPath = `${root}/${dir}/${e.name}`; - if (e.name.endsWith('.json')) { - try { - const data = await readJSON(fullPath); - if (!data) continue; - _items.push({ - chamber: dir, - filename: e.name, - path: fullPath, - summary: data.human_summary || `${data.type || dir} entry`, - type: data.type || dir, - at: data.drawn_at || data.sealed_at || data.started_at || extractDate(e.name), - data, - }); - } catch {} - } else if (e.name.endsWith('.md') && dir === 'diary') { - try { - const text = await readText(fullPath); - if (text == null) continue; - const { meta, body } = parseFrontmatter(text); - _items.push({ - chamber: dir, - filename: e.name, - path: fullPath, - summary: meta.question ? `Diary: "${truncate(meta.question, 60)}"` : `Diary entry for ${meta.date || e.name.replace('.md', '')}`, - type: 'diary-entry', - at: meta.drawn_at || meta.date || extractDate(e.name), - data: { ...meta, body, type: 'diary-entry' }, - }); - } catch {} - } - } - } catch { - /* directory may not exist yet */ - } - } - _items.sort((a, b) => String(b.at).localeCompare(String(a.at))); - _loading = false; - render(); +function selectRouteRecord() { + if (!_routeRecordId && !_routeRecordPath) return; + const match = archiveWorkspace.items.find((item) => item.status === 'ok' + && (_routeRecordPath ? item.path === _routeRecordPath : item.id === _routeRecordId)); + if (match) _selectedPath = match.path; } function render() { if (!_rootEl) return; + const health = archiveWorkspace.health; + const items = filteredItems(); + const selected = _selectedPath ? archiveWorkspace.items.find((item) => item.path === _selectedPath) : null; + const selectedUnavailable = Boolean(_selectedPath && !selected); + const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE)); + _page = Math.min(_page, totalPages - 1); + const pageItems = items.slice(_page * PAGE_SIZE, (_page + 1) * PAGE_SIZE); clear(_rootEl); - const frame = h('div', { class: 'chamber-frame full-bleed reveal' }, [ - h('div', { class: 'archive-header' }, [ - h('div', null, [ - h('div', { class: 'chamber-id' }, ['Archive · timeline view']), - h('h1', { class: 'chamber-title-big' }, ['Archive']), - h('p', { class: 'chamber-tagline' }, [ - _loading ? 'reading…' : `${_items.length} item${_items.length === 1 ? '' : 's'} across all chambers`, - ]), + _rootEl.append(h('div', { class: 'chamber-frame full-bleed reveal archive-v2' }, [ + buildHeader(health), + buildViewToolbar(), + buildFilters(), + buildActiveFilters(), + buildHealthNotice(health), + buildProjectTimeline(items), + buildOnThisDay(), + _selectedPaths.size ? buildSelectionBar() : null, + h('div', { class: `archive-workspace${selected || selectedUnavailable ? ' has-detail' : ''}` }, [ + h('main', { class: 'archive-results', 'aria-label': `${capitalize(_view)} Archive view` }, [ + health.status === 'loading' && archiveWorkspace.items.length === 0 + ? h('div', { class: 'archive-state' }, [Skeleton(6)]) + : buildView(pageItems, items), + ['timeline', 'gallery'].includes(_view) && items.length > PAGE_SIZE + ? buildPagination(totalPages, items.length) + : null, ]), - h('button', { class: 'btn btn-ghost', onclick: () => load() }, ['Reload']), + selected ? buildDetail(selected) : selectedUnavailable ? buildUnavailableDetail(_selectedPath) : null, ]), - h('div', { class: 'archive-filter row' }, [ - h('span', { class: 'label', style: { marginBottom: 0 } }, ['Filter']), - h('div', { class: 'segmented' }, - ['all', ...CHAMBER_DIRS].map((c) => h('button', { - class: 'segmented-opt', - 'aria-pressed': _filterChamber === c ? 'true' : 'false', - onclick: () => { _filterChamber = c; render(); }, - }, [c])) - ), + ])); +} + +function buildHeader(health) { + return h('header', { class: 'archive-header' }, [ + h('div', null, [ + h('div', { class: 'chamber-id' }, ['Archive · local indexed history']), + h('h1', { class: 'chamber-title-big', 'data-page-title': 'true' }, ['Archive']), + h('p', { class: 'chamber-tagline' }, [ + health.status === 'loading' + ? 'Reading local history…' + : `${health.source_count} record${health.source_count === 1 ? '' : 's'} · ${health.error_count} read error${health.error_count === 1 ? '' : 's'}`, + ]), ]), - h('div', { class: 'archive-body' }, [ - buildTimeline(), - _selectedItem ? buildDetail() : h('aside', { class: 'archive-detail-empty muted small center' }, ['Pick an item to see details.']), + h('div', { class: 'archive-header-actions' }, [ + h('button', { class: 'btn btn-ghost', type: 'button', onclick: openArchiveFolder }, ['Open folder']), + h('button', { class: 'btn btn-ghost', type: 'button', onclick: rebuildArchive }, ['Rebuild']), ]), ]); - _rootEl.appendChild(frame); } -function buildTimeline() { - const filtered = _filterChamber === 'all' - ? _items - : _items.filter((it) => it.chamber === _filterChamber); +function buildViewToolbar() { + return h('div', { class: 'archive-viewbar', role: 'group', 'aria-label': 'Archive view' }, VIEWS.map((view) => h('button', { + class: 'archive-view-option', + type: 'button', + 'aria-pressed': _view === view ? 'true' : 'false', + onclick: () => { + _view = view; + _page = 0; + render(); + }, + }, [view === 'timeline' ? 'Timeline' : capitalize(view)]))); +} - if (_loading) { - return h('div', { class: 'archive-list' }, [h('div', { class: 'loading-line' })]); - } - if (!filtered.length) { - return h('div', { class: 'archive-list empty muted center', style: { padding: '40px' } }, [ - _items.length === 0 && _filterChamber === 'all' - ? 'No archived items yet. Save a draw or a decision to begin.' - : 'No items match this filter.', - ]); +function buildFilters() { + return h('section', { class: 'archive-filter-panel', 'aria-label': 'Archive filters' }, [ + h('label', { class: 'archive-search' }, [ + h('span', { class: 'archive-filter-label' }, ['Search Archive']), + h('input', { + class: 'input', type: 'search', placeholder: 'Search titles, prompts, tags, projects…', value: _search, + 'data-archive-search': 'true', + oninput: preserveSearchInput((value) => { _search = value; _page = 0; }), + }), + ]), + filterSelect('Chamber', _filterChamber, [['all', 'All chambers'], ...ARCHIVE_CHAMBERS.map((value) => [value, capitalize(value)])], (value) => { _filterChamber = value; }), + filterSelect('Type', _filterType, optionsFor('type', 'All types'), (value) => { _filterType = value; }), + filterSelect('Source', _filterSource, optionsFor('source', 'All sources'), (value) => { _filterSource = value; }), + h('button', { + class: `archive-filter-toggle${_favoriteOnly ? ' active' : ''}`, type: 'button', + 'aria-pressed': _favoriteOnly ? 'true' : 'false', + onclick: () => { _favoriteOnly = !_favoriteOnly; _page = 0; render(); }, + }, ['★ Favorites']), + h('button', { + class: `archive-filter-toggle${_errorsOnly ? ' active' : ''}`, type: 'button', + 'aria-pressed': _errorsOnly ? 'true' : 'false', + onclick: () => { _errorsOnly = !_errorsOnly; _page = 0; render(); }, + }, ['Read errors']), + h('button', { + class: `archive-filter-toggle${_includeHidden ? ' active' : ''}`, type: 'button', + 'aria-pressed': _includeHidden ? 'true' : 'false', + onclick: () => { _includeHidden = !_includeHidden; _page = 0; render(); }, + }, [_includeHidden ? 'Hidden shown' : 'Show hidden']), + h('details', { class: 'archive-more-filters' }, [ + h('summary', null, ['More filters']), + h('div', { class: 'archive-more-filter-grid' }, [ + filterSelect('Pack', _filterPack, optionsFor('pack', 'All packs'), (value) => { _filterPack = value; }), + filterSelect('Project', _filterProject, optionsFor('project', 'All projects'), (value) => { _filterProject = value; }), + filterSelect('Collection', _filterCollection, collectionOptions(), (value) => { _filterCollection = value; }), + dateFilter('From', _dateFrom, (value) => { _dateFrom = value; }), + dateFilter('To', _dateTo, (value) => { _dateTo = value; }), + ]), + ]), + ]); +} + +function filterSelect(label, value, options, update) { + return h('label', { class: 'archive-filter-control' }, [ + h('span', { class: 'archive-filter-label' }, [label]), + h('select', { + class: 'select', value, 'aria-label': `Filter by ${label.toLowerCase()}`, + onchange: (event) => { update(event.target.value); _page = 0; render(); }, + }, options.map(([optionValue, text]) => h('option', { + value: optionValue, + selected: optionValue === value ? true : null, + }, [text]))), + ]); +} + +function dateFilter(label, value, update) { + return h('label', { class: 'archive-date-control small muted' }, [label, h('input', { + class: 'input', type: 'date', value, + onchange: (event) => { update(event.target.value); _page = 0; render(); }, + })]); +} + +function buildActiveFilters() { + const filters = activeFilters(); + if (!filters.length) return null; + return h('div', { class: 'archive-active-filters', 'aria-label': 'Active filters' }, [ + h('span', { class: 'small muted' }, ['Active']), + ...filters.map(({ label, clear: reset }) => h('button', { + class: 'archive-filter-chip', type: 'button', 'aria-label': `Remove filter ${label}`, + onclick: () => { reset(); _page = 0; render(); }, + }, [label, ' ×'])), + h('button', { class: 'btn btn-ghost archive-clear-filters', type: 'button', onclick: clearFilters }, ['Clear all']), + ]); +} + +function activeFilters() { + const filters = []; + if (_search) filters.push({ label: `Search: ${truncate(_search, 28)}`, clear: () => { _search = ''; } }); + if (_filterChamber !== 'all') filters.push({ label: `Chamber: ${_filterChamber}`, clear: () => { _filterChamber = 'all'; } }); + if (_filterType !== 'all') filters.push({ label: `Type: ${_filterType}`, clear: () => { _filterType = 'all'; } }); + if (_filterSource !== 'all') filters.push({ label: `Source: ${_filterSource}`, clear: () => { _filterSource = 'all'; } }); + if (_filterPack !== 'all') filters.push({ label: `Pack: ${_filterPack}`, clear: () => { _filterPack = 'all'; } }); + if (_filterProject !== 'all') filters.push({ label: `Project: ${projectName(_filterProject)}`, clear: () => { _filterProject = 'all'; } }); + if (_filterCollection !== 'all') filters.push({ label: `Collection: ${collectionName(_filterCollection)}`, clear: () => { _filterCollection = 'all'; } }); + if (_favoriteOnly) filters.push({ label: 'Favorites', clear: () => { _favoriteOnly = false; } }); + if (_errorsOnly) filters.push({ label: 'Read errors', clear: () => { _errorsOnly = false; } }); + if (_includeHidden) filters.push({ label: 'Hidden shown', clear: () => { _includeHidden = false; } }); + if (_dateFrom) filters.push({ label: `From ${_dateFrom}`, clear: () => { _dateFrom = ''; } }); + if (_dateTo) filters.push({ label: `To ${_dateTo}`, clear: () => { _dateTo = ''; } }); + return filters; +} + +function buildHealthNotice(health) { + const notices = []; + if (health.watcher === 'unavailable') notices.push('Automatic refresh is unavailable; use Rebuild after external file changes.'); + if (health.annotation_error) notices.push(`${health.annotation_error} Annotation editing is disabled so the file is not overwritten.`); + if (health.last_error && !health.last_error.includes('Automatic Archive refresh')) notices.push(health.last_error); + if (!notices.length) return null; + return h('div', { class: 'archive-health-notice', role: 'status' }, notices.map((notice) => h('p', null, [notice]))); +} + +function buildProjectTimeline(items) { + if (_filterProject === 'all') return null; + const project = _context?.projects?.repository?.get?.(_filterProject) || null; + const normalized = items.filter((item) => item.status === 'ok'); + const name = project?.name || projectName(_filterProject); + const ordered = project + ? project.steps.map((step) => ({ + step, + item: step.record_reference + ? normalized.find((candidate) => candidate.id === step.record_reference.id) || null + : null, + })) + : normalized.map((item) => ({ + step: { + title: relationText(item, 'step_title') || capitalize(item.chamber), + status: 'completed', + record_reference: { summary: item.summary }, + }, + item, + })); + return h('section', { class: 'archive-project-timeline panel', 'aria-labelledby': 'archive-project-title' }, [ + h('div', { class: 'archive-project-heading' }, [ + h('div', null, [ + h('div', { class: 'chamber-id' }, [project ? `${project.template.name} · linked history` : 'Retained project history']), + h('h2', { id: 'archive-project-title' }, [name]), + h('p', { class: 'muted' }, [project + ? `${normalized.length} related Archive record${normalized.length === 1 ? '' : 's'} · ${project.status}` + : `${normalized.length} Archive record${normalized.length === 1 ? ' remains' : 's remain'} after the mutable project was removed.`]), + ]), + project ? h('button', { + class: 'btn', type: 'button', onclick: () => void _context?.navigate?.('projects', { project_id: project.id }), + }, ['Open project']) : h('span', { class: 'badge' }, ['Project removed']), + ]), + h('ol', { class: 'archive-project-steps' }, ordered.map(({ step, item }, index) => h('li', { + class: `archive-project-step is-${step.status}`, + }, [ + h('span', { class: 'archive-project-step-number', 'aria-hidden': 'true' }, [step.status === 'completed' ? '✓' : step.status === 'skipped' ? '–' : String(index + 1)]), + h('span', { class: 'archive-project-step-copy' }, [ + h('strong', null, [step.title]), + h('span', { class: 'small muted' }, [item?.summary || step.record_reference?.summary || (step.status === 'skipped' ? 'Skipped' : 'No Archive result yet')]), + ]), + item ? h('button', { class: 'btn btn-small', type: 'button', onclick: () => selectItem(item.path) }, ['Open record']) : null, + ]))), + ]); +} + +function buildOnThisDay() { + if (!getState().onThisDay) return null; + const today = localIsoDate().slice(5, 10); + const currentYear = new Date().getFullYear().toString(); + const memories = archiveWorkspace.search('', 'all', false) + .filter((item) => item.status === 'ok' && item.at.slice(5, 10) === today && item.at.slice(0, 4) !== currentYear) + .slice(0, 4); + if (!memories.length) return null; + return h('section', { class: 'archive-on-this-day panel', 'aria-labelledby': 'archive-memory-title' }, [ + h('div', null, [ + h('div', { class: 'chamber-id' }, ['Optional memory']), + h('h2', { id: 'archive-memory-title' }, ['On this day']), + ]), + h('div', { class: 'archive-memory-list' }, memories.map((item) => h('button', { + class: 'archive-memory', type: 'button', onclick: () => selectItem(item.path), + }, [h('strong', null, [displayTitle(item)]), h('span', { class: 'small muted mono' }, [item.at.slice(0, 4)]),]))), + ]); +} + +function buildSelectionBar() { + return h('div', { class: 'archive-selection-bar', role: 'status' }, [ + h('strong', null, [`${_selectedPaths.size} selected`]), + h('button', { class: 'btn btn-primary', type: 'button', onclick: prepareExportSelection }, ['Prepare export']), + h('button', { class: 'btn btn-ghost', type: 'button', onclick: () => { _selectedPaths.clear(); render(); } }, ['Clear selection']), + ]); +} + +function buildView(pageItems, allItems) { + if (!allItems.length) { + return EmptyState( + archiveWorkspace.items.length ? 'No matching records' : 'Nothing archived yet', + archiveWorkspace.items.length ? 'Change or clear a filter to see more local history.' : 'Save a chamber result and it will appear here with its provenance intact.', + activeFilters().length ? h('button', { class: 'btn', type: 'button', onclick: clearFilters }, ['Clear filters']) : undefined, + ); } - return h('ul', { class: 'archive-list' }, filtered.map((it) => h('li', { - class: 'archive-row' + (_selectedItem?.path === it.path ? ' active' : ''), - onclick: () => { _selectedItem = it; render(); }, + if (_view === 'calendar') return buildCalendar(allItems); + if (_view === 'gallery') return buildGallery(pageItems); + if (_view === 'collections') return buildCollections(allItems); + return buildTimeline(pageItems); +} + +function buildTimeline(items) { + return h('ol', { class: 'archive-list' }, items.map((item) => h('li', { + class: `archive-row${_selectedPath === item.path ? ' active' : ''}${item.status === 'error' ? ' error' : ''}`, }, [ - h('span', { class: 'archive-chamber mono small' }, [it.chamber]), - h('span', { class: 'archive-summary' }, [it.summary]), - h('span', { class: 'archive-time small muted mono' }, [it.at ? humanDate(it.at) : '—']), + selectionCheckbox(item), + h('button', { class: 'archive-row-main', type: 'button', onclick: () => selectItem(item.path) }, [ + h('span', { class: 'archive-chamber mono small' }, [item.chamber]), + h('span', { class: 'archive-summary' }, [displayTitle(item)]), + h('span', { class: 'archive-row-badges' }, itemBadges(item)), + h('time', { class: 'archive-time small muted mono', datetime: item.at }, [displayItemDate(item)]), + ]), ]))); } -function buildDetail() { - const it = _selectedItem; - const isDiary = it.type === 'diary-entry'; - return h('aside', { class: 'archive-detail panel' }, [ +function buildGallery(items) { + return h('ul', { class: 'archive-gallery' }, items.map((item) => { + if (item.status === 'error') return h('li', { class: 'archive-gallery-card error' }, [ + selectionCheckbox(item), + h('button', { class: 'archive-gallery-main', type: 'button', onclick: () => selectItem(item.path) }, [ + h('div', { class: 'archive-thumbnail archive-thumbnail-error', role: 'img', 'aria-label': 'Unreadable record preview' }, ['!']), + h('span', { class: 'archive-gallery-copy' }, [ + h('strong', null, [item.summary]), + h('span', { class: 'small bad' }, ['Read error']), + ]), + ]), + ]); + const thumbnail = archiveThumbnail(item); + ensureAssetStatuses(item); + return h('li', { class: `archive-gallery-card${_selectedPath === item.path ? ' active' : ''}` }, [ + selectionCheckbox(item), + h('button', { class: 'archive-gallery-main', type: 'button', onclick: () => selectItem(item.path) }, [ + h('div', { + class: 'archive-thumbnail', role: 'img', 'aria-label': thumbnail.label, + style: { + '--thumb-hue': thumbnail.hue, + '--thumb-hue-2': thumbnail.secondaryHue, + '--thumb-angle': `${thumbnail.angle}deg`, + '--thumb-orbit': `${thumbnail.orbit}%`, + }, + }, [h('span', { 'aria-hidden': 'true' }, [thumbnail.mark])]), + h('span', { class: 'archive-gallery-copy' }, [ + h('strong', null, [displayTitle(item)]), + h('span', { class: 'small muted' }, [`${item.chamber} · ${humanDate(item.at)}`]), + missingAssetCount(item) ? h('span', { class: 'small bad' }, [`${missingAssetCount(item)} missing asset${missingAssetCount(item) === 1 ? '' : 's'}`]) : null, + ]), + ]), + ]); + })); +} + +function buildCalendar(items) { + const cells = calendarCells(_calendarMonth); + const recordsByDay = new Map(); + for (const item of items) { + const date = item.at.slice(0, 10); + const records = recordsByDay.get(date) || []; + records.push(item); + recordsByDay.set(date, records); + } + const monthLabel = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' }) + .format(new Date(`${_calendarMonth}-01T00:00:00Z`)); + const selectedDay = _calendarSelectedDay && _calendarSelectedDay.startsWith(_calendarMonth) + ? _calendarSelectedDay + : cells.find((date) => date.startsWith(_calendarMonth) && recordsByDay.has(date)) || `${_calendarMonth}-01`; + _calendarSelectedDay = selectedDay; + const dayItems = recordsByDay.get(selectedDay) || []; + return h('div', { class: 'archive-calendar-layout' }, [ + h('section', { class: 'archive-calendar panel', 'aria-label': `Archive calendar for ${monthLabel}` }, [ + h('div', { class: 'archive-calendar-header' }, [ + h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous month', onclick: () => changeCalendarMonth(-1) }, ['‹']), + h('h2', { class: 'archive-calendar-month', 'aria-live': 'polite' }, [monthLabel]), + h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next month', onclick: () => changeCalendarMonth(1) }, ['›']), + ]), + h('div', { class: 'archive-calendar-weekdays', 'aria-hidden': 'true' }, ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day) => h('span', null, [day]))), + h('div', { class: 'archive-calendar-grid', role: 'grid', 'aria-label': monthLabel }, cells.map((date, index) => { + const dayRecords = recordsByDay.get(date) || []; + const inMonth = date.startsWith(_calendarMonth); + const label = `${date}; ${dayRecords.length} record${dayRecords.length === 1 ? '' : 's'}`; + return h('div', { role: 'gridcell', class: 'archive-calendar-cell' }, [h('button', { + class: `archive-calendar-day${inMonth ? '' : ' other-month'}${dayRecords.length ? ' has-records' : ''}${date === selectedDay ? ' selected' : ''}`, + type: 'button', tabindex: date === selectedDay ? 0 : -1, + 'data-calendar-index': index, + 'data-calendar-date': date, + 'aria-label': label, + 'aria-selected': date === selectedDay ? 'true' : 'false', + onclick: () => { _calendarSelectedDay = date; if (!inMonth) _calendarMonth = date.slice(0, 7); render(); }, + onkeydown: calendarKeydown, + }, [ + h('span', { class: 'archive-calendar-number' }, [String(Number(date.slice(8, 10)))]), + dayRecords.length ? h('span', { class: 'archive-calendar-count' }, [String(dayRecords.length)]) : null, + ])]); + })), + ]), + h('section', { class: 'archive-calendar-agenda', 'aria-labelledby': 'archive-agenda-title' }, [ + h('h3', { id: 'archive-agenda-title' }, [longDate(selectedDay)]), + dayItems.length ? buildTimeline(dayItems) : EmptyState('No records this day', 'Choose a marked date to inspect its history.'), + ]), + ]); +} + +function buildCollections(items) { + const collections = archiveWorkspace.collectionList(); + const groupById = new Map(collections.map((collection) => [collection.id, { collection, items: [] }])); + const unfiled = []; + for (const item of items) { + if (item.status !== 'ok') continue; + let assigned = false; + for (const collectionId of archiveWorkspace.annotationFor(item.id).collections) { + const group = groupById.get(collectionId); + if (!group) continue; + group.items.push(item); + assigned = true; + } + if (!assigned) unfiled.push(item); + } + const groups = [...groupById.values()].filter((group) => group.items.length > 0); + if (!groups.length && !unfiled.length) return EmptyState('No collections yet', 'Annotate a record to create a collection and gather related history.'); + return h('div', { class: 'archive-collections' }, [ + ...groups.map(({ collection, items: groupItems }) => collectionCard(collection.id, collection.name, groupItems)), + unfiled.length ? collectionCard('unfiled', 'Unfiled', unfiled) : null, + ]); +} + +function collectionCard(id, name, items) { + return h('section', { class: 'archive-collection-card panel' }, [ h('div', { class: 'spread' }, [ - h('h3', { style: { margin: 0 } }, [it.summary]), - h('span', { class: 'badge' }, [it.chamber]), - ]), - h('div', { class: 'small muted mono' }, [it.path]), - h('hr', { class: 'hr' }), - isDiary - ? h('div', { class: 'archive-diary-body' }, [ - h('div', { class: 'archive-diary-meta small muted' }, [ - `prompt: "${truncate(it.data.question || '', 80)}" · word: ${it.data.word || ''} · number: ${it.data.number || ''}`, + h('div', null, [h('div', { class: 'chamber-id' }, ['Collection']), h('h2', null, [name])]), + h('span', { class: 'badge' }, [String(items.length)]), + ]), + h('p', { class: 'muted small' }, [items.slice(0, 3).map(displayTitle).join(' · ')]), + id !== 'unfiled' ? h('button', { + class: 'btn btn-ghost', type: 'button', onclick: () => { _filterCollection = id; _view = 'timeline'; _page = 0; render(); }, + }, ['View collection']) : null, + ]); +} + +function buildDetail(item) { + if (item.status === 'error') return h('aside', { class: 'archive-detail panel', 'aria-label': 'Record details' }, [ + detailCloseButton(), + h('div', { class: 'spread' }, [h('h2', { tabindex: -1 }, ['Recoverable read error']), h('span', { class: 'badge bad' }, ['file unchanged'])]), + h('div', { class: 'small muted mono selectable' }, [item.path]), + h('p', { class: 'bad', role: 'alert' }, [item.error]), + h('p', { class: 'small muted' }, ['Sortilune did not modify or delete this file. Repair it externally; automatic refresh will retry it, or use Rebuild.']), + ]); + ensureAssetStatuses(item); + const annotation = archiveWorkspace.annotationFor(item.id); + const relations = archiveWorkspace.relationsFor(item); + const diaryPayload = item.type === 'diary-entry' ? objectValue(item.payload) : null; + const diaryPrompt = diaryPayload ? objectValue(diaryPayload.prompt) : null; + return h('aside', { class: 'archive-detail panel', 'aria-label': 'Record details' }, [ + detailCloseButton(), + h('div', { class: 'archive-detail-heading' }, [ + h('div', null, [ + h('div', { class: 'archive-detail-kicker' }, [h('span', { class: 'badge' }, [item.chamber]), h('span', { class: 'small muted mono' }, [item.type])]), + h('h2', { tabindex: -1 }, [displayTitle(item)]), + annotation.title ? h('p', { class: 'small muted' }, [`Source title: ${item.summary}`]) : null, + ]), + h('div', { class: 'archive-detail-actions' }, [ + h('button', { + class: `btn btn-ghost btn-icon${annotation.favorite ? ' active' : ''}`, type: 'button', + 'aria-label': annotation.favorite ? 'Remove from favorites' : 'Add to favorites', + 'aria-pressed': annotation.favorite ? 'true' : 'false', + disabled: archiveWorkspace.health.annotation_error ? true : null, + onclick: () => updateAnnotation(item.id, { favorite: !annotation.favorite }), + }, [annotation.favorite ? '★' : '☆']), + h('button', { class: 'btn btn-ghost', type: 'button', disabled: archiveWorkspace.health.annotation_error ? true : null, onclick: () => openAnnotationDialog(item) }, ['Annotate']), + h('button', { class: 'btn btn-ghost', type: 'button', onclick: () => void exportArchiveReceipt(item) }, ['Export receipt']), + ]), + ]), + h('div', { class: 'small muted mono selectable' }, [item.path]), + annotation.tags.length ? h('div', { class: 'archive-tag-list' }, annotation.tags.map((tag) => h('span', { class: 'archive-tag' }, [tag]))) : null, + h('dl', { class: 'archive-detail-facts' }, [ + fact('Created', item.at ? humanDate(item.at) : 'Unknown'), + fact('Source', item.provenance[0]?.source.label || 'No source recorded'), + fact('Format', item.legacy ? 'Legacy, normalized in memory' : `Archive record v${item.record.schema_version}`), + fact('Record ID', item.id), + ]), + diaryPayload ? h('section', { class: 'archive-diary-body' }, [ + h('h3', null, ['Diary entry']), + h('div', { class: 'archive-diary-meta small muted' }, [ + `Prompt: “${truncate(diaryPrompt?.question || '', 100)}” · word: ${diaryPrompt?.word || ''} · number: ${diaryPrompt?.number || ''}`, + ]), + h('pre', { class: 'archive-diary-text' }, [typeof diaryPayload.body === 'string' && diaryPayload.body ? diaryPayload.body : '(empty)']), + ]) : null, + buildPackReference(item.record.pack), + buildRelations(relations), + buildAssets(item), + h('details', { class: 'archive-raw' }, [ + h('summary', null, ['Raw payload']), + h('pre', { class: 'mono selectable' }, [JSON.stringify(item.record, null, 2)]), + ]), + h('button', { + class: 'btn btn-ghost archive-hide-action', type: 'button', disabled: archiveWorkspace.health.annotation_error ? true : null, + onclick: () => updateAnnotation(item.id, { hidden: !annotation.hidden }), + }, [annotation.hidden ? 'Restore from hidden' : 'Hide from default views']), + ]); +} + +async function exportArchiveReceipt(item) { + try { + const receipt = await createReceipt({ + sourceKind: 'archive-record', + sourceId: item.id, + title: displayTitle(item), + content: item.record, + }); + downloadReceipt(receipt); + toast('Portable receipt ready.', 'success'); + } catch (error) { + toast(`Could not export receipt: ${error?.message || error}`, 'danger'); + } +} + +function buildPackReference(pack) { + if (!pack || typeof pack !== 'object') return null; + if (typeof pack.version !== 'string') return h('section', { class: 'archive-pack-reference' }, [ + h('h3', null, ['Content pack']), + h('p', { class: 'small muted' }, [`Legacy reference · ${pack.id} · version ${pack.version}`]), + ]); + return h('section', { class: 'archive-pack-reference' }, [ + h('div', { class: 'spread' }, [ + h('h3', null, ['Content pack']), + h('span', { class: 'badge' }, ['snapshot preserved']), + ]), + h('dl', { class: 'archive-detail-facts small' }, [ + fact('Pack', pack.id), + fact('Version', pack.version), + fact('Selected item', pack.item_id), + fact('Digest', pack.digest), + ]), + h('details', { class: 'archive-pack-snapshot' }, [ + h('summary', null, ['Selected content snapshot']), + h('pre', { class: 'mono selectable' }, [JSON.stringify(pack.content_snapshot, null, 2)]), + ]), + ]); +} + +function buildUnavailableDetail(path) { + return h('aside', { class: 'archive-detail panel', 'aria-label': 'Record unavailable' }, [ + detailCloseButton(), + h('div', { class: 'spread' }, [ + h('h2', { tabindex: -1 }, ['Record no longer available']), + h('span', { class: 'badge bad' }, ['source changed']), + ]), + h('div', { class: 'small muted mono selectable' }, [path]), + h('p', { role: 'status' }, ['The selected source file was moved or removed outside Sortilune. Your selection was preserved so the change is explicit.']), + h('p', { class: 'small muted' }, ['Restore the file to this path or close this panel and choose another record.']), + ]); +} + +function buildRelations(relations) { + if (!relations.length) return null; + return h('section', { class: 'archive-relations', 'aria-labelledby': 'archive-relations-title' }, [ + h('h3', { id: 'archive-relations-title' }, ['Linked history']), + h('ul', null, relations.map((view) => { + if (view.relation.kind === 'project') { + const name = relationMetadata(view.relation).project_name || projectName(view.relation.target_id); + const step = relationMetadata(view.relation).step_title; + const project = _context?.projects?.repository?.get?.(view.relation.target_id); + return h('li', { class: `archive-relation archive-project-relation${project ? '' : ' dangling'}` }, [ + h('span', { class: 'archive-relation-kind small mono' }, ['outgoing · project']), + h('span', { class: 'archive-project-relation-copy' }, [ + h('strong', null, [name]), + step ? h('span', { class: 'small muted' }, [step]) : null, ]), - h('pre', { class: 'archive-diary-text mono' }, [it.data.body || '(empty)']), - ]) - : h('details', { open: true }, [ - h('summary', null, ['payload']), - h('pre', { class: 'mono', style: { maxHeight: '400px', overflow: 'auto' } }, [JSON.stringify(it.data, null, 2)]), + project ? h('button', { + class: 'archive-relation-link', type: 'button', + onclick: () => void _context?.navigate?.('projects', { project_id: project.id }), + }, ['Open project']) : h('span', { class: 'small muted' }, ['Project removed; record retained']), + ]); + } + const related = view.direction === 'incoming' ? [view.source] : view.targets; + return h('li', { class: `archive-relation${view.dangling ? ' dangling' : ''}` }, [ + h('span', { class: 'archive-relation-kind small mono' }, [`${view.direction} · ${view.relation.kind}`]), + related.length + ? related.map((target) => h('button', { + class: 'archive-relation-link', type: 'button', onclick: () => navigateToRecord(target), + }, [displayTitle(target)])) + : h('span', { class: 'bad small' }, [`Missing target ${view.relation.target_id}`]), + ]); + })), + ]); +} + +function buildAssets(item) { + if (!item.assets.length) return null; + return h('section', { class: 'archive-assets', 'aria-labelledby': 'archive-assets-title' }, [ + h('h3', { id: 'archive-assets-title' }, ['Assets']), + h('ul', null, item.assets.map((asset) => { + const status = _assetState.get(asset.path) || 'checking'; + return h('li', { class: `archive-asset ${status}` }, [ + h('span', null, [asset.role]), + h('span', { class: `small ${status === 'missing' ? 'bad' : 'muted'}` }, [ + status === 'present' ? `${asset.media_type} · ${asset.bytes} bytes` + : status === 'missing' ? `Missing · ${asset.path}` + : status === 'unknown' ? `Could not verify · ${asset.path}` : 'Checking…', ]), + ]); + })), ]); } -function parseFrontmatter(text) { - const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); - if (!m) return { meta: {}, body: text }; - const meta = {}; - for (const line of m[1].split('\n')) { - const mm = line.match(/^(\w+):\s*(.*)$/); - if (!mm) continue; - let v = mm[2].trim(); - // strip surrounding quotes - if (v.startsWith('"') && v.endsWith('"')) { - try { v = JSON.parse(v); } catch {} +function openAnnotationDialog(item) { + const annotation = archiveWorkspace.annotationFor(item.id); + const collections = archiveWorkspace.collectionList(); + const titleInput = h('input', { class: 'input', value: annotation.title || '', maxlength: 240 }); + const tagsInput = h('input', { class: 'input', value: annotation.tags.join(', '), maxlength: 2100, placeholder: 'moon, reflection, favorite' }); + const newCollection = h('input', { class: 'input', maxlength: 120, placeholder: 'New collection name' }); + const collectionChecks = collections.map((collection) => h('label', { class: 'archive-collection-check' }, [ + h('input', { type: 'checkbox', value: collection.id, checked: annotation.collections.includes(collection.id) ? true : null }), + h('span', null, [collection.name]), + ])); + let dialog; + const save = h('button', { class: 'btn btn-primary', type: 'button', onclick: async () => { + save.disabled = true; + try { + const collectionIds = collectionChecks + .filter((label) => label.querySelector('input').checked) + .map((label) => label.querySelector('input').value); + await archiveWorkspace.updateAnnotation(item.id, { + title: titleInput.value, + tags: tagsInput.value.split(',').map((tag) => tag.trim()).filter(Boolean), + collections: collectionIds, + }, newCollection.value); + toast('Archive annotation saved', 'success'); + dialog.close(); + } catch (error) { + toast(`Could not save annotation: ${error?.message || error}`, 'danger'); + save.disabled = false; } - meta[mm[1]] = v; + } }, ['Save annotation']); + dialog = Dialog({ + title: `Annotate ${item.summary}`, + className: 'archive-annotation-dialog', + initialFocus: titleInput, + content: [ + h('label', { class: 'archive-dialog-field' }, [h('span', { class: 'label' }, ['Personal title']), titleInput]), + h('label', { class: 'archive-dialog-field' }, [h('span', { class: 'label' }, ['Tags, comma separated']), tagsInput]), + h('fieldset', { class: 'archive-collection-field' }, [ + h('legend', { class: 'label' }, ['Collections']), + collectionChecks.length ? h('div', { class: 'archive-collection-checks' }, collectionChecks) : h('p', { class: 'small muted' }, ['No collections yet.']), + newCollection, + ]), + h('div', { class: 'archive-dialog-actions' }, [save]), + ], + }); + dialog.open(); +} + +function filteredItems() { + const items = archiveWorkspace.search(_search, _filterChamber, _includeHidden); + return items.filter((item) => { + if (_errorsOnly && item.status !== 'error') return false; + if (item.status === 'error') { + const date = item.at.slice(0, 10); + if (_filterType !== 'all' || _filterSource !== 'all' || _filterPack !== 'all' + || _filterProject !== 'all' || _filterCollection !== 'all' || _favoriteOnly) return false; + if (_dateFrom && (!date || date < _dateFrom)) return false; + if (_dateTo && (!date || date > _dateTo)) return false; + return true; + } + const annotation = archiveWorkspace.annotationFor(item.id); + const source = item.provenance[0]?.source.id || ''; + const pack = item.record.pack?.id || ''; + const project = item.relations.find((relation) => relation.kind === 'project')?.target_id || ''; + const date = item.at.slice(0, 10); + if (_filterType !== 'all' && item.type !== _filterType) return false; + if (_filterSource !== 'all' && source !== _filterSource) return false; + if (_filterPack !== 'all' && pack !== _filterPack) return false; + if (_filterProject !== 'all' && project !== _filterProject) return false; + if (_filterCollection !== 'all' && !annotation.collections.includes(_filterCollection)) return false; + if (_favoriteOnly && !annotation.favorite) return false; + if (_dateFrom && date < _dateFrom) return false; + if (_dateTo && date > _dateTo) return false; + return true; + }); +} + +function optionsFor(kind, allLabel) { + if (kind === 'project') { + const values = new Set(); + for (const item of archiveWorkspace.items) { + if (item.status === 'error') continue; + const project = item.relations.find((relation) => relation.kind === 'project')?.target_id; + if (project) values.add(project); + } + return [['all', allLabel], ...[...values].sort((left, right) => projectName(left).localeCompare(projectName(right))) + .map((value) => [value, projectName(value)])]; + } + const values = new Set(); + for (const item of archiveWorkspace.items) { + if (item.status === 'error') continue; + if (kind === 'type') values.add(item.type); + if (kind === 'source' && item.provenance[0]?.source.id) values.add(item.provenance[0].source.id); + if (kind === 'pack' && item.record.pack?.id) values.add(item.record.pack.id); + } + return [['all', allLabel], ...[...values].sort().map((value) => [value, value])]; +} + +function projectName(projectId) { + const stored = _context?.projects?.repository?.get?.(projectId)?.name; + if (stored) return stored; + for (const item of archiveWorkspace.items) { + if (item.status === 'error') continue; + const relation = item.relations.find((candidate) => candidate.kind === 'project' && candidate.target_id === projectId); + const name = relation && relationMetadata(relation).project_name; + if (name) return name; + } + return `Retained project · ${String(projectId).slice(0, 8)}`; +} + +function relationMetadata(relation) { + return relation?.metadata && typeof relation.metadata === 'object' && !Array.isArray(relation.metadata) + ? relation.metadata + : {}; +} + +function relationText(item, key) { + const relation = item.relations.find((candidate) => candidate.kind === 'project' && candidate.target_id === _filterProject); + const value = relation && relationMetadata(relation)[key]; + return typeof value === 'string' ? value : ''; +} + +function collectionOptions() { + return [['all', 'All collections'], ...archiveWorkspace.collectionList().map((collection) => [collection.id, collection.name])]; +} + +function collectionName(id) { + return archiveWorkspace.collectionList().find((collection) => collection.id === id)?.name || id; +} + +function selectionCheckbox(item) { + return h('label', { class: 'archive-select-record', onclick: (event) => event.stopPropagation() }, [ + h('span', { class: 'sr-only' }, [`Select ${displayTitle(item)}`]), + h('input', { + type: 'checkbox', checked: _selectedPaths.has(item.path) ? true : null, + onchange: (event) => { + if (event.target.checked) _selectedPaths.add(item.path); + else _selectedPaths.delete(item.path); + render(); + }, + }), + ]); +} + +function itemBadges(item) { + if (item.status === 'error') return [h('span', { class: 'badge bad' }, ['error'])]; + const annotation = archiveWorkspace.annotationFor(item.id); + return [ + annotation.favorite ? h('span', { class: 'badge badge-accent', 'aria-label': 'Favorite' }, ['★']) : null, + annotation.hidden ? h('span', { class: 'badge' }, ['hidden']) : null, + item.legacy ? h('span', { class: 'badge' }, ['legacy']) : null, + ]; +} + +function displayTitle(item) { + return item.status === 'ok' ? archiveWorkspace.annotationFor(item.id).title || item.summary : item.summary; +} + +function displayItemDate(item) { + if (!item.at) return '—'; + return item.status === 'error' ? item.at.slice(0, 10) : humanDate(item.at); +} + +function selectItem(path) { + _selectedPath = path; + render(); + queueMicrotask(() => _rootEl?.querySelector('.archive-detail h2')?.focus?.({ preventScroll: true })); +} + +function detailCloseButton() { + return h('button', { + class: 'archive-detail-close btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Close record details', + onclick: () => { _selectedPath = null; render(); }, + }, ['×']); +} + +function navigateToRecord(record) { + const params = { record_id: record.id }; + if (record.path.length <= 256) params.record_path = record.path; + else { + selectItem(record.path); + return; + } + void go({ destination: 'archive', params }); +} + +async function updateAnnotation(recordId, patch) { + try { + await archiveWorkspace.updateAnnotation(recordId, patch); + } catch (error) { + toast(`Could not update annotation: ${error?.message || error}`, 'danger'); } - return { meta, body: m[2].trim() }; } -function truncate(s, n) { return String(s).length <= n ? s : String(s).slice(0, n - 1) + '…'; } -function extractDate(filename) { - const m = String(filename).match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d+Z)/); - if (m) return m[1].replace(/-(\d{2})-(\d{2})-(\d+)Z/, ':$1:$2.$3Z'); - const d = String(filename).match(/^(\d{4}-\d{2}-\d{2})/); - return d ? d[1] + 'T00:00:00Z' : ''; +function ensureAssetStatuses(item) { + for (const asset of item.assets) { + if (_assetState.has(asset.path)) continue; + _assetState.set(asset.path, 'checking'); + void archiveWorkspace.assetExists(asset.path).then((exists) => { + _assetState.set(asset.path, exists === null ? 'unknown' : exists ? 'present' : 'missing'); + if (_rootEl) render(); + }).catch(() => { + _assetState.set(asset.path, 'unknown'); + if (_rootEl) render(); + }); + } +} + +function missingAssetCount(item) { + return item.assets.filter((asset) => _assetState.get(asset.path) === 'missing').length; +} + +function prepareExportSelection() { + const selected = archiveWorkspace.items.filter((item) => _selectedPaths.has(item.path)); + const manifest = { + schema: 'sortilune.export-selection-handoff', + schema_version: 1, + created_at: new Date().toISOString(), + records: selected.map((item) => ({ path: item.path, id: item.status === 'ok' ? item.id : null })), + }; + try { + sessionStorage.setItem('sortilune.export-selection.v1', JSON.stringify(manifest)); + toast(`${selected.length} record${selected.length === 1 ? '' : 's'} prepared for journal export`, 'success'); + void _context?.navigate?.('journal'); + } catch (error) { + toast(`Could not prepare export selection: ${error?.message || error}`, 'danger'); + } +} + +function buildPagination(totalPages, totalItems) { + return h('nav', { class: 'archive-pagination', 'aria-label': 'Archive result pages' }, [ + h('button', { class: 'btn btn-ghost', type: 'button', disabled: _page === 0 ? true : null, onclick: () => { _page -= 1; render(); } }, ['Previous']), + h('span', { class: 'small muted mono' }, [`Page ${_page + 1} of ${totalPages} · ${totalItems} records`]), + h('button', { class: 'btn btn-ghost', type: 'button', disabled: _page >= totalPages - 1 ? true : null, onclick: () => { _page += 1; render(); } }, ['Next']), + ]); +} + +function calendarCells(month) { + const [year, monthNumber] = month.split('-').map(Number); + const first = new Date(Date.UTC(year, monthNumber - 1, 1)); + const start = new Date(first); + start.setUTCDate(first.getUTCDate() - first.getUTCDay()); + return Array.from({ length: 42 }, (_, index) => { + const date = new Date(start); + date.setUTCDate(start.getUTCDate() + index); + return date.toISOString().slice(0, 10); + }); +} + +function calendarKeydown(event) { + const button = event.currentTarget; + const index = Number(button.dataset.calendarIndex); + let targetIndex; + if (event.key === 'ArrowLeft') targetIndex = index - 1; + else if (event.key === 'ArrowRight') targetIndex = index + 1; + else if (event.key === 'ArrowUp') targetIndex = index - 7; + else if (event.key === 'ArrowDown') targetIndex = index + 7; + else if (event.key === 'Home') targetIndex = index - (index % 7); + else if (event.key === 'End') targetIndex = index + (6 - (index % 7)); + else if (event.key === 'PageUp' || event.key === 'PageDown') { + event.preventDefault(); + const step = event.key === 'PageUp' ? -1 : 1; + changeCalendarMonth(event.shiftKey ? step * 12 : step, Number(button.dataset.calendarDate.slice(8, 10))); + return; + } else return; + event.preventDefault(); + const target = _rootEl?.querySelector(`[data-calendar-index="${targetIndex}"]`); + if (target) { + _rootEl.querySelectorAll('[data-calendar-index]').forEach((day) => { day.tabIndex = day === target ? 0 : -1; }); + target.focus(); + } else if (targetIndex < 0 || targetIndex >= 42) { + const dayOffset = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 + : event.key === 'ArrowUp' ? -7 : 7; + moveCalendarFocus(button.dataset.calendarDate, dayOffset); + } +} + +function changeCalendarMonth(offset, day = 1) { + const [year, month] = _calendarMonth.split('-').map(Number); + const targetMonth = new Date(Date.UTC(year, month - 1 + offset, 1)).toISOString().slice(0, 7); + _calendarMonth = targetMonth; + _calendarSelectedDay = `${targetMonth}-${String(Math.min(day, daysInMonth(targetMonth))).padStart(2, '0')}`; + render(); + queueMicrotask(() => _rootEl?.querySelector(`[data-calendar-date="${_calendarSelectedDay}"]`)?.focus()); +} + +function moveCalendarFocus(date, dayOffset) { + const target = new Date(`${date}T00:00:00Z`); + target.setUTCDate(target.getUTCDate() + dayOffset); + _calendarSelectedDay = target.toISOString().slice(0, 10); + _calendarMonth = _calendarSelectedDay.slice(0, 7); + render(); + queueMicrotask(() => _rootEl?.querySelector(`[data-calendar-date="${_calendarSelectedDay}"]`)?.focus()); +} + +function daysInMonth(month) { + const [year, monthNumber] = month.split('-').map(Number); + return new Date(Date.UTC(year, monthNumber, 0)).getUTCDate(); +} + +function longDate(date) { + return new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeZone: 'UTC' }).format(new Date(`${date}T00:00:00Z`)); +} + +function localIsoDate(date = new Date()) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; +} + +function fact(term, value) { + return [h('dt', null, [term]), h('dd', { class: term === 'Record ID' ? 'mono selectable' : '' }, [value])]; +} + +function preserveSearchInput(update) { + return (event) => { + const caret = event.target.selectionStart; + update(event.target.value); + render(); + const replacement = _rootEl?.querySelector('[data-archive-search="true"]'); + replacement?.focus(); + replacement?.setSelectionRange?.(caret, caret); + }; +} + +function clearFilters() { + _search = ''; + _filterChamber = 'all'; + _filterSource = 'all'; + _filterType = 'all'; + _filterPack = 'all'; + _filterProject = 'all'; + _filterCollection = 'all'; + _favoriteOnly = false; + _errorsOnly = false; + _includeHidden = false; + _dateFrom = ''; + _dateTo = ''; + _page = 0; + render(); +} + +async function openArchiveFolder() { + try { + const path = await archiveWorkspace.openFolder(); + toast(`Opened ${path}`, 'success'); + } catch (error) { + toast(`Could not open Archive: ${error?.message || error}`, 'danger'); + } +} + +async function rebuildArchive() { + try { + await archiveWorkspace.rebuild(true); + toast('Archive index rebuilt from source files', 'success'); + } catch (error) { + toast(`Archive rebuild failed: ${error?.message || error}`, 'danger'); + } +} + +function objectValue(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function truncate(value, length) { + const text = String(value); + return text.length <= length ? text : `${text.slice(0, length - 1)}…`; +} + +function capitalize(value) { + return value ? `${value[0].toUpperCase()}${value.slice(1)}` : ''; } diff --git a/src/archive/cache.ts b/src/archive/cache.ts new file mode 100644 index 0000000..620ce57 --- /dev/null +++ b/src/archive/cache.ts @@ -0,0 +1,139 @@ +import type { CompactArchiveRecord } from './compact-index.js'; +import * as storage from '../lib/fs.js'; + +export const ARCHIVE_CACHE_PATH = 'archive/_sortilune/cache/archive-index-v1.json'; +const CACHE_NAME = 'archive-index-v1.json'; +const MAX_CACHE_RECORDS = 25_000; +const COMPACT_RECORD_KEYS = new Set([ + 'id', 'chamber', 'type', 'at', 'summary', 'path', 'filename', 'source', 'source_label', + 'pack', 'project', 'title', 'tags', 'collections', 'favorite', 'hidden', 'relation_ids', + 'relation_kinds', 'search_text', +]); +const ARCHIVE_CHAMBERS = new Set([ + 'today', 'oracle', 'decider', 'diary', 'constraint', 'canvas', 'symphony', 'beacon', 'lottery', +]); + +export interface ArchiveCacheSnapshot { + schema: 'sortilune.archive-index-cache'; + schema_version: 1; + generated_at: string; + source_count: number; + records: CompactArchiveRecord[]; +} + +interface CacheTransport { + readText(rel: string): Promise; + writeArchiveCache(name: string, bytes: Uint8Array): Promise; + clearArchiveCache(): Promise; +} + +export class ArchiveCacheRepository { + readonly #transport: CacheTransport; + + constructor(transport: CacheTransport = storage) { + this.#transport = transport; + } + + async load(): Promise { + const text = await this.#transport.readText(ARCHIVE_CACHE_PATH); + if (text == null) return null; + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + return isCacheSnapshot(value) ? cloneSnapshot(value) : null; + } + + async save(records: CompactArchiveRecord[], sourceCount: number): Promise { + const snapshot: ArchiveCacheSnapshot = { + schema: 'sortilune.archive-index-cache', + schema_version: 1, + generated_at: new Date().toISOString(), + source_count: sourceCount, + records: records.map(stripPrivateFields), + }; + const bytes = new TextEncoder().encode(`${JSON.stringify(snapshot)}\n`); + await this.#transport.writeArchiveCache(CACHE_NAME, bytes); + return cloneSnapshot(snapshot); + } + + clear(): Promise { + return this.#transport.clearArchiveCache(); + } +} + +function isCacheSnapshot(value: unknown): value is ArchiveCacheSnapshot { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const candidate = value as Partial; + return candidate.schema === 'sortilune.archive-index-cache' + && candidate.schema_version === 1 + && boundedString(candidate.generated_at, 64) + && !Number.isNaN(Date.parse(candidate.generated_at as string)) + && Number.isInteger(candidate.source_count) + && (candidate.source_count ?? -1) >= 0 + && (candidate.source_count ?? Infinity) <= MAX_CACHE_RECORDS + && Array.isArray(candidate.records) + && candidate.records.length <= MAX_CACHE_RECORDS + && candidate.records.every(isCompactRecord); +} + +function isCompactRecord(value: unknown): value is CompactArchiveRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + const keys = Object.keys(record); + return keys.every((key) => COMPACT_RECORD_KEYS.has(key)) + && boundedString(record.id, 128, 1) + && boundedString(record.chamber, 32, 1) + && ARCHIVE_CHAMBERS.has(record.chamber as string) + && boundedString(record.type, 96, 1) + && boundedString(record.at, 64, 1) + && !Number.isNaN(Date.parse(record.at as string)) + && boundedString(record.summary, 500, 1) + && boundedString(record.path, 520, 1) + && /^archive\/(?:today|oracle|decider|diary|constraint|canvas|symphony|beacon|lottery)\/[^/]+\.(?:json|md)$/u.test(record.path as string) + && optionalString(record.filename, 260) + && optionalString(record.source, 128) + && optionalString(record.source_label, 240) + && optionalString(record.pack, 240) + && optionalString(record.project, 128) + && optionalString(record.title, 240) + && optionalString(record.search_text, 128_000) + && optionalBoolean(record.favorite) + && optionalBoolean(record.hidden) + && stringArray(record.tags, 32, 64) + && stringArray(record.collections, 32, 120) + && stringArray(record.relation_ids, 256, 128) + && stringArray(record.relation_kinds, 256, 96) + && record.private_search_text === undefined; +} + +function boundedString(value: unknown, maximum: number, minimum = 0): value is string { + return typeof value === 'string' && value.length >= minimum && value.length <= maximum; +} + +function optionalString(value: unknown, maximum: number): boolean { + return value === undefined || boundedString(value, maximum); +} + +function optionalBoolean(value: unknown): boolean { + return value === undefined || typeof value === 'boolean'; +} + +function stringArray(value: unknown, maximum: number, length: number): boolean { + return value === undefined || (Array.isArray(value) + && value.length <= maximum + && value.every((entry) => typeof entry === 'string' && entry.length <= length)); +} + +function stripPrivateFields(record: CompactArchiveRecord): CompactArchiveRecord { + const { private_search_text: _private, ...safe } = record; + return structuredClone(safe); +} + +function cloneSnapshot(snapshot: ArchiveCacheSnapshot): ArchiveCacheSnapshot { + return structuredClone(snapshot); +} + +export const archiveCacheRepository = new ArchiveCacheRepository(); diff --git a/src/archive/compact-index.ts b/src/archive/compact-index.ts new file mode 100644 index 0000000..89f66d8 --- /dev/null +++ b/src/archive/compact-index.ts @@ -0,0 +1,218 @@ +import type { ArchiveItem } from './repository.js'; + +const WORD_SEGMENTER = typeof Intl.Segmenter === 'function' + ? new Intl.Segmenter('und', { granularity: 'word' }) + : null; + +export interface CompactArchiveRecord { + id: string; + chamber: string; + type: string; + at: string; + summary: string; + path: string; + filename?: string; + source?: string; + source_label?: string; + pack?: string; + project?: string; + title?: string; + tags?: string[]; + collections?: string[]; + favorite?: boolean; + hidden?: boolean; + relation_ids?: string[]; + relation_kinds?: string[]; + search_text?: string; + /** In-memory-only private search material. Never returned by snapshot(). */ + private_search_text?: string; +} + +export class CompactArchiveIndex { + readonly #records = new Map(); + readonly #pathsById = new Map>(); + readonly #postings = new Map>(); + + constructor(records: CompactArchiveRecord[] = []) { + for (const record of records) this.upsert(record); + } + + static fromItems(items: ArchiveItem[]): CompactArchiveIndex { + return new CompactArchiveIndex(items.flatMap((item) => { + if (item.status !== 'ok') return []; + const record: CompactArchiveRecord = { + id: item.id, + chamber: item.chamber, + type: item.type, + at: item.at, + summary: item.summary, + path: item.path, + filename: item.filename, + relation_ids: item.relations.map((relation) => relation.target_id), + relation_kinds: item.relations.map((relation) => relation.kind), + }; + const source = item.provenance[0]?.source; + if (source) { + record.source = source.id; + record.source_label = source.label; + } + return [record]; + })); + } + + upsert(record: CompactArchiveRecord): void { + if (!record.path) throw new TypeError('compact archive records require a path'); + const previous = this.#records.get(record.path); + if (previous) this.#removeRecord(previous); + const stored = freezeRecord(record); + this.#records.set(stored.path, stored); + const idPaths = this.#pathsById.get(stored.id) ?? new Set(); + idPaths.add(stored.path); + this.#pathsById.set(stored.id, idPaths); + for (const token of recordTokens(stored)) { + const paths = this.#postings.get(token) ?? new Set(); + paths.add(stored.path); + this.#postings.set(token, paths); + } + } + + remove(identifier: string): boolean { + if (this.#records.has(identifier)) return this.removePath(identifier); + return this.removeId(identifier) > 0; + } + + removePath(path: string): boolean { + const record = this.#records.get(path); + if (!record) return false; + this.#removeRecord(record); + return true; + } + + removeId(id: string): number { + const paths = [...(this.#pathsById.get(id) ?? [])]; + for (const path of paths) this.removePath(path); + return paths.length; + } + + getByPath(path: string): CompactArchiveRecord | undefined { + return this.#records.get(path); + } + + getById(id: string): CompactArchiveRecord[] { + return [...(this.#pathsById.get(id) ?? [])] + .map((path) => this.#records.get(path)) + .filter((record): record is CompactArchiveRecord => Boolean(record)); + } + + search(query: string, chamber = 'all'): CompactArchiveRecord[] { + const tokens = tokenize(query); + let paths: Set | null = null; + for (const token of tokens) { + const posting = this.#postings.get(token) ?? new Set(); + paths = paths === null ? new Set(posting) : intersection(paths, posting); + if (paths.size === 0) return []; + } + const candidates = paths === null + ? this.#records.values() + : [...paths].map((path) => this.#records.get(path)!).filter(Boolean); + return [...candidates] + .filter((record) => chamber === 'all' || record.chamber === chamber) + .sort(compareRecords); + } + + snapshot(): CompactArchiveRecord[] { + return [...this.#records.values()].map(compactSnapshotRecord); + } + + get size(): number { + return this.#records.size; + } + + #removeRecord(record: CompactArchiveRecord): void { + for (const token of recordTokens(record)) { + const paths = this.#postings.get(token); + paths?.delete(record.path); + if (paths?.size === 0) this.#postings.delete(token); + } + this.#records.delete(record.path); + const idPaths = this.#pathsById.get(record.id); + idPaths?.delete(record.path); + if (idPaths?.size === 0) this.#pathsById.delete(record.id); + } +} + +export function tokenize(value: string): string[] { + const normalized = value.normalize('NFKC').toLowerCase(); + // ASCII archive metadata is overwhelmingly common and its alphanumeric + // boundaries are unambiguous. Keep that hot path allocation-light while + // using the standards-based segmenter for every non-ASCII query/record. + if (/^[\x00-\x7f]*$/u.test(normalized)) return normalized.match(/[a-z0-9]+/gu) ?? []; + if (WORD_SEGMENTER) { + return [...WORD_SEGMENTER.segment(normalized)] + .filter((part) => part.isWordLike) + .map((part) => part.segment); + } + return normalized.match(/[\p{L}\p{N}]+/gu) ?? []; +} + +function recordTokens(record: CompactArchiveRecord): Set { + return new Set(tokenize([ + record.summary, + record.type, + record.chamber, + record.path, + record.filename, + record.source, + record.source_label, + record.pack, + record.project, + record.title, + ...(record.tags ?? []), + ...(record.collections ?? []), + ...(record.relation_ids ?? []), + ...(record.relation_kinds ?? []), + record.search_text, + record.private_search_text, + ].filter(Boolean).join(' '))); +} + +function freezeRecord(record: CompactArchiveRecord): CompactArchiveRecord { + return Object.freeze({ + ...record, + ...(record.tags ? { tags: Object.freeze([...record.tags]) as unknown as string[] } : {}), + ...(record.collections ? { collections: Object.freeze([...record.collections]) as unknown as string[] } : {}), + ...(record.relation_ids ? { relation_ids: Object.freeze([...record.relation_ids]) as unknown as string[] } : {}), + ...(record.relation_kinds ? { relation_kinds: Object.freeze([...record.relation_kinds]) as unknown as string[] } : {}), + }); +} + +function compactSnapshotRecord(record: CompactArchiveRecord): CompactArchiveRecord { + const snapshot: CompactArchiveRecord = { + id: record.id, + chamber: record.chamber, + type: record.type, + at: record.at, + summary: record.summary, + path: record.path, + }; + for (const key of ['source', 'source_label', 'pack', 'project', 'title', 'search_text'] as const) { + if (record[key]) snapshot[key] = record[key]; + } + for (const key of ['tags', 'collections', 'relation_ids', 'relation_kinds'] as const) { + const value = record[key]; + if (value?.length) snapshot[key] = [...value]; + } + if (record.favorite) snapshot.favorite = true; + if (record.hidden) snapshot.hidden = true; + return snapshot; +} + +function compareRecords(left: CompactArchiveRecord, right: CompactArchiveRecord): number { + return right.at.localeCompare(left.at) || left.path.localeCompare(right.path); +} + +function intersection(left: Set, right: Set): Set { + const output = new Set(); + for (const value of left) if (right.has(value)) output.add(value); + return output; +} diff --git a/src/archive/legacy.js b/src/archive/legacy.js new file mode 100644 index 0000000..9f1a545 --- /dev/null +++ b/src/archive/legacy.js @@ -0,0 +1,175 @@ +import { validateArchiveRecord } from '../schemas/validate.js'; +import { isRfc3339Timestamp } from '../domain/identifiers.js'; + +/** @returns {import('../domain/archive-record.js').ArchiveRecord} */ +export function normalizeLegacyJson(chamber, filename, path, data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) throw new TypeError('legacy JSON record must be an object'); + if (data.schema === 'sortilune.archive-record') { + if (!validateArchiveRecord(data)) { + const detail = validateArchiveRecord.errors?.[0]; + throw new TypeError(`versioned archive record is invalid at ${detail?.instancePath || '/'}: ${detail?.message || 'schema validation failed'}`); + } + return data; + } + const createdAt = normalizeLegacyTimestamp(data.drawn_at || data.sealed_at || data.started_at || extractDate(filename)); + const type = safeIdentifier(data.type, chamber); + const record = { + schema: 'sortilune.archive-record', + schema_version: 1, + id: legacyStableId(path), + chamber, + type, + created_at: createdAt, + summary: normalizedSummary(data.human_summary, `${type} entry`), + payload: data, + provenance: legacyProvenance(data, createdAt), + relations: [], + assets: [], + }; + assertNormalizedRecord(record); + return record; +} + +/** @returns {import('../domain/archive-record.js').ArchiveRecord} */ +export function normalizeLegacyDiary(filename, path, text) { + if (typeof text !== 'string') throw new TypeError('legacy Diary record must be text'); + const { meta, body } = parseFrontmatter(text); + const createdAt = normalizeLegacyTimestamp(meta.drawn_at || meta.date || extractDate(filename)); + const payload = { ...meta, body, type: 'diary-entry' }; + const record = { + schema: 'sortilune.archive-record', + schema_version: 1, + id: legacyStableId(path), + chamber: 'diary', + type: 'diary-entry', + created_at: createdAt, + summary: normalizedSummary(null, meta.question + ? `Diary: "${truncate(meta.question, 60)}"` + : `Diary entry for ${meta.date || filename.replace('.md', '')}`), + payload, + provenance: legacyProvenance(meta, createdAt), + relations: [], + assets: [], + }; + assertNormalizedRecord(record); + return record; +} + +export function parseFrontmatter(text) { + const normalized = text.replaceAll('\r\n', '\n'); + const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!match) return { meta: {}, body: text }; + const meta = {}; + let section = null; + for (const line of match[1].split('\n')) { + const nested = line.match(/^\s{2}(\w+):\s*(.*)$/); + if (nested && section) { + meta[section][nested[1]] = parseFrontmatterValue(nested[2]); + continue; + } + const field = line.match(/^(\w+):\s*(.*)$/); + if (!field) continue; + if (!field[2].trim()) { + section = field[1]; + meta[section] = {}; + continue; + } + section = null; + meta[field[1]] = parseFrontmatterValue(field[2]); + } + return { meta, body: match[2].trim() }; +} + +function parseFrontmatterValue(raw) { + let value = raw.trim(); + if (value.startsWith('"') && value.endsWith('"')) { + try { value = JSON.parse(value); } catch { /* preserve malformed legacy scalar text */ } + } + return value; +} + +function truncate(value, length) { + const text = String(value); + return text.length <= length ? text : `${text.slice(0, length - 1)}…`; +} + +function extractDate(filename) { + const timestamp = String(filename).match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d+Z)/); + if (timestamp) return timestamp[1].replace(/-(\d{2})-(\d{2})-(\d+)Z/, ':$1:$2.$3Z'); + const date = String(filename).match(/^(\d{4}-\d{2}-\d{2})/); + return date ? `${date[1]}T00:00:00Z` : ''; +} + +function normalizeLegacyTimestamp(value) { + if (typeof value !== 'string' || !value) return '1970-01-01T00:00:00Z'; + const candidate = /^\d{4}-\d{2}-\d{2}$/u.test(value) ? `${value}T00:00:00Z` : value; + return isRfc3339Timestamp(candidate) ? candidate : '1970-01-01T00:00:00Z'; +} + +function legacyProvenance(data, createdAt) { + const candidates = [data.provenance, data.cards?.[0]?.provenance, data.prompt?.provenance].filter(Boolean); + return candidates.flatMap((candidate) => Array.isArray(candidate) ? candidate : [candidate]) + .filter((candidate) => candidate && typeof candidate === 'object' && !Array.isArray(candidate)) + .map((candidate) => { + if (candidate.source && typeof candidate.source === 'object') return candidate; + const sourceId = safeIdentifier(candidate.source_id, 'imported'); + const fetchedAt = normalizeLegacyTimestamp(candidate.fetched_at || createdAt); + return { + source: { + id: sourceId, + label: normalizedSummary(candidate.source_name, sourceId).slice(0, 200), + kind: sourceKind(sourceId), + }, + fetched_at: fetchedAt, + raw: typeof candidate.raw === 'string' ? candidate.raw : JSON.stringify(candidate.raw ?? ''), + signature: typeof candidate.signature === 'string' ? candidate.signature : null, + details: { + description: typeof candidate.description === 'string' ? candidate.description : '', + extra: candidate.extra ?? null, + }, + }; + }); +} + +function safeIdentifier(value, fallback) { + return typeof value === 'string' && /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(value) && value.length <= 96 + ? value + : fallback; +} + +function normalizedSummary(value, fallback) { + const text = typeof value === 'string' && value.trim() ? value.trim() : String(fallback); + return text.slice(0, 2_000) || 'Legacy archive entry'; +} + +function sourceKind(sourceId) { + if (sourceId === 'nist-beacon') return 'public-randomness'; + if (sourceId.includes('quantum')) return 'quantum'; + if (sourceId.includes('random-org')) return 'atmospheric'; + if (sourceId.includes('seismic')) return 'seismic'; + if (sourceId.includes('meteo') || sourceId.includes('weather')) return 'weather'; + if (sourceId === 'system') return 'system'; + if (sourceId.includes('fixture')) return 'fixture'; + return 'imported'; +} + +function assertNormalizedRecord(record) { + if (!validateArchiveRecord(record)) { + const detail = validateArchiveRecord.errors?.[0]; + throw new TypeError(`legacy record could not normalize at ${detail?.instancePath || '/'}: ${detail?.message || 'schema validation failed'}`); + } +} + +/** Stable, deterministic UUID-shaped identity for immutable legacy paths. */ +function legacyStableId(path) { + const text = String(path); + const words = [0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35]; + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + for (let word = 0; word < words.length; word++) { + words[word] = Math.imul(words[word] ^ (code + word * 131), 0x01000193) >>> 0; + } + } + const hex = words.map((word) => word.toString(16).padStart(8, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} diff --git a/src/archive/repository.ts b/src/archive/repository.ts new file mode 100644 index 0000000..bd56db4 --- /dev/null +++ b/src/archive/repository.ts @@ -0,0 +1,440 @@ +import type { + AlgorithmReference, + ArchiveRecord, + AssetMediaType, + AssetReference, + JsonValue, + PackReference, + Provenance, + Relation, +} from '../domain/archive-record.js'; +import { createStableId, nowRfc3339, parseRfc3339Timestamp, parseSha256Hex, stableIdFromText } from '../domain/identifiers.js'; +import { validateArchiveRecord } from '../schemas/validate.js'; +import * as storage from '../lib/fs.js'; +import { normalizeLegacyDiary, normalizeLegacyJson } from './legacy.js'; + +export const ARCHIVE_CHAMBERS = [ + 'today', + 'oracle', + 'decider', + 'diary', + 'constraint', + 'canvas', + 'symphony', + 'beacon', + 'lottery', + 'practice', +] as const; + +export type ArchiveChamber = typeof ARCHIVE_CHAMBERS[number]; + +interface ArchiveTransport { + writeBatch(files: Array<{ rel: string; bytes: Uint8Array }>): Promise; + writeBytes(rel: string, bytes: Uint8Array): Promise; + readText(rel: string): Promise; + listDir(rel: string): Promise>; + isAvailable(): Promise; + revealArchive(): Promise; + exists?(rel: string): Promise; +} + +export interface ArchiveAssetDraft { + role: string; + mediaType: AssetMediaType; + extension: 'svg' | 'png' | 'json' | 'md' | 'wav'; + content: string | Uint8Array; + width?: number; + height?: number; +} + +export interface ArchiveRecordDraft { + chamber: ArchiveChamber; + type: string; + createdAt?: string; + summary: string; + payload: unknown; + provenance?: unknown | unknown[]; + relations?: Relation[]; + assets?: ArchiveAssetDraft[]; + /** Stable repository-owned overwrite slot, used only for mutable workflows such as Diary autosave. */ + logicalKey?: string; + /** Reject an existing logical slot instead of replacing it. */ + immutableLogicalKey?: boolean; + algorithm?: AlgorithmReference; + /** Immutable imported-content identity and selected snapshot. */ + pack?: PackReference; +} + +export interface SavedArchiveRecord { + path: string; + record: ArchiveRecord; +} + +export type ArchiveSavedListener = ( + saved: SavedArchiveRecord, + draft: Readonly, +) => void | Promise; + +export interface ArchiveReadError { + status: 'error'; + chamber: string; + filename: string; + path: string; + summary: string; + type: 'archive-error'; + at: string; + error: string; + data: null; +} + +export interface NormalizedArchiveItem { + status: 'ok'; + legacy: boolean; + filename: string; + path: string; + id: string; + chamber: string; + type: string; + created_at: string; + at: string; + summary: string; + payload: JsonValue; + provenance: Provenance[]; + relations: Relation[]; + assets: AssetReference[]; + data: JsonValue; + record: ArchiveRecord; +} + +export type ArchiveItem = NormalizedArchiveItem | ArchiveReadError; + +export interface ArchiveDescriptor { + chamber: ArchiveChamber; + name: string; + path: string; +} + +const textEncoder = new TextEncoder(); +const MEDIA_TYPE_BY_EXTENSION: Record = { + svg: 'image/svg+xml', + png: 'image/png', + json: 'application/json', + md: 'text/markdown', + wav: 'audio/wav', +}; + +export class ArchiveRepository { + readonly #transport: ArchiveTransport; + readonly #savedListeners = new Set(); + + constructor(transport: ArchiveTransport = storage) { + this.#transport = transport; + } + + async save(draft: ArchiveRecordDraft): Promise { + assertIdentifier(draft.chamber, 'chamber'); + assertIdentifier(draft.type, 'record type'); + const summary = String(draft.summary ?? '').trim(); + if (!summary || summary.length > 2_000) throw new TypeError('archive summary must contain 1 to 2,000 characters'); + + if (draft.logicalKey !== undefined && (!draft.logicalKey || draft.logicalKey.length > 256)) { + throw new TypeError('archive logical key must contain 1 to 256 characters'); + } + if (draft.logicalKey && (draft.assets?.length ?? 0) > 0) { + throw new TypeError('mutable archive slots cannot contain assets'); + } + if (draft.immutableLogicalKey && !draft.logicalKey) { + throw new TypeError('immutable logical slots require a logical key'); + } + if ((draft.assets?.length ?? 0) > 63) { + throw new TypeError('an archive record can write at most 63 assets in one transaction'); + } + const id = draft.logicalKey + ? await stableIdFromText(`${draft.chamber}\0${draft.type}\0${draft.logicalKey}`) + : createStableId(); + const createdAt = draft.createdAt ? parseRfc3339Timestamp(draft.createdAt) : nowRfc3339(); + const stamp = fileSafeTimestamp(createdAt); + const prefix = `archive/${draft.chamber}/${stamp}__${draft.type}_${id}`; + const assetFiles: Array<{ rel: string; bytes: Uint8Array }> = []; + const assets: AssetReference[] = []; + + for (const asset of draft.assets ?? []) { + assertIdentifier(asset.role, 'asset role'); + if (MEDIA_TYPE_BY_EXTENSION[asset.extension] !== asset.mediaType) { + throw new TypeError(`asset extension .${asset.extension} does not match ${asset.mediaType}`); + } + const assetId = createStableId(); + const bytes = typeof asset.content === 'string' ? textEncoder.encode(asset.content) : asset.content; + if (!(bytes instanceof Uint8Array)) throw new TypeError('asset content must be text or bytes'); + const path = `${prefix}__${asset.role}_${assetId}.${asset.extension}`; + const reference: AssetReference = { + id: assetId, + role: asset.role, + media_type: asset.mediaType, + path, + sha256: parseSha256Hex(await sha256(bytes)), + bytes: bytes.byteLength, + }; + if (asset.width !== undefined) reference.width = asset.width; + if (asset.height !== undefined) reference.height = asset.height; + assets.push(reference); + assetFiles.push({ rel: path, bytes }); + } + + const record: ArchiveRecord = { + schema: 'sortilune.archive-record', + schema_version: 1, + id, + chamber: draft.chamber, + type: draft.type, + created_at: createdAt, + summary, + payload: toJsonValue(draft.payload), + provenance: normalizeProvenance(draft.provenance), + relations: toJsonValue(draft.relations ?? []) as unknown as Relation[], + assets, + }; + if (draft.algorithm) record.algorithm = toJsonValue(draft.algorithm) as unknown as AlgorithmReference; + if (draft.pack) record.pack = toJsonValue(draft.pack) as unknown as PackReference; + if (!validateArchiveRecord(record)) { + throw new TypeError(`archive record is invalid: ${formatValidationErrors(validateArchiveRecord.errors)}`); + } + + const recordPath = `${prefix}.json`; + const recordBytes = textEncoder.encode(`${JSON.stringify(record, null, 2)}\n`); + if (draft.logicalKey && draft.immutableLogicalKey) { + await this.#transport.writeBatch([{ rel: recordPath, bytes: recordBytes }]); + } else if (draft.logicalKey) await this.#transport.writeBytes(recordPath, recordBytes); + else await this.#transport.writeBatch([...assetFiles, { rel: recordPath, bytes: recordBytes }]); + const saved = { path: recordPath, record }; + for (const listener of this.#savedListeners) await listener(saved, draft); + return saved; + } + + subscribeSaved(listener: ArchiveSavedListener): () => void { + this.#savedListeners.add(listener); + return () => this.#savedListeners.delete(listener); + } + + async list(): Promise { + return this.#listChambers(ARCHIVE_CHAMBERS); + } + + async listChamber(chamber: ArchiveChamber): Promise { + if (!ARCHIVE_CHAMBERS.includes(chamber)) throw new TypeError(`unsupported archive chamber: ${chamber}`); + return this.#listChambers([chamber]); + } + + async listDescriptors(chambers: readonly ArchiveChamber[] = ARCHIVE_CHAMBERS): Promise { + if (!(await this.#transport.isAvailable())) return []; + const descriptors: ArchiveDescriptor[] = []; + for (const chamber of chambers) { + if (!ARCHIVE_CHAMBERS.includes(chamber)) throw new TypeError(`unsupported archive chamber: ${chamber}`); + const entries = await this.#transport.listDir(`archive/${chamber}`); + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (entry.isDirectory || (!entry.name.endsWith('.json') && !(chamber === 'diary' && entry.name.endsWith('.md')))) continue; + descriptors.push({ chamber, name: entry.name, path: `archive/${chamber}/${entry.name}` }); + } + } + return descriptors.sort((left, right) => left.path.localeCompare(right.path)); + } + + async readPath(path: string): Promise { + const match = /^archive\/([a-z][a-z0-9._-]*)\/([^/]+\.(?:json|md))$/u.exec(path); + if (!match) throw new TypeError('archive record path is invalid'); + const chamber = match[1] as ArchiveChamber; + const name = match[2]!; + if (!ARCHIVE_CHAMBERS.includes(chamber) || (name.endsWith('.md') && chamber !== 'diary')) { + throw new TypeError('archive record path is not supported'); + } + const text = await this.#transport.readText(path); + if (text == null) return null; + try { + if (name.endsWith('.md')) return archiveItem(normalizeLegacyDiary(name, path, text), name, path, true); + let data: unknown; + try { + data = JSON.parse(text); + } catch (error) { + throw new Error(`could not parse JSON: ${error instanceof Error ? error.message : String(error)}`); + } + const legacy = !data || typeof data !== 'object' || Array.isArray(data) + || (data as Record).schema !== 'sortilune.archive-record'; + return archiveItem(normalizeLegacyJson(chamber, name, path, data), name, path, legacy); + } catch (error) { + return recoverableError(chamber, name, path, error); + } + } + + async #listChambers(chambers: readonly ArchiveChamber[]): Promise { + const descriptors = await this.listDescriptors(chambers); + const items = await mapConcurrent(descriptors, 32, async ({ chamber, name, path }) => ( + await this.readPath(path) ?? recoverableError(chamber, name, path, new Error('file disappeared while the archive was loading')) + )); + return items.sort((left, right) => String(right.at).localeCompare(String(left.at))); + } + + isAvailable(): Promise { + return this.#transport.isAvailable(); + } + + reveal(): Promise { + return this.#transport.revealArchive(); + } + + async assetExists(path: string): Promise { + if (!/^archive\/[a-z][a-z0-9._-]*\/[^/]+\.(?:svg|png|json|md|wav)$/u.test(path)) return false; + return this.#transport.exists ? this.#transport.exists(path) : null; + } +} + +function archiveItem( + record: ArchiveRecord, + filename: string, + path: string, + legacy: boolean, +): NormalizedArchiveItem { + return { + status: 'ok', + legacy, + filename, + path, + id: record.id, + chamber: record.chamber, + type: record.type, + created_at: record.created_at, + at: record.created_at, + summary: record.summary, + payload: record.payload, + provenance: record.provenance, + relations: record.relations, + assets: record.assets, + data: record.payload, + record, + }; +} + +async function mapConcurrent( + values: T[], + concurrency: number, + task: (value: T) => Promise, +): Promise { + const results = new Array(values.length); + let cursor = 0; + const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (cursor < values.length) { + const index = cursor++; + results[index] = await task(values[index]!); + } + }); + await Promise.all(workers); + return results; +} + +function recoverableError(chamber: string, filename: string, path: string, error: unknown): ArchiveReadError { + return { + status: 'error', + chamber, + filename, + path, + summary: `Could not read ${filename}`, + type: 'archive-error', + at: timestampFromFilename(filename), + error: error instanceof Error ? error.message : String(error), + data: null, + }; +} + +function timestampFromFilename(filename: string): string { + const date = filename.match(/^(\d{4}-\d{2}-\d{2})/u)?.[1]; + return date ? `${date}T00:00:00Z` : ''; +} + +function assertIdentifier(value: string, label: string): void { + if (!/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(value) || value.length > 96) { + throw new TypeError(`${label} must be a lowercase identifier`); + } +} + +function fileSafeTimestamp(value: string): string { + return value.replaceAll(':', '-').replace('.', '-'); +} + +function toJsonValue(value: unknown): JsonValue { + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new TypeError('archive payload must be JSON-serializable'); + const parsed: unknown = JSON.parse(serialized); + assertJsonValue(parsed, 0); + return parsed as JsonValue; +} + +function assertJsonValue(value: unknown, depth: number): void { + if (depth > 64) throw new TypeError('archive payload nesting exceeds 64 levels'); + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number' && Number.isFinite(value)) return; + if (Array.isArray(value)) { + if (value.length > 10_000) throw new TypeError('archive payload array exceeds 10,000 items'); + for (const item of value) assertJsonValue(item, depth + 1); + return; + } + if (typeof value === 'object') { + const entries = Object.entries(value); + if (entries.length > 1_000) throw new TypeError('archive payload object exceeds 1,000 properties'); + for (const [key, child] of entries) { + if (key.length > 128) throw new TypeError('archive payload property name exceeds 128 characters'); + assertJsonValue(child, depth + 1); + } + return; + } + throw new TypeError('archive payload contains a non-JSON value'); +} + +function normalizeProvenance(input: unknown | unknown[] | undefined): Provenance[] { + if (input == null) return []; + const values = Array.isArray(input) ? input : [input]; + return values.map((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new TypeError('provenance must be an object'); + const item = value as Record; + if (item.source && typeof item.source === 'object') return toJsonValue(item) as unknown as Provenance; + const sourceId = typeof item.source_id === 'string' ? item.source_id : 'imported'; + const sourceLabel = typeof item.source_name === 'string' && item.source_name.trim() ? item.source_name : sourceId; + const fetchedAt = typeof item.fetched_at === 'string' ? parseRfc3339Timestamp(item.fetched_at) : nowRfc3339(); + const details = toJsonValue({ + description: typeof item.description === 'string' ? item.description : '', + extra: item.extra ?? null, + }); + return { + source: { id: sourceId, label: sourceLabel, kind: sourceKind(sourceId) }, + fetched_at: fetchedAt, + raw: typeof item.raw === 'string' ? item.raw : JSON.stringify(item.raw ?? ''), + signature: typeof item.signature === 'string' ? item.signature : null, + details, + }; + }); +} + +function sourceKind(sourceId: string): Provenance['source']['kind'] { + if (sourceId === 'nist-beacon') return 'public-randomness'; + if (sourceId.includes('quantum')) return 'quantum'; + if (sourceId.includes('random-org')) return 'atmospheric'; + if (sourceId.includes('seismic')) return 'seismic'; + if (sourceId.includes('meteo') || sourceId.includes('weather')) return 'weather'; + if (sourceId === 'system') return 'system'; + if (sourceId.includes('fixture')) return 'fixture'; + return 'imported'; +} + +async function sha256(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', Uint8Array.from(bytes).buffer); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function formatValidationErrors(errors: unknown): string { + if (!Array.isArray(errors)) return 'unknown schema error'; + return errors.slice(0, 4).map((error) => { + const item = error as { instancePath?: string; message?: string }; + return `${item.instancePath || '/'} ${item.message || 'is invalid'}`; + }).join('; '); +} + +export const archiveRepository = new ArchiveRepository(); diff --git a/src/archive/thumbnail.ts b/src/archive/thumbnail.ts new file mode 100644 index 0000000..76f40fb --- /dev/null +++ b/src/archive/thumbnail.ts @@ -0,0 +1,34 @@ +import type { NormalizedArchiveItem } from './repository.js'; + +export interface ArchiveThumbnail { + hue: number; + secondaryHue: number; + angle: number; + orbit: number; + mark: string; + label: string; +} + +const MARKS = ['◐', '◇', '✦', '⌁', '△', '○', '⋮', '◫', '✧']; + +export function archiveThumbnail(item: Pick): ArchiveThumbnail { + const hash = fnv1a(`${item.id}\0${item.chamber}\0${item.type}`); + const hue = hash % 360; + return Object.freeze({ + hue, + secondaryHue: (hue + 70 + ((hash >>> 8) % 80)) % 360, + angle: (hash >>> 12) % 360, + orbit: 28 + ((hash >>> 18) % 28), + mark: MARKS[hash % MARKS.length]!, + label: `${item.chamber} ${item.type} deterministic preview`, + }); +} + +function fnv1a(value: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} diff --git a/src/archive/workspace.ts b/src/archive/workspace.ts new file mode 100644 index 0000000..c1ad15b --- /dev/null +++ b/src/archive/workspace.ts @@ -0,0 +1,423 @@ +import type { ArchiveAnnotation, ArchiveAnnotationStore, ArchiveCollection } from '../domain/archive-annotations.js'; +import type { Relation } from '../domain/archive-record.js'; +import * as storage from '../lib/fs.js'; +import { + archiveAnnotationRepository, + emptyAnnotation, + type ArchiveAnnotationPatch, + type ArchiveAnnotationRepository, +} from './annotations.js'; +import { archiveCacheRepository, type ArchiveCacheRepository } from './cache.js'; +import { CompactArchiveIndex, type CompactArchiveRecord } from './compact-index.js'; +import { archiveRepository, type ArchiveItem, type ArchiveRepository, type NormalizedArchiveItem } from './repository.js'; + +export type ArchiveWorkspaceStatus = 'idle' | 'loading' | 'ready' | 'degraded' | 'error'; + +export interface ArchiveWorkspaceHealth { + status: ArchiveWorkspaceStatus; + watcher: 'starting' | 'active' | 'unavailable' | 'stopped'; + cache: 'unknown' | 'hit' | 'miss' | 'rebuilt' | 'error'; + last_rebuild_at: string | null; + source_count: number; + indexed_count: number; + error_count: number; + annotation_error: string | null; + last_error: string | null; + generation: number; +} + +export interface ArchiveRelationView { + direction: 'outgoing' | 'incoming'; + relation: Relation; + source: NormalizedArchiveItem; + targets: NormalizedArchiveItem[]; + dangling: boolean; +} + +interface WorkspaceOptions { + repository?: ArchiveRepository; + annotations?: ArchiveAnnotationRepository; + cache?: ArchiveCacheRepository; + watcher?: typeof storage.watchArchive; + searchDiaryBody?: boolean; +} + +type WorkspaceListener = (workspace: ArchiveWorkspace) => void; + +export class ArchiveWorkspace { + readonly #repository: ArchiveRepository; + readonly #annotationRepository: ArchiveAnnotationRepository; + readonly #cacheRepository: ArchiveCacheRepository; + readonly #watcher: typeof storage.watchArchive; + readonly #listeners = new Set(); + readonly #items = new Map(); + #annotations: ArchiveAnnotationStore | null = null; + #index = new CompactArchiveIndex(); + #unwatch: (() => void) | null = null; + #refreshTail: Promise = Promise.resolve(); + #searchDiaryBody: boolean; + #health: ArchiveWorkspaceHealth = { + status: 'idle', + watcher: 'stopped', + cache: 'unknown', + last_rebuild_at: null, + source_count: 0, + indexed_count: 0, + error_count: 0, + annotation_error: null, + last_error: null, + generation: 0, + }; + + constructor(options: WorkspaceOptions = {}) { + this.#repository = options.repository ?? archiveRepository; + this.#annotationRepository = options.annotations ?? archiveAnnotationRepository; + this.#cacheRepository = options.cache ?? archiveCacheRepository; + this.#watcher = options.watcher ?? storage.watchArchive; + this.#searchDiaryBody = options.searchDiaryBody ?? false; + } + + get health(): Readonly { + return Object.freeze({ ...this.#health }); + } + + get items(): ArchiveItem[] { + return [...this.#items.values()].sort(compareItems); + } + + get annotations(): ArchiveAnnotationStore | null { + return this.#annotations ? structuredClone(this.#annotations) : null; + } + + subscribe(listener: WorkspaceListener): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + async load(): Promise { + if (this.#health.status === 'loading') return; + this.#setHealth({ status: 'loading', watcher: 'starting', last_error: null }); + try { + const cached = await this.#cacheRepository.load(); + if (cached) { + this.#index = new CompactArchiveIndex(cached.records); + this.#setHealth({ cache: 'hit', indexed_count: this.#index.size }); + } else { + this.#setHealth({ cache: 'miss' }); + } + await this.#loadAnnotations(); + await this.rebuild(false); + await this.#startWatcher(); + } catch (error) { + this.#setHealth({ status: 'error', watcher: 'unavailable', last_error: message(error) }); + } + } + + async rebuild(clearCache = true): Promise { + if (clearCache) { + try { + await this.#cacheRepository.clear(); + if (this.#health.last_error?.startsWith('Could not clear Archive cache:')) { + this.#setHealth({ last_error: null }); + } + } catch (error) { + this.#setHealth({ cache: 'error', last_error: `Could not clear Archive cache: ${message(error)}` }); + } + } + const items = await this.#repository.list(); + this.#items.clear(); + for (const item of items) this.#items.set(item.path, item); + this.#rebuildIndex(); + const timestamp = new Date().toISOString(); + this.#setHealth({ + status: this.#degradedStatus(), + cache: 'rebuilt', + last_rebuild_at: timestamp, + source_count: this.#items.size, + indexed_count: this.#index.size, + error_count: items.filter((item) => item.status === 'error').length, + generation: this.#health.generation + 1, + }); + await this.#persistCache(); + } + + async applyPaths(paths: string[]): Promise { + return this.#enqueueRefresh(async () => { + const unique = [...new Set(paths)]; + if (unique.some((path) => path === 'archive')) { + await this.rebuild(false); + return; + } + if (unique.includes('archive/_sortilune/annotations.json')) await this.#loadAnnotations(); + let sourceChanged = false; + let assetChanged = false; + for (const path of unique) { + if (path.startsWith('archive/_sortilune/')) continue; + if (!isRecordPath(path)) { + if (/^archive\/[^/]+\/[^/]+\.(?:svg|png|wav)$/u.test(path)) assetChanged = true; + continue; + } + const previous = this.#items.get(path); + let item: ArchiveItem | null; + try { + item = await this.#repository.readPath(path); + } catch (error) { + this.#setHealth({ last_error: `Could not refresh ${path}: ${message(error)}` }); + continue; + } + this.#index.removePath(path); + if (item) { + this.#items.set(path, item); + if (item.status === 'ok') this.#index.upsert(this.#recordFor(item)); + } else if (previous) { + this.#items.delete(path); + } + sourceChanged = true; + } + if (unique.includes('archive/_sortilune/annotations.json')) this.#rebuildIndex(); + if (sourceChanged || assetChanged || unique.includes('archive/_sortilune/annotations.json')) { + this.#setHealth({ + status: this.#degradedStatus(), + source_count: this.#items.size, + indexed_count: this.#index.size, + error_count: this.items.filter((item) => item.status === 'error').length, + generation: this.#health.generation + 1, + }); + if (sourceChanged) await this.#persistCache(); + } + }); + } + + search(query = '', chamber = 'all', includeHidden = false): ArchiveItem[] { + const records = this.#index.search(query, chamber); + const matches = records + .filter((record) => includeHidden || !record.hidden) + .map((record) => this.#items.get(record.path)) + .filter((item): item is ArchiveItem => Boolean(item)); + if (!query.trim()) { + const errors = this.items.filter((item) => item.status === 'error' && (chamber === 'all' || item.chamber === chamber)); + matches.push(...errors); + } + return matches.sort(compareItems); + } + + annotationFor(recordId: string): ArchiveAnnotation { + return structuredClone(this.#annotations?.records[recordId] ?? emptyAnnotation()); + } + + collectionList(): ArchiveCollection[] { + return Object.values(this.#annotations?.collections ?? {}) + .map((collection) => structuredClone(collection)) + .sort((left, right) => left.name.localeCompare(right.name)); + } + + async updateAnnotation(recordId: string, patch: ArchiveAnnotationPatch, newCollectionName = ''): Promise { + if (!this.#annotations) throw new Error(this.#health.annotation_error || 'Archive annotations are unavailable'); + this.#annotations = newCollectionName.trim() + ? (await this.#annotationRepository.updateWithNewCollection(recordId, patch, newCollectionName)).store + : await this.#annotationRepository.update(recordId, patch); + this.#rebuildRecordsById(recordId); + this.#setHealth({ generation: this.#health.generation + 1 }); + await this.#persistCache(); + } + + async createCollection(name: string): Promise { + if (!this.#annotations) throw new Error(this.#health.annotation_error || 'Archive annotations are unavailable'); + const result = await this.#annotationRepository.createCollection(name); + this.#annotations = result.store; + this.#rebuildIndex(); + this.#setHealth({ generation: this.#health.generation + 1 }); + await this.#persistCache(); + return result.collection.id; + } + + setSearchDiaryBody(enabled: boolean): void { + if (this.#searchDiaryBody === enabled) return; + this.#searchDiaryBody = enabled; + this.#rebuildIndex(); + this.#setHealth({ generation: this.#health.generation + 1 }); + void this.#persistCache(); + } + + relationsFor(item: NormalizedArchiveItem): ArchiveRelationView[] { + const outgoing = item.relations.map((relation): ArchiveRelationView => { + const targets = this.#index.getById(relation.target_id) + .map((record) => this.#items.get(record.path)) + .filter((target): target is NormalizedArchiveItem => target?.status === 'ok'); + return { direction: 'outgoing', relation, source: item, targets, dangling: targets.length === 0 }; + }); + const incoming: ArchiveRelationView[] = []; + for (const candidate of this.#items.values()) { + if (candidate.status !== 'ok' || candidate.path === item.path) continue; + for (const relation of candidate.relations.filter((value) => value.target_id === item.id)) { + incoming.push({ direction: 'incoming', relation, source: candidate, targets: [item], dangling: false }); + } + } + return [...outgoing, ...incoming]; + } + + async assetExists(path: string): Promise { + return this.#repository.assetExists(path); + } + + openFolder(): Promise { + return this.#repository.reveal(); + } + + stop(): void { + this.#unwatch?.(); + this.#unwatch = null; + this.#setHealth({ watcher: 'stopped' }); + this.#listeners.clear(); + } + + async #loadAnnotations(): Promise { + try { + this.#annotations = await this.#annotationRepository.load(); + this.#setHealth({ annotation_error: null }); + } catch (error) { + this.#annotations = null; + this.#setHealth({ annotation_error: message(error), status: 'degraded' }); + } + } + + async #startWatcher(): Promise { + this.#unwatch?.(); + try { + this.#unwatch = await this.#watcher((paths) => this.applyPaths(paths)); + this.#setHealth({ watcher: 'active', status: this.#degradedStatus() }); + } catch (error) { + this.#unwatch = null; + this.#setHealth({ + watcher: 'unavailable', + status: this.#degradedStatus(), + last_error: `Automatic Archive refresh is unavailable: ${message(error)}`, + }); + } + } + + #rebuildIndex(): void { + this.#index = new CompactArchiveIndex(); + for (const item of this.#items.values()) if (item.status === 'ok') this.#index.upsert(this.#recordFor(item)); + } + + #rebuildRecordsById(id: string): void { + for (const item of this.#items.values()) { + if (item.status === 'ok' && item.id === id) this.#index.upsert(this.#recordFor(item)); + } + } + + #recordFor(item: NormalizedArchiveItem): CompactArchiveRecord { + const annotation = this.#annotations?.records[item.id]; + const collectionNames = (annotation?.collections ?? []) + .map((id) => this.#annotations?.collections[id]?.name) + .filter((name): name is string => Boolean(name)); + const source = item.provenance[0]?.source; + const project = item.relations.find((relation) => relation.kind === 'project')?.target_id; + const record: CompactArchiveRecord = { + id: item.id, + chamber: item.chamber, + type: item.type, + at: item.at, + summary: item.summary, + path: item.path, + filename: item.filename, + favorite: annotation?.favorite ?? false, + hidden: annotation?.hidden ?? false, + tags: annotation?.tags ?? [], + collections: collectionNames, + relation_ids: item.relations.map((relation) => relation.target_id), + relation_kinds: item.relations.map((relation) => relation.kind), + search_text: safePayloadSearchText(item.payload), + }; + if (source) { + record.source = source.id; + record.source_label = source.label; + } + if (item.record.pack) record.pack = `${item.record.pack.id} ${item.record.pack.version}`; + if (project) record.project = project; + if (annotation?.title) record.title = annotation.title; + if (this.#searchDiaryBody && item.type === 'diary-entry') { + const body = objectValue(item.payload).body; + if (typeof body === 'string') record.private_search_text = body; + } + return record; + } + + async #persistCache(): Promise { + try { + await this.#cacheRepository.save(this.#index.snapshot(), this.#items.size); + const lastError = this.#health.last_error?.startsWith('Could not write Archive cache:') + ? null + : this.#health.last_error; + this.#setHealth({ + cache: 'rebuilt', + last_error: lastError, + status: this.#health.annotation_error || lastError ? 'degraded' : 'ready', + }); + } catch (error) { + this.#setHealth({ + status: 'degraded', + cache: 'error', + last_error: `Could not write Archive cache: ${message(error)}`, + }); + } + } + + #degradedStatus(): ArchiveWorkspaceStatus { + return this.#health.annotation_error || this.#health.last_error ? 'degraded' : 'ready'; + } + + #setHealth(patch: Partial): void { + this.#health = { ...this.#health, ...patch }; + for (const listener of this.#listeners) listener(this); + } + + #enqueueRefresh(task: () => Promise): Promise { + const result = this.#refreshTail.then(task, task); + this.#refreshTail = result.catch(() => undefined); + return result; + } +} + +const SAFE_PAYLOAD_KEYS = new Set([ + 'answer', 'card', 'choice', 'label', 'name', 'number', 'option', 'project_name', 'prompt', + 'question', 'result', 'selected', 'symbol', 'title', 'word', +]); + +export function safePayloadSearchText(value: unknown): string { + const output: string[] = []; + const visit = (candidate: unknown, parentKey: string, depth: number): void => { + if (depth > 6 || output.length >= 256) return; + if (typeof candidate === 'string' || typeof candidate === 'number') { + if (SAFE_PAYLOAD_KEYS.has(parentKey)) output.push(String(candidate).slice(0, 500)); + return; + } + if (Array.isArray(candidate)) { + for (const child of candidate.slice(0, 100)) visit(child, parentKey, depth + 1); + return; + } + if (!candidate || typeof candidate !== 'object') return; + for (const [key, child] of Object.entries(candidate).slice(0, 100)) visit(child, key, depth + 1); + }; + visit(value, '', 0); + return output.join(' '); +} + +function isRecordPath(path: string): boolean { + return /^archive\/(?:today|oracle|decider|diary|constraint|canvas|symphony|beacon|lottery)\/[^/]+\.(?:json|md)$/u.test(path); +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function compareItems(left: ArchiveItem, right: ArchiveItem): number { + return String(right.at).localeCompare(String(left.at)) || left.path.localeCompare(right.path); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export const archiveWorkspace = new ArchiveWorkspace(); diff --git a/src/chambers/_placeholder.js b/src/chambers/_placeholder.js deleted file mode 100644 index a243693..0000000 --- a/src/chambers/_placeholder.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Shared placeholder renderer used by every chamber while its real - * implementation is still pending in the roadmap. Renders the chamber's - * name, tagline, description, the phase it lands in, and a sample of the - * design system so the foundation can be reviewed visually. - */ - -import { h, svg, clear } from '../lib/dom.js'; - -export function mountPlaceholder(rootEl, chamber) { - clear(rootEl); - const frame = h('div', { class: 'chamber-frame reveal' }, [ - h('header', { class: 'chamber-header' }, [ - h('div', null, [ - h('div', { class: 'chamber-id' }, [`Chamber · ${String(chamber.phase).padStart(2, '0')}/08`]), - h('h1', { class: 'chamber-title-big' }, [chamber.displayName]), - h('p', { class: 'chamber-tagline' }, [chamber.tagline]), - ]), - h('div', null, [ - h('span', { class: 'phase-tag' }, [`Phase ${chamber.phase}`]), - ]), - ]), - - h('section', { class: 'panel placeholder' }, [ - h('div', { class: 'placeholder-hero' }, [svg(chamber.icon)]), - h('p', { class: 'placeholder-blurb selectable' }, [chamber.description]), - ]), - - h('section', { class: 'panel placeholder-meta-panel', style: { padding: '24px 32px' } }, [ - h('div', { class: 'placeholder-meta' }, [ - h('div', { class: 'meta-row' }, [ - h('span', { class: 'meta-key' }, ['id']), - h('span', { class: 'mono' }, [chamber.id]), - ]), - h('div', { class: 'meta-row' }, [ - h('span', { class: 'meta-key' }, ['status']), - h('span', null, [ - h('span', { class: 'badge badge-accent' }, ['Pending']), - ]), - ]), - h('div', { class: 'meta-row' }, [ - h('span', { class: 'meta-key' }, ['roadmap']), - h('span', { class: 'mono' }, [`phase ${chamber.phase}`]), - ]), - h('div', { class: 'meta-row' }, [ - h('span', { class: 'meta-key' }, ['archive']), - h('span', { class: 'mono' }, [`/${chamber.id}/`]), - ]), - ]), - ]), - - h('div', { class: 'hr-tick' }, ['• • •']), - - h('section', { class: 'provenance' }, [ - h('div', { class: 'pv-row' }, [ - h('span', { class: 'pv-key' }, ['note']), - h('span', { class: 'pv-val' }, ['the foundation is here; this chamber is on the roadmap.']), - ]), - h('div', { class: 'pv-row' }, [ - h('span', { class: 'pv-key' }, ['source']), - h('span', { class: 'pv-val' }, ['(no entropy fetched yet — the engine arrives in phase 2)']), - ]), - ]), - ]); - - rootEl.appendChild(frame); -} - -export function unmountPlaceholder() { - /* no listeners attached */ -} diff --git a/src/chambers/beacon/index.js b/src/chambers/beacon/index.js index afc952b..02d7672 100644 --- a/src/chambers/beacon/index.js +++ b/src/chambers/beacon/index.js @@ -1,17 +1,18 @@ /** * The Beacon chamber. * Write an entry. Seal it against the latest NIST beacon pulse. Anyone can - * later re-fetch the pulse and verify the entry was written against that - * timestamp — proving timing, not secrecy. + * later re-fetch the pulse and recompute the same checksum. This is a + * reproducible integrity recipe, not a third-party timestamp. */ import { h, clear } from '../../lib/dom.js'; import { CHAMBER_BY_ID } from '../manifest.js'; -import { fileSafeISO, humanDate, shortHash } from '../../lib/format.js'; -import { sha256Hex } from '../../lib/hash.js'; +import { shortHash } from '../../lib/format.js'; import { fetchJSON } from '../../lib/http.js'; -import { buildProvenance, toast } from '../lottery/_shared.js'; +import { toast } from '../lottery/_shared.js'; import { verifyEntry, computeEntryHash } from './verify.js'; +import { verifyReceipt } from '../../receipts/receipt.ts'; +import { ActionBar, ErrorState, ProvenanceDisclosure, ResultStage, Skeleton } from '../../ui/primitives.js'; const meta = CHAMBER_BY_ID['beacon']; export const id = 'beacon'; @@ -28,19 +29,32 @@ let _sealed = null; let _verifyInput = ''; let _verifyResult = null; let _sealing = false; +let _saving = false; +let _verifying = false; +let _sealError = null; +let _lifecycle = 0; export function mount(rootEl, ctx) { + _lifecycle += 1; _ctx = ctx; render(rootEl); } export function unmount() { + _lifecycle += 1; _ctx = null; + _sealing = false; + _saving = false; + _verifying = false; } function render(rootEl) { clear(rootEl); - const frame = h('div', { class: 'chamber-frame reveal' }, [ + const busy = _sealing || _saving || _verifying; + const hasResultState = _activeMode === 'compose' + ? Boolean(_sealing || _sealError || _sealed) + : Boolean(_verifying || _verifyResult); + const frame = h('div', { class: `chamber-frame beacon-frame reveal${hasResultState ? ' has-result' : ''}` }, [ h('div', { class: 'chamber-header' }, [ h('div', null, [ h('div', { class: 'chamber-id' }, ['Chamber · 05/08']), @@ -53,6 +67,7 @@ function render(rootEl) { ].map(([id, label]) => h('button', { class: 'segmented-opt', 'aria-pressed': _activeMode === id ? 'true' : 'false', + disabled: busy, onclick: () => { _activeMode = id; render(rootEl); }, }, [label]))), ]), @@ -60,32 +75,28 @@ function render(rootEl) { h('summary', null, ['What is the Beacon?']), h('div', { class: 'chamber-intro-body' }, [ h('p', null, [ - h('strong', null, ['The Beacon lets you prove later that you wrote something before a specific public moment in time']), - ' — without a notary, an account, or trusting any third party other than the U.S. National Institute of Standards (NIST).', + h('strong', null, ['The Beacon creates a portable checksum recipe tied to a public NIST pulse.']), + ' It can help detect accidental changes when you keep the expected hash separately.', ]), h('p', { class: 'muted small' }, [ - 'It proves ', - h('em', null, ['when']), - ', not ', - h('em', null, ['what']), - '. Your text is not encrypted; if you paste a secret, encrypt it yourself first.', + 'It does not prove when the text was written, does not identify its author, and is not an adversarial tamper-proof timestamp. ', + 'The pulse and hash are public and recomputable. Your text is not encrypted; encrypt secrets before using this tool.', ]), h('h4', null, ['How it works']), h('ol', null, [ h('li', null, ['You write an entry (any text).']), - h('li', null, ['You click ', h('strong', null, ['Seal']), '. Sortilune fetches the latest ', h('strong', null, ['NIST randomness beacon pulse']), ' — a cryptographically signed 512-bit value that NIST publishes every 60 seconds and archives forever, publicly.']), - h('li', null, ['Sortilune computes ', h('code', null, ['SHA-256(your text + pulse value)']), ' and bundles your text, the pulse, and the hash into one .json file you save to the archive.']), - h('li', null, ['Anyone — you in five years, a lawyer, a stranger — can re-fetch that exact pulse from NIST’s public archive and recompute the hash. If it matches, your entry verifiably existed in its current form when that pulse was published, and not one second earlier.']), + h('li', null, ['You click ', h('strong', null, ['Create checksum']), '. Sortilune fetches the latest public NIST randomness beacon pulse.']), + h('li', null, ['Sortilune computes ', h('code', null, ['SHA-256(your text + pulse value)']), ' and stores the text, pulse reference, and checksum together.']), + h('li', null, ['Later, the verifier re-fetches the pulse and recomputes the checksum. A match shows that the supplied fields are internally consistent; it does not establish when they were assembled.']), ]), h('h4', null, ['Good for']), h('ul', null, [ - h('li', null, ['Sealed predictions ("I think X will happen by Y")']), - h('li', null, ['Decisions you want timestamped without an audit trail']), - h('li', null, ['Vows, intentions, drafts of contracts']), - h('li', null, ['"I called it" claims — patentable ideas, design notes, callouts']), + h('li', null, ['Portable integrity checks where the expected hash is stored separately']), + h('li', null, ['Learning how public randomness and SHA-256 can be combined']), + h('li', null, ['Binding notes to a particular public pulse as a reproducible creative ritual']), ]), h('p', { class: 'muted small' }, [ - 'The ', h('strong', null, ['Verify']), ' tab lets you paste a sealed entry and have Sortilune re-fetch the pulse and re-check the hash. The sealed JSON also includes shell-command instructions so verification works without Sortilune installed.', + 'The ', h('strong', null, ['Verify']), ' tab re-fetches the pulse and re-checks the checksum. For a real proof-of-existence timestamp, use a trusted timestamp authority or public append-only commitment service.', ]), ]), ]), @@ -95,46 +106,87 @@ function render(rootEl) { } function buildCompose(rootEl) { - return h('section', { class: 'panel beacon-panel' }, [ - h('p', { class: 'muted' }, [ - 'Write what you want to prove you wrote before a specific public moment. ', - 'The Beacon does not encrypt; it timestamps. Anyone with this file can verify ', - 'that the SHA-256 of your text was computed against a NIST pulse that NIST has ', - 'archived under that signature.', - ]), - h('label', { class: 'stack-1', style: { marginTop: '16px' } }, [ - h('span', { class: 'label' }, ['Entry']), - h('textarea', { - class: 'textarea beacon-textarea', rows: 12, - placeholder: 'Markdown is fine. Whatever you write here will be hashed verbatim.', - oninput: (e) => { _entryText = e.target.value; }, - value: _entryText, - }), - ]), - h('div', { class: 'row center', style: { gap: '12px', marginTop: '20px' } }, [ - h('button', { - class: 'btn btn-primary btn-big', - onclick: () => doSeal(rootEl), - disabled: _sealing || !_entryText.trim(), - }, [_sealing ? 'sealing…' : 'Seal']), - _sealed ? h('button', { class: 'btn', onclick: () => { _sealed = null; render(rootEl); } }, ['Clear seal']) : null, + const busy = _sealing || _saving || _verifying; + return h('div', { class: `beacon-workflow${_sealed ? ' has-result' : ''}` }, [ + _sealing ? ResultStage({ + className: 'sealed-view beacon-loading', + label: 'Creating checksum', + status: 'loading', + children: [Skeleton(3)], + }) : (_sealError ? buildSealError(rootEl) : (_sealed ? buildSealedView(rootEl) : null)), + h('section', { class: 'panel beacon-panel beacon-compose' }, [ + h('p', { class: 'muted' }, [ + 'Create a reproducible checksum from your text and a public NIST pulse. ', + 'This does not timestamp the text or prevent someone from recomputing the file after editing it. ', + 'Keep the expected hash in a separate trusted place if you want useful change detection.', + ]), + h('label', { class: 'stack-1', style: { marginTop: '16px' } }, [ + h('span', { class: 'label' }, ['Entry']), + h('textarea', { + class: 'textarea beacon-textarea', rows: _sealed ? 5 : 12, + maxlength: 100000, + disabled: busy || Boolean(_sealed), + placeholder: 'Markdown is fine. Whatever you write here will be hashed verbatim.', + oninput: (e) => { + _entryText = e.target.value.slice(0, 100_000); + const action = e.currentTarget.closest('.beacon-panel')?.querySelector('[data-action="create-checksum"]'); + if (action) action.disabled = _sealing || !_entryText.trim(); + }, + value: _entryText, + }), + ]), + h('div', { class: 'row center beacon-compose-actions' }, [ + h('button', { + class: 'btn btn-primary btn-big', + 'data-action': 'create-checksum', + onclick: () => doSeal(rootEl), + disabled: busy || Boolean(_sealed) || !_entryText.trim(), + }, [_sealing ? 'Creating…' : (_sealed ? 'Clear result before another checksum' : 'Create checksum')]), + ]), ]), - _sealed ? buildSealedView(rootEl) : null, ]); } +function buildSealError(rootEl) { + return ResultStage({ + className: 'sealed-view beacon-error', + label: 'Checksum creation error', + status: 'error', + children: [ErrorState( + 'Checksum was not created', + _sealError, + ActionBar({ + label: 'Checksum error actions', + primary: [h('button', { class: 'btn btn-primary', onclick: () => doSeal(rootEl) }, ['Retry'])], + secondary: [h('button', { + class: 'btn', + onclick: () => { + _sealError = null; + render(rootEl); + }, + }, ['Clear'])], + }), + )], + }); +} + function buildSealedView(rootEl) { const e = _sealed; - return h('section', { class: 'sealed-view stack-4' }, [ - h('hr', { class: 'hr-tick' }, ['SEAL']), - h('div', { class: 'sealed-badge panel-raised' }, [ + const provenance = sealedProvenance(e); + return ResultStage({ + className: 'sealed-view', + label: 'Created checksum', + status: 'result', + children: [ + h('hr', { class: 'hr-tick' }, ['CHECKSUM']), + h('div', { class: 'sealed-badge' }, [ h('div', { class: 'row spread' }, [ h('div', null, [ - h('div', { class: 'label' }, ['Sealed against NIST pulse']), + h('div', { class: 'label' }, ['Bound to NIST pulse']), h('div', { class: 'sealed-pulse-id mono' }, [`#${e.pulse.pulseIndex}`]), ]), h('div', { class: 'row', style: { gap: '8px' } }, [ - h('span', { class: 'badge badge-accent' }, ['verified seal']), + h('span', { class: 'badge badge-accent' }, ['checksum created']), ]), ]), h('div', { class: 'sealed-grid' }, [ @@ -145,14 +197,49 @@ function buildSealedView(rootEl) { sealField('entry_hash', shortHash(e.entry_hash, 14), true), ]), h('details', { class: 'sealed-verify' }, [ - h('summary', null, ['verification instructions (curl + openssl)']), + h('summary', null, ['Verification reference']), h('pre', { class: 'mono' }, [e.verify_instructions]), ]), - h('div', { class: 'row', style: { gap: '12px', marginTop: '16px' } }, [ - h('button', { class: 'btn btn-primary', onclick: () => saveSealed() }, ['Save sealed entry']), - ]), + ProvenanceDisclosure(provenance), ]), - ]); + ActionBar({ + label: 'Checksum result actions', + primary: [h('button', { + class: 'btn btn-primary', + disabled: _saving || Boolean(e.saved_path), + onclick: () => saveSealed(rootEl), + }, [e.saved_path ? 'Saved' : (_saving ? 'Saving…' : 'Save')])], + secondary: [h('button', { + class: 'btn btn-ghost', + disabled: _saving, + onclick: () => clearSealed(rootEl), + }, ['Clear'])], + }), + ], + }); +} + +function sealedProvenance(sealed) { + return { + source_id: 'nist-beacon', + source_name: 'NIST Randomness Beacon', + fetched_at: sealed.sealed_at, + raw: sealed.pulse.outputValue, + signature: sealed.pulse.signatureValue || null, + extra: { + chain_index: sealed.pulse.chainIndex, + pulse_index: sealed.pulse.pulseIndex, + uri: sealed.pulse.uri, + verification_state: sealed.pulse.signatureValue ? 'signature recorded' : 'signature unavailable', + }, + }; +} + +function clearSealed(rootEl) { + if (_sealing || _saving) return; + _sealed = null; + _sealError = null; + render(rootEl); } function sealField(label, value, mono, sig) { @@ -163,19 +250,28 @@ function sealField(label, value, mono, sig) { } async function doSeal(rootEl) { - if (_sealing) return; + if (_sealing || _saving || _verifying || _sealed || !_entryText.trim()) return; + const ctx = _ctx; + if (!ctx) return; + const lifecycle = _lifecycle; + const entryText = _entryText; _sealing = true; + _sealError = null; render(rootEl); try { const pulseResp = await fetchJSON(NIST_LAST); const pulse = pulseResp?.pulse; - if (!pulse || !pulse.outputValue) throw new Error('NIST returned no pulse'); + if (!pulse || !Number.isSafeInteger(pulse.chainIndex) || pulse.chainIndex < 1 || + !Number.isSafeInteger(pulse.pulseIndex) || pulse.pulseIndex < 1 || + typeof pulse.outputValue !== 'string' || !/^[0-9a-f]{128}$/i.test(pulse.outputValue)) { + throw new Error('NIST returned a malformed pulse'); + } - const entryHash = await computeEntryHash(_entryText, [], pulse.outputValue); + const entryHash = await computeEntryHash(entryText, [], pulse.outputValue); const verify = makeVerifyInstructions(pulse, entryHash); - _sealed = { + const sealed = { type: 'beacon-entry', - entry_text: _entryText, + entry_text: entryText, entry_hash: entryHash, file_hashes: [], pulse: { @@ -189,27 +285,49 @@ async function doSeal(rootEl) { sealed_at: new Date().toISOString(), verify_instructions: verify, }; + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + _sealed = sealed; } catch (e) { - toast(`Seal failed: ${e.message || e}`, 'danger'); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + _sealError = String(e?.message || e); + toast(`Checksum failed: ${_sealError}`, 'danger'); + } finally { + if (_ctx === ctx && _lifecycle === lifecycle) { + _sealing = false; + render(rootEl); + } } - _sealing = false; - render(rootEl); } -async function saveSealed() { - if (!_ctx?.archive || !_sealed) return; - const stamp = fileSafeISO(new Date(_sealed.sealed_at)); - const id = _sealed.entry_hash.slice(0, 10); - const rel = `archive/beacon/${stamp}__entry_${id}.json`; +async function saveSealed(rootEl) { + if (_sealing || _saving || !_ctx?.archive || !_sealed || _sealed.saved_path) return; + const ctx = _ctx; + const lifecycle = _lifecycle; + const sealed = _sealed; + _saving = true; + render(rootEl); try { - const body = { - human_summary: `Sealed entry against NIST pulse #${_sealed.pulse.pulseIndex} (${_sealed.entry_text.slice(0, 80).replace(/\s+/g, ' ').trim()}${_sealed.entry_text.length > 80 ? '…' : ''})`, - ..._sealed, - }; - await _ctx.archive.writeJSON(rel, body); - toast(`Saved → ${rel}`, 'success'); + const summary = `Checksum entry bound to NIST pulse #${sealed.pulse.pulseIndex} (${sealed.entry_text.slice(0, 80).replace(/\s+/g, ' ').trim()}${sealed.entry_text.length > 80 ? '…' : ''})`; + const { saved_path: _savedPath, ...payload } = sealed; + const saved = await ctx.archive.save({ + chamber: 'beacon', + type: 'beacon-entry', + createdAt: sealed.sealed_at, + summary, + payload, + provenance: sealedProvenance(sealed), + }); + if (_ctx !== ctx || _lifecycle !== lifecycle || _sealed !== sealed) return; + sealed.saved_path = saved.path; + toast(`Saved → ${saved.path}`, 'success'); } catch (e) { + if (_ctx !== ctx || _lifecycle !== lifecycle) return; toast(`Save failed: ${e.message || e}`, 'danger'); + } finally { + if (_ctx === ctx && _lifecycle === lifecycle) { + _saving = false; + render(rootEl); + } } } @@ -219,56 +337,200 @@ function makeVerifyInstructions(pulse, entryHash) { `curl -sf https://beacon.nist.gov/beacon/2.0/chain/${pulse.chainIndex}/pulse/${pulse.pulseIndex} \\`, ` | jq -r '.pulse.outputValue' # must equal: ${pulse.outputValue}`, ``, - `# Recompute the entry hash from the entry_text + file_hashes + pulse.outputValue.`, + `# Recompute the checksum from entry_text + file_hashes + pulse.outputValue.`, `# Concatenation order: entry_text + "\\n--files--\\n" + file_hashes.join("\\n") + "\\n--pulse--\\n" + outputValue`, `# Expected SHA-256: ${entryHash}`, ].join('\n'); } function buildVerify(rootEl) { - return h('section', { class: 'panel beacon-panel' }, [ - h('p', { class: 'muted' }, ['Paste a sealed entry’s JSON (or just its archive contents) below. ', - 'We will re-fetch the referenced pulse from NIST and recompute the hash.']), - h('label', { class: 'stack-1', style: { marginTop: '12px' } }, [ - h('span', { class: 'label' }, ['Sealed entry JSON']), - h('textarea', { - class: 'textarea', rows: 12, - placeholder: '{ "type": "beacon-entry", "entry_text": "...", "pulse": { ... }, "entry_hash": "..." }', - oninput: (e) => { _verifyInput = e.target.value; _verifyResult = null; }, - value: _verifyInput, - }), - ]), - h('div', { class: 'row center', style: { marginTop: '16px' } }, [ - h('button', { class: 'btn btn-primary btn-big', onclick: () => doVerify(rootEl) }, ['Verify']), + return h('div', { class: `beacon-workflow${_verifyResult ? ' has-result' : ''}` }, [ + _verifying ? ResultStage({ + className: 'verify-result beacon-loading', + label: 'Verifying checksum', + status: 'loading', + children: [Skeleton(3)], + }) : (_verifyResult ? buildVerifyResult(_verifyResult, rootEl) : null), + h('section', { class: 'panel beacon-panel beacon-verify' }, [ + h('p', { class: 'muted' }, ['Paste a Beacon checksum entry or a portable Sortilune receipt below. ', + 'Beacon entries re-fetch their NIST pulse; portable receipts are checked locally. Neither proves authorship or a creation time.']), + h('label', { class: 'stack-1', style: { marginTop: '12px' } }, [ + h('span', { class: 'label' }, ['Load a receipt file']), + h('input', { + class: 'input', type: 'file', accept: '.json,.sortilune-receipt.json,application/json', disabled: _verifying, + onchange: (event) => void loadVerifyFile(event, rootEl), + }), + ]), + h('label', { class: 'stack-1', style: { marginTop: '12px' } }, [ + h('span', { class: 'label' }, ['Receipt or sealed entry JSON']), + h('textarea', { + class: 'textarea', rows: _verifyResult ? 6 : 12, + maxlength: 1000000, + disabled: _verifying, + placeholder: '{ "schema": "sortilune.receipt", ... }', + oninput: (e) => { + _verifyInput = e.target.value; + _verifyResult = null; + const workflow = e.currentTarget.closest('.beacon-workflow'); + workflow?.querySelector('.verify-result')?.remove(); + workflow?.classList.remove('has-result'); + e.currentTarget.closest('.beacon-frame')?.classList.remove('has-result'); + const action = workflow?.querySelector('[data-action="verify-entry"]'); + if (action) action.disabled = _verifying || !_verifyInput.trim(); + }, + value: _verifyInput, + }), + ]), + h('div', { class: 'row center beacon-compose-actions' }, [ + h('button', { + class: 'btn btn-primary btn-big', + 'data-action': 'verify-entry', + disabled: _verifying || !_verifyInput.trim(), + onclick: () => doVerify(rootEl), + }, [_verifying ? 'Verifying…' : 'Verify']), + ]), ]), - _verifyResult ? buildVerifyResult(_verifyResult) : null, ]); } -function buildVerifyResult(r) { +function buildVerifyResult(r, rootEl) { + if (r.receipt) return buildReceiptVerifyResult(r.verification, rootEl); if (r.ok) { - return h('div', { class: 'panel-raised verify-result success', style: { marginTop: '16px' } }, [ - h('h3', { class: 'success' }, ['✓ Verified']), - h('p', { class: 'muted' }, [`Pulse #${r.pulseIndex} re-fetched from NIST matches; entry_hash recomputed correctly. The entry was written against this beacon pulse.`]), - ]); + return ResultStage({ + className: 'verify-result success', + label: 'Checksum verification result', + status: 'result', + children: [ + h('h3', { class: 'success' }, ['✓ Checksum matches']), + h('p', { class: 'muted' }, [`Pulse #${r.pulseIndex} re-fetched from NIST matches and entry_hash recomputed correctly. This does not establish when the entry was created.`]), + ActionBar({ + label: 'Verification result actions', + secondary: [h('button', { class: 'btn', onclick: () => clearVerifyResult(rootEl) }, ['Clear'])], + }), + ], + }); } - return h('div', { class: 'panel-raised verify-result fail', style: { marginTop: '16px' } }, [ - h('h3', { class: 'amber' }, ['Could not verify']), - h('p', { class: 'muted' }, [r.reason || 'unknown reason']), - ]); + return ResultStage({ + className: 'verify-result fail', + label: 'Checksum verification error', + status: 'error', + children: [ErrorState( + 'Could not verify', + r.reason || 'Unknown reason', + ActionBar({ + label: 'Verification error actions', + primary: [h('button', { class: 'btn btn-primary', onclick: () => doVerify(rootEl) }, ['Retry'])], + secondary: [h('button', { class: 'btn', onclick: () => clearVerifyResult(rootEl) }, ['Clear'])], + }), + )], + }); +} + +function buildReceiptVerifyResult(verification, rootEl) { + if (verification.status === 'valid') { + return ResultStage({ + className: 'verify-result success receipt-verification', + label: 'Portable receipt verification result', + status: 'result', + children: [ + h('h3', { class: 'success' }, ['✓ Receipt is intact']), + h('p', null, [verification.receipt.source.title]), + h('p', { class: 'muted' }, ['The local SHA-256 value matches this receipt snapshot. This does not prove authorship or trusted time.']), + h('ul', { class: 'small muted' }, verification.receipt.limitations.map((item) => h('li', null, [item]))), + h('div', { class: 'small mono selectable' }, [verification.actual]), + ActionBar({ + label: 'Receipt verification actions', + secondary: [h('button', { class: 'btn', onclick: () => clearVerifyResult(rootEl) }, ['Clear'])], + }), + ], + }); + } + const title = verification.status === 'changed' ? 'Receipt content changed' + : verification.status === 'unsupported' ? 'Receipt version is unsupported' : 'Receipt is malformed'; + const reason = verification.status === 'changed' + ? 'The stored checksum does not match the supplied receipt content.' + : verification.reason; + return ResultStage({ + className: 'verify-result fail receipt-verification', + label: 'Portable receipt verification error', + status: 'error', + children: [ErrorState( + title, + reason, + ActionBar({ + label: 'Receipt verification error actions', + secondary: [h('button', { class: 'btn', onclick: () => clearVerifyResult(rootEl) }, ['Clear'])], + }), + )], + }); +} + +function clearVerifyResult(rootEl) { + if (_verifying) return; + _verifyResult = null; + render(rootEl); } async function doVerify(rootEl) { + if (_verifying || _sealing || _saving) return; + const ctx = _ctx; + if (!ctx) return; + const lifecycle = _lifecycle; + const input = _verifyInput; + if (input.length > 1_000_000) { + _verifyResult = { ok: false, reason: 'Input exceeds the 1,000,000 character verification limit.' }; + render(rootEl); + return; + } let sealed; try { - sealed = JSON.parse(_verifyInput); - } catch (e) { + sealed = JSON.parse(input); + } catch { _verifyResult = { ok: false, reason: 'Input is not valid JSON.' }; render(rootEl); return; } - _verifyResult = { ok: false, reason: 'verifying…' }; - render(rootEl); - _verifyResult = await verifyEntry(sealed); + _verifyResult = null; + _verifying = true; render(rootEl); + try { + const result = sealed?.schema === 'sortilune.receipt' + ? { receipt: true, verification: await verifyReceipt(sealed) } + : await verifyEntry(sealed); + if (_ctx !== ctx || _lifecycle !== lifecycle || _verifyInput !== input) return; + _verifyResult = result; + } catch (e) { + if (_ctx !== ctx || _lifecycle !== lifecycle || _verifyInput !== input) return; + _verifyResult = { ok: false, reason: `Verification failed safely: ${e.message || e}` }; + } finally { + if (_ctx === ctx && _lifecycle === lifecycle) { + _verifying = false; + render(rootEl); + requestAnimationFrame(() => { + const result = rootEl.querySelector('.verify-result'); + result?.setAttribute('tabindex', '-1'); + result?.focus({ preventScroll: true }); + result?.scrollIntoView({ block: 'start', behavior: 'smooth' }); + }); + } + } +} + +async function loadVerifyFile(event, rootEl) { + const file = event.currentTarget?.files?.[0]; + if (!file) return; + if (file.size > 1_000_000) { + _verifyInput = ''; + _verifyResult = { receipt: true, verification: { status: 'invalid', reason: 'Receipt file exceeds the 1 MB limit.' } }; + render(rootEl); + return; + } + try { + _verifyInput = await file.text(); + _verifyResult = null; + render(rootEl); + } catch (error) { + _verifyResult = { receipt: true, verification: { status: 'invalid', reason: `Could not read receipt: ${error?.message || error}` } }; + render(rootEl); + } } diff --git a/src/chambers/beacon/verify.js b/src/chambers/beacon/verify.js index 4a2b9f6..83b4d5c 100644 --- a/src/chambers/beacon/verify.js +++ b/src/chambers/beacon/verify.js @@ -12,14 +12,27 @@ const PULSE_BY_INDEX = (chainIndex, pulseIndex) => /** Verify a sealed entry. */ export async function verifyEntry(sealed) { + if (!sealed || typeof sealed !== 'object' || Array.isArray(sealed)) { + return { ok: false, reason: 'entry must be a JSON object' }; + } const expected = sealed.entry_hash; - if (!expected) return { ok: false, reason: 'entry has no entry_hash field' }; + if (typeof expected !== 'string' || !/^[0-9a-f]{64}$/i.test(expected)) { + return { ok: false, reason: 'entry_hash must be a 64-character SHA-256 hex string' }; + } const chain = sealed.pulse?.chainIndex; const idx = sealed.pulse?.pulseIndex; const outputValue = sealed.pulse?.outputValue; - if (chain == null || idx == null || !outputValue) { + if (!Number.isSafeInteger(chain) || chain < 1 || !Number.isSafeInteger(idx) || idx < 1 || + typeof outputValue !== 'string' || !/^[0-9a-f]{128}$/i.test(outputValue)) { return { ok: false, reason: 'entry is missing pulse reference' }; } + if (typeof sealed.entry_text !== 'string' || sealed.entry_text.length > 100_000) { + return { ok: false, reason: 'entry_text must be a string no longer than 100,000 characters' }; + } + if (!Array.isArray(sealed.file_hashes) || sealed.file_hashes.length > 1_000 || + !sealed.file_hashes.every((hash) => typeof hash === 'string' && /^[0-9a-f]{64}$/i.test(hash))) { + return { ok: false, reason: 'file_hashes must contain at most 1,000 SHA-256 hex strings' }; + } // Re-fetch the pulse from NIST let fetched; @@ -29,7 +42,10 @@ export async function verifyEntry(sealed) { return { ok: false, reason: `could not fetch pulse from NIST: ${e.message || e}` }; } const fetchedOutput = fetched?.pulse?.outputValue; - if (!fetchedOutput) return { ok: false, reason: 'NIST returned no pulse outputValue' }; + if (fetched?.pulse?.chainIndex !== chain || fetched?.pulse?.pulseIndex !== idx || + typeof fetchedOutput !== 'string' || !/^[0-9a-f]{128}$/i.test(fetchedOutput)) { + return { ok: false, reason: 'NIST returned a malformed or mismatched pulse' }; + } if (fetchedOutput.toLowerCase() !== outputValue.toLowerCase()) { return { ok: false, reason: 'sealed pulse outputValue does not match NIST' }; } @@ -44,6 +60,14 @@ export async function verifyEntry(sealed) { /** Compute the canonical entry hash. */ export async function computeEntryHash(text, fileHashes, pulseOutputValue) { - const parts = [text || '', '\n--files--\n', (fileHashes || []).join('\n'), '\n--pulse--\n', pulseOutputValue]; + if (typeof text !== 'string' || text.length > 100_000) throw new TypeError('entry text must be a string no longer than 100,000 characters'); + if (!Array.isArray(fileHashes) || fileHashes.length > 1_000 || + !fileHashes.every((hash) => typeof hash === 'string' && /^[0-9a-f]{64}$/i.test(hash))) { + throw new TypeError('file hashes must contain at most 1,000 SHA-256 hex strings'); + } + if (typeof pulseOutputValue !== 'string' || !/^[0-9a-f]{128}$/i.test(pulseOutputValue)) { + throw new TypeError('pulse output must be a 128-character hex string'); + } + const parts = [text, '\n--files--\n', fileHashes.join('\n'), '\n--pulse--\n', pulseOutputValue]; return sha256Hex(parts.join('')); } diff --git a/src/chambers/canvas/generators/constellation.js b/src/chambers/canvas/generators/constellation.js index a6dbcc5..130de47 100644 --- a/src/chambers/canvas/generators/constellation.js +++ b/src/chambers/canvas/generators/constellation.js @@ -2,75 +2,81 @@ import { makeRng, deriveAccent } from '../rng.js'; export const id = 'constellation'; export const label = 'Constellation'; -export const description = 'A field of stars with suggestive line-clusters; a plate from an imagined atlas.'; +export const description = 'A luminous star atlas with connected figures and deep-field detail.'; -export function generate(bytes, { width = 1600, height = 1000 } = {}) { +export function generate(bytes, { width = 1600, height = 1000, palette } = {}) { const rng = makeRng(bytes); - const accent = deriveAccent(bytes); - const W = width, H = height; - const starCount = 90 + rng.int(0, 80); - const stars = []; - for (let i = 0; i < starCount; i++) { - stars.push({ - x: rng.range(40, W - 40), - y: rng.range(40, H - 40), - r: rng.next() < 0.85 ? 0.6 + rng.next() * 1.0 : 1.4 + rng.next() * 2.6, - bright: rng.next() < 0.18, - }); - } - // Pick a few clusters - const constellationCount = 3 + rng.int(0, 4); - const constellations = []; - for (let c = 0; c < constellationCount; c++) { - const cx = rng.range(120, W - 120); - const cy = rng.range(120, H - 120); - const segments = 3 + rng.int(0, 5); - const radius = 90 + rng.next() * 200; - const angle0 = rng.angle(); - const pts = []; - let lastA = angle0; - for (let k = 0; k < segments; k++) { - const a = lastA + (rng.next() - 0.5) * Math.PI; - const r = radius * (0.4 + rng.next() * 0.8); - const x = cx + r * Math.cos(a); - const y = cy + r * Math.sin(a); - pts.push({ x, y }); - lastA = a; - } - constellations.push(pts); - } - - let starsSvg = ''; - for (const s of stars) { - const fill = s.bright ? accent : 'currentColor'; - const op = (0.35 + rng.next() * 0.5).toFixed(2); - starsSvg += ``; - } + const accent = deriveAccent(bytes, palette); + const W = width; + const H = height; + const stars = Array.from({ length: 160 + rng.int(0, 90) }, () => ({ + x: rng.range(44, W - 44), + y: rng.range(44, H - 44), + radius: rng.next() < 0.82 ? rng.range(1.1, 2.5) : rng.range(3, 6.5), + opacity: rng.range(0.42, 0.96), + accent: rng.next() < 0.22, + })); - let linesSvg = ''; - let dotsSvg = ''; - for (const c of constellations) { - for (let i = 0; i < c.length - 1; i++) { - linesSvg += ``; - } - for (const p of c) { - dotsSvg += ``; - dotsSvg += ``; + const figures = []; + const figureCount = 4 + rng.int(0, 4); + for (let figureIndex = 0; figureIndex < figureCount; figureIndex += 1) { + const points = []; + let x = rng.range(W * 0.16, W * 0.84); + let y = rng.range(H * 0.18, H * 0.80); + const pointCount = 5 + rng.int(0, 5); + for (let pointIndex = 0; pointIndex < pointCount; pointIndex += 1) { + points.push({ x, y }); + const angle = rng.angle(); + const distance = rng.range(75, 180); + x = clamp(x + Math.cos(angle) * distance, 76, W - 76); + y = clamp(y + Math.sin(angle) * distance, 76, H - 76); } + figures.push(points); } - return wrap(W, H, starsSvg + linesSvg + dotsSvg, { accent, label: 'CONSTELLATION · LATE NIGHT WATCH' }); -} + const starField = stars.map((star) => { + const color = star.accent ? accent : 'currentColor'; + const core = ``; + if (star.radius < 3) return core; + return `${core}`; + }).join(''); + + const atlasFigures = figures.map((points, index) => { + const path = points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); + const nodes = points.map((point, pointIndex) => ` + + `).join(''); + const labelPoint = points[0]; + return ` + + + ${nodes} + FIG ${String(index + 1).padStart(2, '0')} + `; + }).join(''); -function wrap(W, H, inner, { accent, label }) { return ` - - - ${inner} - - ${label} - SORTILUNE + style="color:var(--text-primary);background:var(--bg);" class="canvas-svg"> + + + + + + + + + + + + ${starField} + ${atlasFigures} + + CONSTELLATION · ENTROPY STAR ATLAS + SORTILUNE `; } + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} diff --git a/src/chambers/canvas/generators/interference.js b/src/chambers/canvas/generators/interference.js index 5831ead..271febe 100644 --- a/src/chambers/canvas/generators/interference.js +++ b/src/chambers/canvas/generators/interference.js @@ -2,54 +2,69 @@ import { makeRng, deriveAccent } from '../rng.js'; export const id = 'interference'; export const label = 'Wave interference'; -export const description = 'Concentric wavefronts from N sources; bright fringes where they meet.'; +export const description = 'Overlapping circular wavefronts with bright and dark interference fringes.'; -export function generate(bytes, { width = 1600, height = 1000 } = {}) { +export function generate(bytes, { width = 1600, height = 1000, palette } = {}) { const rng = makeRng(bytes); - const accent = deriveAccent(bytes); - const W = width, H = height; + const accent = deriveAccent(bytes, palette); + const W = width; + const H = height; const sources = 2 + rng.int(0, 3); - const points = []; - for (let i = 0; i < sources; i++) { - points.push({ - x: rng.range(W * 0.2, W * 0.8), - y: rng.range(H * 0.2, H * 0.8), - wavelength: 20 + rng.next() * 40, - phase: rng.next() * Math.PI * 2, - }); - } - let inner = ''; - // Sample on a grid; for each grid point sum cosines, draw a dot if peak - const step = 10; - for (let y = step; y < H - step; y += step) { - for (let x = step; x < W - step; x += step) { + const points = Array.from({ length: sources }, () => ({ + x: rng.range(W * 0.18, W * 0.82), + y: rng.range(H * 0.18, H * 0.82), + wavelength: rng.range(34, 68), + phase: rng.angle(), + })); + + const wavefronts = points.map((point, sourceIndex) => { + const maxRadius = Math.hypot(Math.max(point.x, W - point.x), Math.max(point.y, H - point.y)); + const rings = []; + for (let radius = point.wavelength; radius < maxRadius; radius += point.wavelength) { + rings.push(``); + } + return `${rings.join('')}`; + }).join(''); + + const fringes = []; + const step = 16; + for (let y = 48; y < H - 48; y += step) { + for (let x = 48; x < W - 48; x += step) { let sum = 0; - for (const p of points) { - const d = Math.hypot(x - p.x, y - p.y); - sum += Math.cos((d / p.wavelength) * Math.PI * 2 + p.phase); - } - const amp = sum / sources; - if (amp > 0.7) { - inner += ``; - } else if (amp < -0.7) { - inner += ``; + for (const point of points) { + const distance = Math.hypot(x - point.x, y - point.y); + sum += Math.cos((distance / point.wavelength) * Math.PI * 2 + point.phase); } + const amplitude = sum / sources; + if (Math.abs(amplitude) < 0.48) continue; + const strength = (Math.abs(amplitude) - 0.48) / 0.52; + const radius = 1.8 + strength * 4.8; + fringes.push(``); } } - // sources marked - for (const p of points) { - inner += ``; - inner += ``; - inner += ``; - } + + const sourceMarks = points.map((point, index) => ` + + + + S${index + 1} + `).join(''); + return ` - - - ${inner} - - WAVE INTERFERENCE PATTERN · ${sources} SOURCES - SORTILUNE + + + + + + + + ${wavefronts} + ${fringes.join('')} + ${sourceMarks} + + WAVE INTERFERENCE · ${sources} COHERENT SOURCES + SORTILUNE `; } diff --git a/src/chambers/canvas/generators/lissajous.js b/src/chambers/canvas/generators/lissajous.js index 6419269..d6c79a4 100644 --- a/src/chambers/canvas/generators/lissajous.js +++ b/src/chambers/canvas/generators/lissajous.js @@ -4,9 +4,9 @@ export const id = 'lissajous'; export const label = 'Lissajous'; export const description = 'A family of phase-locked curves; parameters drawn from entropy.'; -export function generate(bytes, { width = 1600, height = 1000 } = {}) { +export function generate(bytes, { width = 1600, height = 1000, palette } = {}) { const rng = makeRng(bytes); - const accent = deriveAccent(bytes); + const accent = deriveAccent(bytes, palette); const W = width, H = height; const cx = W / 2, cy = H / 2; const r = Math.min(W, H) * 0.4; diff --git a/src/chambers/canvas/generators/particles.js b/src/chambers/canvas/generators/particles.js index ebca287..85e3660 100644 --- a/src/chambers/canvas/generators/particles.js +++ b/src/chambers/canvas/generators/particles.js @@ -4,9 +4,9 @@ export const id = 'particles'; export const label = 'Particle traces'; export const description = 'Curved bubble-chamber tracks of fictional particles; momenta and decay branches from entropy.'; -export function generate(bytes, { width = 1600, height = 1000 } = {}) { +export function generate(bytes, { width = 1600, height = 1000, palette } = {}) { const rng = makeRng(bytes); - const accent = deriveAccent(bytes); + const accent = deriveAccent(bytes, palette); const W = width, H = height; const cx = W / 2, cy = H / 2; let inner = ''; diff --git a/src/chambers/canvas/generators/spectral.js b/src/chambers/canvas/generators/spectral.js index 74b7d71..7d18151 100644 --- a/src/chambers/canvas/generators/spectral.js +++ b/src/chambers/canvas/generators/spectral.js @@ -2,58 +2,89 @@ import { makeRng, deriveAccent } from '../rng.js'; export const id = 'spectral'; export const label = 'Spectrum'; -export const description = 'A horizontal spectrum plot with emission and absorption lines, like a stellar spectrogram.'; +export const description = 'A luminous stellar spectrum with a continuum profile and readable emission peaks.'; -export function generate(bytes, { width = 1600, height = 1000 } = {}) { +export function generate(bytes, { width = 1600, height = 1000, palette } = {}) { const rng = makeRng(bytes); - const accent = deriveAccent(bytes); - const W = width, H = height; - const margin = 80; - const baselineY = H / 2; - const plotW = W - margin * 2; + const accent = deriveAccent(bytes, palette); + const W = width; + const H = height; + const left = 105; + const right = W - 70; + const top = 95; + const baseline = H * 0.76; + const plotWidth = right - left; + const emissionLines = Array.from({ length: 12 + rng.int(0, 10) }, () => ({ + x: left + rng.next() * plotWidth, + height: rng.range(120, 470), + width: rng.range(2.1, 5.4), + })).sort((a, b) => a.x - b.x); - const emissionLines = 16 + rng.int(0, 18); - const absorptionLines = 12 + rng.int(0, 14); - - let inner = ''; - - // baseline - inner += ``; - - // ticks - const ticks = 20; - for (let i = 0; i <= ticks; i++) { - const x = margin + (i / ticks) * plotW; - const h = i % 5 === 0 ? 12 : 5; - inner += ``; - if (i % 5 === 0) { - inner += `${(380 + i * 20).toFixed(0)}nm`; + const samples = 112; + const profile = []; + for (let index = 0; index <= samples; index += 1) { + const x = left + (index / samples) * plotWidth; + const t = index / samples; + let intensity = 90 + Math.sin(t * Math.PI) * 92 + Math.sin(t * Math.PI * 5 + rng.next() * 0.35) * 22; + for (const line of emissionLines) { + const distance = (x - line.x) / Math.max(12, line.width * 7); + intensity += line.height * 0.46 * Math.exp(-(distance * distance)); } + profile.push({ x, y: Math.max(top + 35, baseline - intensity) }); } + const profilePath = profile.map((point, index) => `${index ? 'L' : 'M'}${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' '); + const areaPath = `${profilePath} L${right} ${baseline} L${left} ${baseline} Z`; - // emission lines (upward, accent) - for (let i = 0; i < emissionLines; i++) { - const x = margin + rng.next() * plotW; - const intensity = 60 + rng.next() * 220; - inner += ``; - } - // absorption (downward, dim) - for (let i = 0; i < absorptionLines; i++) { - const x = margin + rng.next() * plotW; - const depth = 30 + rng.next() * 140; - inner += ``; - } - // a soft continuum band - inner += ``; + const grid = Array.from({ length: 7 }, (_, index) => { + const y = top + index * ((baseline - top) / 6); + return ``; + }).join(''); + const ticks = Array.from({ length: 9 }, (_, index) => { + const x = left + (index / 8) * plotWidth; + const wavelength = Math.round(380 + index * 50); + return ` + ${wavelength} nm`; + }).join(''); + + const lines = emissionLines.map((line, index) => { + const y = Math.max(top + 20, baseline - line.height); + const wavelength = Math.round(380 + ((line.x - left) / plotWidth) * 400); + const label = index % 4 === 0 + ? `${wavelength}` + : ''; + return ` + + + ${label} + `; + }).join(''); + + const absorption = Array.from({ length: 18 + rng.int(0, 16) }, () => { + const x = left + rng.next() * plotWidth; + const depth = rng.range(22, 95); + return ``; + }).join(''); return ` - - - ${inner} - - STELLAR SPECTRUM · COSMIC ENTROPY ATLAS - SORTILUNE + + + + + + + + + ${grid}${ticks} + + + + ${lines} + ${absorption} + RELATIVE INTENSITY / WAVELENGTH + + STELLAR SPECTRUM · ENTROPY EMISSION PROFILE + SORTILUNE `; } diff --git a/src/chambers/canvas/generators/voronoi.js b/src/chambers/canvas/generators/voronoi.js index b278eb8..92c4500 100644 --- a/src/chambers/canvas/generators/voronoi.js +++ b/src/chambers/canvas/generators/voronoi.js @@ -4,9 +4,9 @@ export const id = 'voronoi'; export const label = 'Voronoi cells'; export const description = 'A cosmic-web tessellation derived from N seed points.'; -export function generate(bytes, { width = 1600, height = 1000 } = {}) { +export function generate(bytes, { width = 1600, height = 1000, palette } = {}) { const rng = makeRng(bytes); - const accent = deriveAccent(bytes); + const accent = deriveAccent(bytes, palette); const W = width, H = height; const n = 50 + rng.int(0, 70); const seeds = []; diff --git a/src/chambers/canvas/index.js b/src/chambers/canvas/index.js index 34fa0fa..75f54ba 100644 --- a/src/chambers/canvas/index.js +++ b/src/chambers/canvas/index.js @@ -7,11 +7,18 @@ * Actions: save to archive (svg + json), export PNG at 4K, set as wallpaper. */ -import { h, svg, clear } from '../../lib/dom.js'; +import { invoke } from '@tauri-apps/api/core'; +import { h, clear } from '../../lib/dom.js'; import { CHAMBER_BY_ID } from '../manifest.js'; -import { fileSafeISO, hexToBytes, shortHash } from '../../lib/format.js'; +import { hexToBytes, shortHash } from '../../lib/format.js'; import { sha256Hex } from '../../lib/hash.js'; import { buildProvenance, toast } from '../lottery/_shared.js'; +import { ActionBar, EmptyState, ErrorState, ResultStage, Skeleton } from '../../ui/primitives.js'; +import { + dailyDerivedProvenance, + readDailyRouteContext, + relationsFromDailyRoute, +} from '../../features/today/route-context.js'; import * as constellation from './generators/constellation.js'; import * as spectral from './generators/spectral.js'; @@ -33,20 +40,80 @@ let _ctx = null; let _type = 'random'; let _source = 'quantum'; let _generating = false; +let _busyAction = null; let _last = null; // { typeId, svg, provenance, drawn_at, id, title } +let _error = null; +let _lifecycle = 0; +let _dailyResult = false; +let _palettes = []; +let _paletteId = 'sortilune'; +let _root = null; +let _unsubscribeContent = null; +let _route = null; -export function mount(rootEl, ctx) { +export async function mount(rootEl, ctx, route) { + const lifecycle = ++_lifecycle; _ctx = ctx; + _root = rootEl; + _route = route; + _palettes = ctx.content.canvasPalettes(); + if (!_palettes.some((palette) => palette.id === _paletteId)) _paletteId = 'sortilune'; + _unsubscribeContent?.(); + _unsubscribeContent = ctx.content.subscribe(() => { + _palettes = ctx.content.canvasPalettes(); + if (!_palettes.some((palette) => palette.id === _paletteId)) _paletteId = 'sortilune'; + if (_ctx === ctx && _root) render(_root); + }); + const hydrated = await hydrateDailyCanvas(route); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + if (!hydrated && _dailyResult) { + _dailyResult = false; + _last = null; + } render(rootEl); } export function unmount() { + _lifecycle += 1; _ctx = null; + _generating = false; + _busyAction = null; + _unsubscribeContent?.(); + _unsubscribeContent = null; + _root = null; + _route = null; +} + +async function hydrateDailyCanvas(route) { + const daily = readDailyRouteContext(route); + if (!daily || daily.params.daily_stream !== 'canvas') return false; + const typeId = daily.params.daily_generator || ''; + const raw = daily.params.daily_seed || ''; + const gen = GENERATORS[typeId]; + if (!gen || !/^[0-9a-f]{128}$/.test(raw)) throw new TypeError('Today Canvas handoff is invalid'); + const provenance = dailyDerivedProvenance(route, raw, 'Canvas artwork'); + if (!provenance) throw new TypeError('Today Canvas provenance is incomplete'); + _type = typeId; + _dailyResult = true; + _error = null; + _last = { + typeId, + type_label: gen.label, + svg: gen.generate(hexToBytes(raw), { width: 1600, height: 1000 }), + provenance, + drawn_at: provenance.fetched_at, + id: (await sha256Hex(`${raw}:${typeId}`)).slice(0, 10), + title: titleFor(typeId, provenance), + request: { type: typeId, source: 'daily-record' }, + archive_relations: relationsFromDailyRoute(route), + }; + return true; } function render(rootEl) { clear(rootEl); - const frame = h('div', { class: 'chamber-frame full-bleed reveal' }, [ + const busy = _generating || Boolean(_busyAction); + const frame = h('div', { class: `chamber-frame canvas-frame full-bleed reveal${_last && !_error ? ' has-result' : ''}` }, [ h('div', { class: 'canvas-header' }, [ h('div', null, [ h('div', { class: 'chamber-id' }, ['Chamber · 06/08']), @@ -63,7 +130,8 @@ function render(rootEl) { .map((t) => h('button', { class: 'canvas-type-pill', 'aria-current': t.id === _type ? 'true' : 'false', - onclick: () => { _type = t.id; render(rootEl); }, + disabled: busy, + onclick: () => applyCanvasControl(rootEl, () => { _type = t.id; }), }, [ h('div', { class: 'canvas-type-label' }, [t.label]), h('div', { class: 'small muted' }, [t.description]), @@ -71,11 +139,13 @@ function render(rootEl) { ), ]), h('div', { class: 'row spread', style: { marginTop: '20px' } }, [ + h('div', { class: 'row', style: { gap: '16px', flexWrap: 'wrap' } }, [ h('label', { class: 'row', style: { gap: '8px' } }, [ h('span', { class: 'label', style: { marginBottom: 0 } }, ['Source']), h('select', { class: 'select', style: { width: '220px' }, - onchange: (e) => { _source = e.target.value; }, + disabled: busy, + onchange: (e) => applyCanvasControl(rootEl, () => { _source = e.target.value; }), }, [ ['quantum', 'Quantum (if available)'], @@ -84,45 +154,105 @@ function render(rootEl) { ['random-org', 'random.org'], ['usgs-seismic', 'USGS earthquakes'], ['open-meteo', 'Open-Meteo'], - ['system', 'System fallback'], + ['system', 'On-device randomness'], ].map(([v, l]) => h('option', { value: v, selected: v === _source ? true : null }, [l])) ), ]), - h('button', { + h('label', { class: 'row', style: { gap: '8px' } }, [ + h('span', { class: 'label', style: { marginBottom: 0 } }, ['Palette']), + h('select', { + class: 'select', style: { width: '220px' }, disabled: busy, + onchange: (event) => applyCanvasControl(rootEl, () => { _paletteId = event.target.value; }), + }, _palettes.map((palette) => h('option', { + value: palette.id, selected: palette.id === _paletteId ? true : null, + }, [`${palette.name}${palette.source.type === 'pack' ? ` · ${palette.source.label}` : ''}`]))), + ]), + ]), + !_last && !_error ? h('button', { class: 'btn btn-primary btn-big', onclick: () => generate(rootEl), - disabled: _generating, - }, [_generating ? 'generating…' : (_last ? 'Regenerate' : 'Generate')]), + disabled: busy, + }, [_generating ? 'Generating…' : 'Generate']) : h('span', { + class: 'small muted canvas-auto-status', role: 'status', + }, ['Changes regenerate automatically.']), ]), ]), - _last ? buildResult(rootEl) : h('div', { class: 'canvas-empty muted center-x', style: { padding: '60px' } }, [ - 'Pick a visualization and a source. The universe will paint you a plate.', - ]), + _generating ? ResultStage({ + className: 'canvas-result canvas-loading', + label: 'Generating artwork', + status: 'loading', + children: [Skeleton(4)], + }) : (_error ? buildError(rootEl) : (_last ? buildResult(rootEl, _last) : buildEmptyCanvas())), ]); rootEl.appendChild(frame); } -async function generate(rootEl) { - if (_generating) return; +function applyCanvasControl(rootEl, update) { + if (_generating || _busyAction) return; + update(); + if (_last || _error) void generate(rootEl); + else render(rootEl); +} + +function buildEmptyCanvas() { + const state = EmptyState( + 'Awaiting a seed', + 'Pick a visualization and a source. The universe will paint you a plate.', + ); + state.classList.add('canvas-empty'); + return state; +} + +async function generate(rootEl, repeatRequest = null) { + if (_generating || _busyAction) return; + const ctx = _ctx; + if (!ctx) return; + const lifecycle = _lifecycle; + const request = repeatRequest + ? structuredClone(repeatRequest) + : (() => { + const palette = _palettes.find((candidate) => candidate.id === _paletteId) || _palettes[0]; + return { + type: _type, + source: _source, + palette_id: palette?.id || 'sortilune', + palette_name: palette?.name || 'Sortilune', + palette_colors: palette?.colors?.slice() || [], + palette_source: palette?.source ? structuredClone(palette.source) : null, + }; + })(); _generating = true; + _error = null; render(rootEl); try { // Pick generator - let typeId = _type; + let typeId = request.type; if (typeId === 'random') { - const pick = await _ctx.entropy.request({ kind: 'integer', range: [0, GENERATOR_LIST.length - 1], source: 'preferred' }); + const pick = await ctx.entropy.request({ kind: 'integer', range: [0, GENERATOR_LIST.length - 1], source: 'preferred' }); typeId = GENERATOR_LIST[pick.value]; } const gen = GENERATORS[typeId]; // Draw 64 bytes of entropy - const entropy = await _ctx.entropy.request({ kind: 'bytes', count: 64, source: _source }); + const entropy = await ctx.entropy.request({ + kind: 'bytes', + count: 64, + source: request.source, + fallback: request.source === 'quantum', + }); const bytes = hexToBytes(entropy.provenance.raw); - const svgStr = gen.generate(bytes, { width: 1600, height: 1000 }); + const svgStr = gen.generate(bytes, { width: 1600, height: 1000, palette: request.palette_colors }); const drawnAt = new Date().toISOString(); const idHex = (await sha256Hex(entropy.provenance.raw + typeId)).slice(0, 10); - _last = { + const packReference = request.palette_source?.type === 'pack' + ? ctx.content.reference(request.palette_source, request.palette_id, { + id: request.palette_id, + name: request.palette_name, + colors: request.palette_colors, + }) + : undefined; + const result = { typeId, type_label: gen.label, svg: svgStr, @@ -130,12 +260,22 @@ async function generate(rootEl) { drawn_at: drawnAt, id: idHex, title: titleFor(typeId, entropy.provenance), + request, + ...(packReference ? { pack_reference: packReference } : {}), }; + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + _last = result; + _dailyResult = false; } catch (e) { - toast(`Generation failed: ${e.message || e}`, 'danger'); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + _error = { message: String(e?.message || e), request }; + toast(`Generation failed: ${_error.message}`, 'danger'); + } finally { + if (_ctx === ctx && _lifecycle === lifecycle) { + _generating = false; + render(rootEl); + } } - _generating = false; - render(rootEl); } function titleFor(typeId, provenance) { @@ -146,78 +286,173 @@ function titleFor(typeId, provenance) { 'random-org': 'atmospheric', 'usgs-seismic': 'seismic', 'open-meteo': 'weather', - 'system': 'system', + 'system': 'on-device', + 'daily-record': 'Today', })[provenance.source_id] || provenance.source_id; return `${capitalize(GENERATORS[typeId]?.label || typeId)} — ${date}, seeded by ${sourceLabel} ${shortHash(provenance.raw, 6)}`; } -function buildResult(rootEl) { - return h('section', { class: 'canvas-result' }, [ - h('div', { class: 'canvas-art-stage' }, [ - h('div', { class: 'canvas-art', html: _last.svg }), - ]), - h('div', { class: 'canvas-meta panel' }, [ - h('h3', { class: 'canvas-title' }, [_last.title]), - buildProvenance(_last.provenance), - h('div', { class: 'row', style: { gap: '12px', marginTop: '16px', flexWrap: 'wrap' } }, [ - h('button', { class: 'btn btn-primary', onclick: () => save() }, ['Save to archive']), - h('button', { class: 'btn', onclick: () => exportPNG() }, ['Export PNG (4K)']), - h('button', { class: 'btn', onclick: () => setWallpaper() }, ['Set as wallpaper']), - h('button', { class: 'btn btn-ghost', onclick: () => generate(rootEl) }, ['Regenerate']), +function buildError(rootEl) { + const busy = _generating || Boolean(_busyAction); + return ResultStage({ + className: 'canvas-result canvas-error', + label: 'Artwork generation error', + status: 'error', + children: [ErrorState( + 'Artwork was not generated', + _error.message, + ActionBar({ + label: 'Canvas error actions', + primary: [h('button', { + class: 'btn btn-primary', + disabled: busy, + onclick: () => generate(rootEl, _error.request), + }, ['Retry'])], + secondary: [h('button', { + class: 'btn', + disabled: busy, + onclick: () => { + _error = null; + render(rootEl); + }, + }, ['Clear'])], + }), + )], + }); +} + +function buildResult(rootEl, result) { + const busy = _generating || Boolean(_busyAction); + const desktopAvailable = Boolean(window.__TAURI_INTERNALS__); + return ResultStage({ + className: 'canvas-result', + label: 'Generated artwork', + status: 'result', + children: [ + h('div', { class: 'canvas-art-stage' }, [ + h('div', { class: 'canvas-art', html: result.svg }), ]), - ]), - ]); + h('div', { class: 'canvas-meta' }, [ + h('h3', { class: 'canvas-title' }, [result.title]), + buildProvenance(result.provenance), + ]), + ActionBar({ + label: 'Canvas result actions', + primary: [h('button', { + class: 'btn btn-primary', + disabled: busy || Boolean(result.saved_path), + onclick: () => save(rootEl), + }, [result.saved_path ? 'Saved' : (_busyAction === 'save' ? 'Saving…' : 'Save')])], + secondary: [ + h('button', { + class: 'btn', + disabled: busy, + title: 'Export a 3840 × 2400 PNG.', + onclick: () => exportPNG(rootEl), + }, [_busyAction === 'export' ? 'Exporting…' : 'Export']), + h('button', { + class: 'btn', + disabled: busy || !desktopAvailable, + title: desktopAvailable ? 'Set this artwork as the Windows wallpaper.' : 'Available in the portable desktop app.', + onclick: () => setWallpaper(rootEl), + }, [_busyAction === 'wallpaper' ? 'Setting wallpaper…' : 'Set as wallpaper']), + h('button', { + class: 'btn', + disabled: busy, + onclick: () => generate(rootEl, result.request), + }, ['Repeat']), + h('button', { + class: 'btn btn-ghost', + disabled: busy, + onclick: () => clearCanvasResult(rootEl), + }, ['Clear']), + ], + }), + ], + }); } -async function save() { - if (!_ctx?.archive || !_last) return; - const stamp = fileSafeISO(new Date(_last.drawn_at)); - const baseRel = `archive/canvas/${stamp}__work_${_last.id}`; - try { - await _ctx.archive.writeText(`${baseRel}.svg`, _last.svg); - await _ctx.archive.writeJSON(`${baseRel}.json`, { - human_summary: _last.title, - type: 'canvas-work', - generator: _last.typeId, - ..._last, - // remove the large svg from JSON to keep it small; reference the sibling .svg file - svg: undefined, - svg_path: `${baseRel}.svg`, - }); - toast(`Saved → ${baseRel}.(svg|json)`, 'success'); - } catch (e) { - toast(`Save failed: ${e.message || e}`, 'danger'); - } +function clearCanvasResult(rootEl) { + if (_generating || _busyAction) return; + _last = null; + _error = null; + render(rootEl); } -async function exportPNG() { - if (!_last) return; +async function performResultAction(rootEl, action, task, onSuccess) { + if (_generating || _busyAction || !_last) return; + const ctx = _ctx; + if (!ctx) return; + const result = _last; + const lifecycle = _lifecycle; + _busyAction = action; + render(rootEl); try { - const blob = await renderSvgToPng(_last.svg, 3840, 2400); - triggerDownload(blob, `sortilune-${_last.typeId}-${_last.id}.png`); + const value = await task(ctx, result); + if (_ctx !== ctx || _lifecycle !== lifecycle || _last !== result) return; + onSuccess?.(value, result); } catch (e) { - toast(`Export failed: ${e.message || e}`, 'danger'); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + const labels = { save: 'Save', export: 'Export', wallpaper: 'Wallpaper' }; + toast(`${labels[action] || 'Action'} failed: ${e.message || e}`, 'danger'); + } finally { + if (_ctx === ctx && _lifecycle === lifecycle) { + _busyAction = null; + render(rootEl); + } } } -async function setWallpaper() { - if (!_last) return; - try { - // Render PNG - const blob = await renderSvgToPng(_last.svg, 3840, 2400); +async function save(rootEl) { + if (!_ctx?.archive || !_last || _last.saved_path) return; + await performResultAction(rootEl, 'save', async (ctx, result) => { + const { + svg, + provenance, + saved_path: _savedPath, + archive_relations: archiveRelations = [], + pack_reference: packReference, + ...payload + } = result; + return ctx.archive.save({ + chamber: 'canvas', + type: 'canvas-work', + createdAt: result.drawn_at, + summary: result.title, + payload, + provenance, + relations: ctx.projects.relations(_route, archiveRelations), + pack: packReference, + assets: [{ + role: 'artwork', + mediaType: 'image/svg+xml', + extension: 'svg', + content: svg, + width: 1600, + height: 1000, + }], + }); + }, (saved, result) => { + result.saved_path = saved.path; + toast(`Saved → ${saved.path}`, 'success'); + }); +} + +async function exportPNG(rootEl) { + await performResultAction(rootEl, 'export', async (_ctxValue, result) => { + const blob = await renderSvgToPng(result.svg, 3840, 2400); + triggerDownload(blob, `sortilune-${result.typeId}-${result.id}.png`); + }, () => toast('PNG export ready.', 'success')); +} + +async function setWallpaper(rootEl) { + if (!window.__TAURI_INTERNALS__) return; + await performResultAction(rootEl, 'wallpaper', async (_ctxValue, result) => { + const blob = await renderSvgToPng(result.svg, 3840, 2400); const ab = await blob.arrayBuffer(); const bytes = Array.from(new Uint8Array(ab)); - - if (!window.__TAURI_INTERNALS__) { - toast('Wallpaper-setting requires the desktop build.', 'danger'); - return; - } - const { invoke } = await import('@tauri-apps/api/core'); await invoke('set_wallpaper', { bytes }); - toast('Wallpaper set.', 'success'); - } catch (e) { - toast(`Could not set wallpaper: ${e.message || e}`, 'danger'); - } + }, () => toast('Wallpaper set.', 'success')); } async function renderSvgToPng(svgStr, targetW, targetH) { @@ -235,12 +470,15 @@ async function renderSvgToPng(svgStr, targetW, targetH) { canvas.width = targetW; canvas.height = targetH; const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('2D canvas rendering is unavailable'); // Fill background with surface color in case SVG transparency const bg = cs.getPropertyValue('--bg').trim() || '#0a0e14'; ctx.fillStyle = bg; ctx.fillRect(0, 0, targetW, targetH); ctx.drawImage(img, 0, 0, targetW, targetH); - return await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + const output = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!output) throw new Error('PNG encoding returned no data'); + return output; } finally { URL.revokeObjectURL(url); } @@ -250,7 +488,7 @@ function loadImage(src) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); - img.onerror = (e) => reject(new Error('image decode failed')); + img.onerror = () => reject(new Error('image decode failed')); img.src = src; }); } diff --git a/src/chambers/canvas/rng.js b/src/chambers/canvas/rng.js index e46ff9b..378507c 100644 --- a/src/chambers/canvas/rng.js +++ b/src/chambers/canvas/rng.js @@ -16,8 +16,8 @@ export function makeRng(bytes) { } /** Compute a deterministic accent color from bytes. */ -export function deriveAccent(bytes) { - const palettes = [ +export function deriveAccent(bytes, palette) { + const palettes = Array.isArray(palette) && palette.length > 0 ? palette : [ '#d4a574', '#7fb3d5', '#e08552', '#5fb3a5', '#c98a4a', '#9d8fc0', '#8db58a', '#c97171', ]; return palettes[bytes[0] % palettes.length]; diff --git a/src/chambers/constraint/index.js b/src/chambers/constraint/index.js index 96f2ab0..969492d 100644 --- a/src/chambers/constraint/index.js +++ b/src/chambers/constraint/index.js @@ -4,11 +4,17 @@ * its library via physical entropy. Accept, pass, or close. */ -import { h, svg, clear } from '../../lib/dom.js'; +import { h, clear } from '../../lib/dom.js'; import { CHAMBER_BY_ID } from '../manifest.js'; -import { humanDate, fileSafeISO } from '../../lib/format.js'; +import { humanDate } from '../../lib/format.js'; import { sha256Hex } from '../../lib/hash.js'; import { buildProvenance, toast } from '../lottery/_shared.js'; +import { ActionBar, ResultStage } from '../../ui/primitives.js'; +import { + dailyDerivedProvenance, + readDailyRouteContext, + relationsFromDailyRoute, +} from '../../features/today/route-context.js'; const meta = CHAMBER_BY_ID['constraint']; export const id = 'constraint'; @@ -16,35 +22,73 @@ export const displayName = meta.displayName; export const tagline = meta.tagline; export const icon = meta.icon; -const CATEGORIES = [ - { id: 'creative', label: 'Creative', desc: 'Apply a discipline to your making.' }, - { id: 'behavioral', label: 'Behavioral', desc: 'A small move different from usual.' }, - { id: 'perceptual', label: 'Perceptual', desc: 'Something to notice, count, or watch.' }, - { id: 'linguistic', label: 'Linguistic', desc: 'A constraint on language and speech.' }, - { id: 'whimsical', label: 'Whimsical', desc: 'Slower, stranger, quieter.' }, -]; - let _ctx = null; -let _enabled = new Set(CATEGORIES.map((c) => c.id)); +let _enabled = new Set(); let _pickedCategory = 'any'; let _last = null; let _drawing = false; +let _settling = false; let _libraryCache = {}; +let _lifecycle = 0; +let _dailyResult = false; +let _libraries = []; +let _root = null; +let _unsubscribeContent = null; +let _route = null; -export async function mount(rootEl, ctx) { +export async function mount(rootEl, ctx, route) { + const lifecycle = ++_lifecycle; _ctx = ctx; + _root = rootEl; + _route = route; + refreshLibraries(ctx.content.constraintLibraries()); + _unsubscribeContent?.(); + _unsubscribeContent = ctx.content.subscribe(() => { + _libraryCache = {}; + refreshLibraries(ctx.content.constraintLibraries()); + if (_pickedCategory !== 'any' && !_libraries.some((library) => library.id === _pickedCategory)) _pickedCategory = 'any'; + if (_ctx === ctx && _root) render(_root); + }); + const hydrated = await hydrateDailyConstraint(route); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + if (!hydrated && _dailyResult) { + _dailyResult = false; + _last = null; + } render(rootEl); } export function unmount() { + _lifecycle += 1; _ctx = null; + _drawing = false; + _settling = false; + _unsubscribeContent?.(); + _unsubscribeContent = null; + _root = null; + _route = null; +} + +function refreshLibraries(nextLibraries) { + const previousIds = new Set(_libraries.map((library) => library.id)); + const nextIds = new Set(nextLibraries.map((library) => library.id)); + if (_enabled.size === 0) { + _enabled = new Set(nextIds); + } else { + _enabled = new Set([ + ...[..._enabled].filter((id) => nextIds.has(id)), + ...[...nextIds].filter((id) => !previousIds.has(id)), + ]); + } + _libraries = nextLibraries; } async function loadLibrary(catId) { if (_libraryCache[catId]) return _libraryCache[catId]; try { - const mod = await import(`./libraries/${catId}.json`); - const arr = mod.default || mod; + const definition = _libraries.find((library) => library.id === catId); + if (!definition) throw new RangeError(`unknown constraint library: ${catId}`); + const arr = await definition.load(); _libraryCache[catId] = arr; return arr; } catch (e) { @@ -53,9 +97,41 @@ async function loadLibrary(catId) { } } +async function hydrateDailyConstraint(route) { + const daily = readDailyRouteContext(route); + if (!daily || daily.params.daily_stream !== 'constraint') return false; + const category = daily.params.daily_category; + const itemIndex = Number(daily.params.daily_item_index); + if (!_libraries.some((candidate) => candidate.id === category && candidate.source.type === 'builtin') || !Number.isSafeInteger(itemIndex)) { + throw new TypeError('Today Constraint handoff is invalid'); + } + const library = await loadLibrary(category); + if (itemIndex < 0 || itemIndex >= library.length) { + throw new RangeError('Today Constraint item is outside the built-in library'); + } + const raw = `${daily.dailyRecordId}:${category}:${itemIndex}`; + const provenance = dailyDerivedProvenance(route, raw, 'Constraint'); + if (!provenance) throw new TypeError('Today Constraint provenance is incomplete'); + _pickedCategory = category; + _dailyResult = true; + _last = { + category, + text: library[itemIndex].text, + item_id: library[itemIndex].id, + category_label: _libraries.find((candidate) => candidate.id === category)?.label || category, + provenance, + drawn_at: provenance.fetched_at, + request: { pickedCategory: category, enabled: [category] }, + id: (await sha256Hex(raw)).slice(0, 10), + archive_relations: relationsFromDailyRoute(route), + }; + return true; +} + function render(rootEl) { clear(rootEl); - const frame = h('div', { class: 'chamber-frame reveal' }, [ + const busy = _drawing || _settling; + const frame = h('div', { class: `chamber-frame constraint-frame reveal${_last ? ' has-result' : ''}` }, [ h('div', { class: 'chamber-header' }, [ h('div', null, [ h('div', { class: 'chamber-id' }, ['Chamber · 04/08']), @@ -88,7 +164,7 @@ function render(rootEl) { h('ol', null, [ h('li', null, ['Below, pick a category (or "Any enabled" to let entropy choose) and toggle off any categories you don’t want drawn from.']), h('li', null, ['Click ', h('strong', null, ['Draw a constraint']), '.']), - h('li', null, ['When the result appears: ', h('strong', null, ['Accept and archive']), ' saves it to the archive with today’s date; ', h('strong', null, ['Pass']), ' draws another (both are saved so the record stays honest); ', h('strong', null, ['Just close']), ' discards it.']), + h('li', null, ['When the result appears: ', h('strong', null, ['Accept & save']), ' writes it to the Archive; ', h('strong', null, ['Pass & repeat']), ' archives the pass before drawing another; ', h('strong', null, ['Clear']), ' dismisses it without saving.']), ]), ]), ]), @@ -96,14 +172,15 @@ function render(rootEl) { h('section', { class: 'constraint-categories panel' }, [ h('div', { class: 'label' }, ['Category']), h('div', { class: 'category-grid' }, - [{ id: 'any', label: 'Any enabled', desc: 'Draw from any enabled category.' }, ...CATEGORIES].map((c) => + [{ id: 'any', label: 'Any enabled', description: 'Draw from any enabled category.' }, ..._libraries].map((c) => h('button', { class: 'category-pill', 'aria-current': c.id === _pickedCategory ? 'true' : 'false', + disabled: busy, onclick: () => { _pickedCategory = c.id; render(rootEl); }, }, [ h('span', { class: 'cat-label' }, [c.label]), - h('span', { class: 'cat-desc small muted' }, [c.desc]), + h('span', { class: 'cat-desc small muted' }, [c.description]), c.id !== 'any' ? h('label', { class: 'cat-enable', onclick: (e) => e.stopPropagation(), @@ -111,6 +188,7 @@ function render(rootEl) { h('input', { type: 'checkbox', checked: _enabled.has(c.id) ? true : null, + disabled: busy, onchange: (e) => { if (e.target.checked) _enabled.add(c.id); else _enabled.delete(c.id); render(rootEl); @@ -123,81 +201,208 @@ function render(rootEl) { ), ]), - h('div', { class: 'center', style: { margin: '40px 0' } }, [ + h('div', { class: 'center constraint-draw-action', style: { margin: '40px 0' } }, [ h('button', { class: 'btn btn-primary btn-big', onclick: () => draw(rootEl), - disabled: _drawing, - }, [_drawing ? 'drawing…' : (_last ? 'Draw another' : 'Draw a constraint')]), + disabled: busy || Boolean(_last), + }, [_drawing ? 'Drawing…' : (_last ? 'Clear result before a new draw' : 'Draw a constraint')]), ]), - _last && !_drawing ? buildResultPanel(_last, rootEl) : null, + _drawing ? ResultStage({ + className: 'constraint-result constraint-loading', + label: 'Drawing constraint', + status: 'loading', + children: [h('p', { class: 'muted center-x' }, ['Drawing a constraint…'])], + }) : (_last ? buildResultPanel(_last, rootEl) : null), ]); rootEl.appendChild(frame); } -async function draw(rootEl) { - if (_drawing) return; +async function draw(rootEl, repeatRequest = null) { + if (_drawing || _settling) return; + const ctx = _ctx; + if (!ctx) return; + const lifecycle = _lifecycle; + const request = repeatRequest + ? { pickedCategory: repeatRequest.pickedCategory, enabled: [...repeatRequest.enabled] } + : { pickedCategory: _pickedCategory, enabled: [..._enabled] }; _drawing = true; render(rootEl); try { - let categoryId = _pickedCategory; + let categoryId = request.pickedCategory; if (categoryId === 'any') { - const pool = [..._enabled]; + const pool = request.enabled; if (pool.length === 0) throw new Error('No categories enabled'); - const catPick = await _ctx.entropy.request({ kind: 'integer', range: [0, pool.length - 1], source: 'preferred' }); + const catPick = await ctx.entropy.request({ kind: 'integer', range: [0, pool.length - 1], source: 'preferred' }); categoryId = pool[catPick.value]; } const lib = await loadLibrary(categoryId); if (!lib.length) throw new Error(`No items in ${categoryId} library`); - const pick = await _ctx.entropy.request({ kind: 'integer', range: [0, lib.length - 1], source: 'preferred' }); + const pick = await ctx.entropy.request({ kind: 'integer', range: [0, lib.length - 1], source: 'preferred' }); const constraint = lib[pick.value]; - _last = { + const definition = _libraries.find((library) => library.id === categoryId); + const packReference = definition?.source.type === 'pack' + ? ctx.content.reference(definition.source, constraint.id, constraint) + : undefined; + const result = { category: categoryId, - text: constraint, + category_label: definition?.label || categoryId, + text: constraint.text, + item_id: constraint.id, provenance: pick.provenance, drawn_at: new Date().toISOString(), + request, + ...(packReference ? { pack_reference: packReference } : {}), }; - _last.id = (await sha256Hex(_last.text + _last.drawn_at)).slice(0, 10); + result.id = (await sha256Hex(result.text + result.drawn_at)).slice(0, 10); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + _last = result; } catch (e) { - _last = { error: String(e?.message || e) }; + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + _last = { error: String(e?.message || e), request }; + } finally { + if (_ctx === ctx && _lifecycle === lifecycle) { + _drawing = false; + render(rootEl); + } } - _drawing = false; - render(rootEl); } function buildResultPanel(r, rootEl) { if (r.error) { - return h('div', { class: 'panel', style: { color: 'var(--danger)' } }, [r.error]); + return ResultStage({ + className: 'constraint-result constraint-error', + label: 'Constraint draw error', + status: 'error', + children: [ + h('p', { role: 'alert' }, [r.error]), + ActionBar({ + label: 'Constraint error actions', + primary: [h('button', { + class: 'btn btn-primary', + disabled: _drawing || _settling, + onclick: () => { + _last = null; + draw(rootEl, r.request); + }, + }, ['Retry'])], + secondary: [h('button', { + class: 'btn', + disabled: _drawing || _settling, + onclick: () => clearResult(rootEl), + }, ['Clear'])], + }), + ], + }); } - return h('div', { class: 'constraint-result panel reveal stack-4' }, [ - h('div', { class: 'small muted center-x' }, [ - h('span', { class: 'badge badge-accent' }, [r.category]), - h('span', null, [` · drawn at ${humanDate(r.drawn_at)}`]), - ]), - h('blockquote', { class: 'constraint-text' }, [r.text]), - buildProvenance(r.provenance), - h('div', { class: 'row center', style: { gap: '12px' } }, [ - h('button', { class: 'btn btn-primary', onclick: () => accept(r) }, ['Accept and archive']), - h('button', { class: 'btn', onclick: () => draw(rootEl) }, ['Pass (draw another)']), - h('button', { class: 'btn btn-ghost', onclick: () => { _last = null; render(rootEl); } }, ['Just close']), - ]), - ]); + const busy = _drawing || _settling; + return ResultStage({ + className: 'constraint-result reveal', + label: 'Drawn constraint', + status: 'result', + children: [ + h('div', { class: 'small muted center-x' }, [ + h('span', { class: 'badge badge-accent' }, [r.category_label || r.category]), + h('span', null, [` · drawn at ${humanDate(r.drawn_at)}`]), + ]), + h('blockquote', { class: 'constraint-text' }, [r.text]), + buildProvenance(r.provenance), + ActionBar({ + label: 'Constraint result actions', + primary: [h('button', { + class: 'btn btn-primary', + disabled: busy || Boolean(r.archived_disposition), + onclick: () => accept(r, rootEl), + }, [r.archived_disposition ? 'Saved' : (_settling ? 'Saving…' : 'Accept & save')])], + secondary: [ + h('button', { + class: 'btn', + disabled: busy || Boolean(r.archived_disposition), + onclick: () => pass(r, rootEl), + }, [_settling ? 'Saving…' : 'Pass & repeat']), + h('button', { + class: 'btn btn-ghost', + disabled: busy, + onclick: () => clearResult(rootEl), + }, ['Clear']), + ], + }), + ], + }); } -async function accept(r) { - if (!_ctx?.archive) { toast('Archive not available', 'danger'); return; } - const stamp = fileSafeISO(new Date(r.drawn_at)); - const rel = `archive/constraint/${stamp}__constraint_${r.id}.json`; +function clearResult(rootEl) { + if (_drawing || _settling) return; + _last = null; + render(rootEl); +} + +async function accept(r, rootEl) { + if (_drawing || _settling || r.archived_disposition) return; + const ctx = _ctx; + if (!ctx?.archive) { toast('Archive not available', 'danger'); return; } + const lifecycle = _lifecycle; + _settling = true; + render(rootEl); try { - await _ctx.archive.writeJSON(rel, { - human_summary: `Constraint accepted (${r.category}): "${r.text}"`, - type: 'constraint', - ...r, - }); + const rel = await writeConstraint(ctx, r, { accepted: true, passed: false }); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + r.archived_disposition = 'accepted'; toast(`Saved → ${rel}`, 'success'); } catch (e) { + if (_ctx !== ctx || _lifecycle !== lifecycle) return; toast(`Save failed: ${e.message || e}`, 'danger'); + } finally { + if (_ctx === ctx && _lifecycle === lifecycle) { + _settling = false; + render(rootEl); + } } } + +async function pass(r, rootEl) { + if (_drawing || _settling || r.archived_disposition) return; + const ctx = _ctx; + if (!ctx?.archive) { toast('Archive not available; pass was not recorded', 'danger'); return; } + const lifecycle = _lifecycle; + _settling = true; + render(rootEl); + try { + await writeConstraint(ctx, r, { accepted: false, passed: true }); + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + toast('Passed constraint archived.', 'success'); + } catch (e) { + if (_ctx !== ctx || _lifecycle !== lifecycle) return; + toast(`Could not archive the pass: ${e.message || e}`, 'danger'); + _settling = false; + render(rootEl); + return; + } + _settling = false; + _last = null; + await draw(rootEl, r.request); +} + +async function writeConstraint(ctx, r, disposition) { + const verb = disposition.passed ? 'passed' : 'accepted'; + const { + provenance, + archived_disposition: _archivedDisposition, + archive_relations: archiveRelations = [], + pack_reference: packReference, + ...result + } = r; + const saved = await ctx.archive.save({ + chamber: 'constraint', + type: 'constraint', + createdAt: r.drawn_at, + summary: `Constraint ${verb} (${r.category}): "${r.text}"`, + payload: { ...result, ...disposition, disposition_at: new Date().toISOString() }, + provenance, + relations: ctx.projects.relations(_route, archiveRelations), + pack: packReference, + }); + return saved.path; +} diff --git a/src/chambers/decider/certificate.js b/src/chambers/decider/certificate.js index 840d5a6..12d56c0 100644 --- a/src/chambers/decider/certificate.js +++ b/src/chambers/decider/certificate.js @@ -1,8 +1,8 @@ /** - * Decision Certificate: render a beautiful PNG certificate for a decision - * result. The PNG is suitable for sharing or printing. + * Decision receipt: render a PNG summary of a decision result. + * The complete machine-readable record remains the archive JSON. * - * Output: 1600x1000 px (16:10), dark amber/blue palette. The certificate + * Output: 1600x1000 px (16:10), dark amber/blue palette. The receipt * shows the question, the chosen option, full provenance, the decision ID, * and verification instructions. */ @@ -21,7 +21,7 @@ const PALETTE = { highlight: '#7fb3d5', }; -export function renderCertificate(decision) { +export function renderDecisionReceipt(decision) { const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; @@ -49,7 +49,7 @@ export function renderCertificate(decision) { c.fillStyle = PALETTE.faint; c.font = '14px "JetBrains Mono", "Cascadia Code", Consolas, monospace'; - c.fillText('DECISION CERTIFICATE', 80, 112); + c.fillText('DECISION RECEIPT', 80, 112); // date right c.textAlign = 'right'; @@ -85,10 +85,16 @@ export function renderCertificate(decision) { const optsToShow = opts.slice(0, 8); optsToShow.forEach((o, i) => { const text = o.text + (o.weight && o.weight !== 1 ? ` · weight ${o.weight}` : ''); - const isChosen = o.text === decision.chosen; + const isChosen = Number.isInteger(decision.chosen_index) + ? decision.chosen_index === i + : o.text === decision.chosen; c.fillStyle = isChosen ? PALETTE.accent : PALETTE.text; c.fillText((isChosen ? '◆ ' : '○ ') + text, 80, 420 + i * optLineH); }); + if (opts.length > optsToShow.length) { + c.fillStyle = PALETTE.faint; + c.fillText(`+ ${opts.length - optsToShow.length} more option${opts.length - optsToShow.length === 1 ? '' : 's'} in archive JSON`, 80, 420 + optsToShow.length * optLineH); + } // tick c.strokeStyle = PALETTE.border; @@ -117,6 +123,8 @@ export function renderCertificate(decision) { c.font = '12px "JetBrains Mono", monospace'; const provLines = [ `decision_id ${decision.id || ''}`, + `receipt_hash ${shortHash(decision.verification_hash || '', 24)}`, + `hash_algorithm ${decision.verification_algorithm || 'legacy'}`, `source ${decision.provenance?.source_id || ''}`, `fetched_at ${decision.provenance?.fetched_at || ''}`, `raw ${shortHash(decision.provenance?.raw || '', 24)}`, @@ -132,16 +140,16 @@ export function renderCertificate(decision) { // verify line c.textAlign = 'right'; c.fillStyle = PALETTE.faint; - c.fillText('verify against the source at the URL above · sortilune', W - 80, 940); + c.fillText('Full inputs and provenance are stored in the archive JSON · sortilune', W - 80, 940); return canvas; } /** Export the canvas to a PNG blob and trigger a download. */ -export async function exportCertificate(decision, filename) { - const canvas = renderCertificate(decision); +export async function exportDecisionReceipt(decision, filename) { + const canvas = renderDecisionReceipt(decision); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); - if (!blob) throw new Error('certificate export failed'); + if (!blob) throw new Error('receipt export failed'); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; diff --git a/src/chambers/decider/index.js b/src/chambers/decider/index.js index e51e3e9..ff2a95f 100644 --- a/src/chambers/decider/index.js +++ b/src/chambers/decider/index.js @@ -1,16 +1,19 @@ /** * The Decider chamber. * Pose a question. List options. Pick a source / stakes. Decide. - * High-stakes mode pins NIST Beacon for cryptographic provenance. + * High-stakes mode requires NIST Beacon without silent fallback. * Re-rolling is allowed; the previous draw is also saved with a re-roll note. */ import { h, svg, clear } from '../../lib/dom.js'; import { CHAMBER_BY_ID } from '../manifest.js'; -import { humanDate, fileSafeISO, shortHash } from '../../lib/format.js'; +import { humanDate } from '../../lib/format.js'; import { sha256Hex } from '../../lib/hash.js'; import { buildProvenance, toast } from '../lottery/_shared.js'; -import { renderCertificate, exportCertificate } from './certificate.js'; +import { exportDecisionReceipt } from './certificate.js'; +import { ActionBar, ResultStage } from '../../ui/primitives.js'; + +const DECISION_HASH_ALGORITHM = 'sha256:sortilune-decider-receipt-v2'; const meta = CHAMBER_BY_ID['decider']; export const id = 'decider'; @@ -29,9 +32,12 @@ let _source = 'preferred'; let _useWeights = false; let _last = null; // most recent decision result (saved or not) let _prior = null; // previous decision (for re-roll receipt) +let _deciding = false; +let _route = null; -export function mount(rootEl, ctx) { +export function mount(rootEl, ctx, route) { _ctx = ctx; + _route = route; // Reset transient state when entering the chamber. if (!_question && _options.every((o) => !o.text)) { _options = [ @@ -44,6 +50,7 @@ export function mount(rootEl, ctx) { export function unmount() { _ctx = null; + _route = null; } function render(rootEl) { @@ -53,7 +60,8 @@ function render(rootEl) { h('span', { class: 'label' }, ['Your question']), h('textarea', { class: 'textarea', rows: 3, placeholder: 'What needs deciding?', - oninput: (e) => { _question = e.target.value; }, + maxlength: 2000, + oninput: (e) => { _question = e.target.value.slice(0, 2000); }, value: _question, }), ]), @@ -69,10 +77,14 @@ function render(rootEl) { ]), ]), h('div', { class: 'option-list' }, - _options.map((opt, idx) => optionRow(opt, idx, rootEl)) + _options.map((opt, idx) => optionRow(opt, idx)) ), h('div', { class: 'row', style: { gap: '8px' } }, [ - h('button', { class: 'btn btn-ghost small', onclick: () => { _options.push({ text: '', weight: 1 }); render(rootEl); } }, ['+ option']), + h('button', { + class: 'btn btn-ghost small', + disabled: _options.length >= 100, + onclick: () => { _options.push({ text: '', weight: 1 }); render(rootEl); }, + }, ['+ option']), _options.length > 2 ? h('button', { class: 'btn btn-ghost small', onclick: () => { _options.pop(); render(rootEl); } }, ['− remove last']) : null, @@ -91,6 +103,7 @@ function render(rootEl) { h('span', { class: 'label' }, ['Source']), h('select', { class: 'select', style: { width: '240px' }, + 'aria-label': 'Entropy source', disabled: _stakes === 'high', onchange: (e) => { _source = e.target.value; }, }, @@ -101,7 +114,7 @@ function render(rootEl) { ['random-org', 'random.org (atmospheric)'], ['usgs-seismic', 'USGS earthquakes'], ['open-meteo', 'Open-Meteo weather'], - ['system', 'System fallback'], + ['system', 'On-device randomness'], ].map(([v, l]) => h('option', { value: v, selected: v === _source ? true : null }, [l])) ), ]), @@ -111,7 +124,8 @@ function render(rootEl) { h('button', { class: 'btn btn-primary btn-big', onclick: () => decide(rootEl), - }, [_last ? 'Decide again' : 'Decide']), + disabled: _deciding, + }, [_deciding ? 'deciding…' : 'Decide']), ]), ]); @@ -127,24 +141,25 @@ function render(rootEl) { h('p', { class: 'chamber-tagline' }, [meta.tagline]), ]), ]), - h('div', { class: 'decider-body' }, [left, right]), + h('div', { class: `decider-body${_last ? ' has-result' : ''}` }, _last ? [right, left] : [left, right]), ]); rootEl.appendChild(frame); } -function optionRow(opt, idx, rootEl) { +function optionRow(opt, idx) { return h('div', { class: 'option-row' }, [ h('span', { class: 'option-tick mono small' }, [String(idx + 1).padStart(2, '0')]), h('input', { class: 'input', type: 'text', placeholder: `Option ${idx + 1}`, - oninput: (e) => { _options[idx].text = e.target.value; }, + maxlength: 500, + oninput: (e) => { _options[idx].text = e.target.value.slice(0, 500); }, value: opt.text, }), _useWeights ? h('input', { class: 'input', type: 'number', min: 1, max: 999, value: String(opt.weight), style: { width: '90px' }, - oninput: (e) => { _options[idx].weight = Math.max(1, parseInt(e.target.value, 10) || 1); }, + oninput: (e) => { _options[idx].weight = Math.max(1, Math.min(999, parseInt(e.target.value, 10) || 1)); }, }) : null, ]); @@ -169,74 +184,163 @@ function buildEmptyResult() { ]); } -async function decide(rootEl) { - const opts = _options.filter((o) => o.text.trim()); +async function decide(rootEl, suppliedInput = null) { + if (_deciding) return; + const context = _ctx; + if (!context?.entropy || !context?.archive) { toast('Decision services are unavailable', 'danger'); return; } + const input = suppliedInput || currentDecisionInput(); + const opts = input.options.filter((option) => option.text.trim()); if (opts.length < 2) { toast('Need at least 2 options', 'danger'); return; } - if (!_question.trim()) { toast('Add a question first', 'danger'); return; } + if (!input.question.trim()) { toast('Add a question first', 'danger'); return; } + + _deciding = true; + render(rootEl); - // record the prior for re-roll honesty - if (_last) _prior = _last; + // Persist the visible result before a re-roll so a later draw cannot erase it. + const prior = _last; + if (prior) { + try { + await writeDecision(prior, { + outcome_status: 'reroll-pending', + rerolled_from: prior.rerolled_from || (_prior ? decisionRef(_prior) : null), + }, context.archive); + } catch (e) { + _deciding = false; + if (_ctx) toast(`Could not preserve the previous draw: ${e.message || e}`, 'danger'); + if (!_ctx) return; + render(rootEl); + return; + } + } try { - const source = _stakes === 'high' ? 'nist-beacon' : _source; + const source = input.stakes === 'high' ? 'nist-beacon' : input.source; let chosenIdx; let result; - if (_useWeights) { - // Build a weighted-by-count expansion (each option appears weight times) - const expanded = []; - opts.forEach((o, i) => { for (let k = 0; k < o.weight; k++) expanded.push(i); }); - result = await _ctx.entropy.request({ kind: 'integer', range: [0, expanded.length - 1], source }); - chosenIdx = expanded[result.value]; + if (input.useWeights) { + const totalWeight = opts.reduce((sum, option) => sum + option.weight, 0); + result = await context.entropy.request({ kind: 'integer', range: [1, totalWeight], source }); + chosenIdx = weightedIndex(opts, result.value); } else { - result = await _ctx.entropy.request({ kind: 'integer', range: [0, opts.length - 1], source }); + result = await context.entropy.request({ kind: 'integer', range: [0, opts.length - 1], source }); chosenIdx = result.value; } const chosenText = opts[chosenIdx].text; const decisionInputs = { - question: _question, - options: opts.map((o) => ({ text: o.text, weight: _useWeights ? o.weight : 1 })), + question: input.question, + options: opts.map((o) => ({ text: o.text, weight: input.useWeights ? o.weight : 1 })), chosen: chosenText, - stakes: _stakes, + chosen_index: chosenIdx, + stakes: input.stakes, + weighted: input.useWeights, + requested_source: source, source: result.provenance.source_id, drawn_at: new Date().toISOString(), provenance: result.provenance, + rerolled_from: prior ? decisionRef(prior) : null, + verification_algorithm: DECISION_HASH_ALGORITHM, }; - decisionInputs.id = await deterministicId(decisionInputs); + decisionInputs.verification_hash = await decisionVerificationHash(decisionInputs); + decisionInputs.id = decisionInputs.verification_hash.slice(0, 12); + _prior = prior; _last = decisionInputs; + + if (prior) { + try { + await writeDecision(prior, { + outcome_status: 'rerolled', + rerolled_from: prior.rerolled_from || null, + rerolled_to: decisionRef(decisionInputs), + }, context.archive); + } catch (e) { + if (_ctx) toast(`New draw completed, but the re-roll link could not be updated: ${e.message || e}`, 'danger'); + } + } } catch (e) { - toast(`Decide failed: ${e.message || e}`, 'danger'); - return; + if (_ctx) toast(`Decide failed: ${e.message || e}`, 'danger'); + if (prior) { + try { + await writeDecision(prior, { + outcome_status: 'saved', + rerolled_from: prior.rerolled_from || (_prior ? decisionRef(_prior) : null), + }, context.archive); + } catch (restoreError) { + if (_ctx) toast(`Previous draw remains marked pending: ${restoreError.message || restoreError}`, 'danger'); + } + } } + _deciding = false; + if (!_ctx) return; render(rootEl); } -async function deterministicId(d) { - const seed = [d.question, JSON.stringify(d.options), d.chosen, d.drawn_at, d.provenance?.raw].join('|'); - const hex = await sha256Hex(seed); - return hex.slice(0, 12); +function currentDecisionInput() { + return { + question: _question, + options: _options.map((option) => ({ ...option })), + stakes: _stakes, + source: _source, + useWeights: _useWeights, + }; +} + +function repeatedDecisionInput(decision) { + return { + question: decision.question, + options: decision.options.map((option) => ({ ...option })), + stakes: decision.stakes, + source: decision.requested_source || decision.source, + useWeights: decision.weighted ?? decision.options.some((option) => option.weight !== 1), + }; +} + +export function weightedIndex(options, ticket) { + if (!Array.isArray(options) || options.length < 1) throw new TypeError('weighted options must be a non-empty array'); + const total = options.reduce((sum, option) => { + if (!Number.isSafeInteger(option?.weight) || option.weight < 1) throw new TypeError('weights must be positive safe integers'); + return sum + option.weight; + }, 0); + if (!Number.isSafeInteger(total) || !Number.isSafeInteger(ticket) || ticket < 1 || ticket > total) { + throw new RangeError('weighted ticket is outside the available weight range'); + } + let cursor = ticket; + return options.findIndex((option) => { + cursor -= option.weight; + return cursor <= 0; + }); +} + +export async function decisionVerificationHash(decision) { + const seed = JSON.stringify([ + decision.verification_algorithm || DECISION_HASH_ALGORITHM, + decision.question, + decision.options.map((option) => [option.text, option.weight]), + decision.chosen, + decision.chosen_index, + decision.stakes, + Boolean(decision.weighted), + decision.requested_source, + decision.source, + decision.drawn_at, + decision.provenance?.source_id, + decision.provenance?.fetched_at, + decision.provenance?.raw, + decision.provenance?.signature ?? null, + decision.rerolled_from + ? [decision.rerolled_from.id, decision.rerolled_from.chosen, decision.rerolled_from.drawn_at] + : null, + ]); + return sha256Hex(seed); } function buildResultPanel(decision, prior, rootEl) { const lines = [ - h('div', { class: 'cert-tagline' }, ['DECISION CERTIFICATE']), + h('div', { class: 'cert-tagline' }, ['DECISION RECEIPT']), h('div', { class: 'spread' }, [ h('div', { class: 'mono small muted' }, [decision.drawn_at]), h('div', { class: 'mono small muted' }, [`id ${decision.id}`]), ]), h('hr', { class: 'hr' }), - h('div', { class: 'label' }, ['The question']), - h('p', { class: 'cert-question' }, [decision.question]), - h('div', { class: 'label', style: { marginTop: '20px' } }, ['Options']), - h('ul', { class: 'cert-options' }, - decision.options.map((o) => h('li', { - class: o.text === decision.chosen ? 'is-chosen' : '', - }, [ - h('span', { class: 'cert-mark' }, [o.text === decision.chosen ? '◆' : '○']), - h('span', null, [o.text]), - o.weight && o.weight !== 1 ? h('span', { class: 'small muted' }, [` · weight ${o.weight}`]) : null, - ])) - ), - h('hr', { class: 'hr' }), h('div', { class: 'cert-chosen' }, [ h('div', { class: 'label' }, ['Chosen']), h('div', { class: 'cert-chosen-big' }, [decision.chosen]), @@ -245,44 +349,104 @@ function buildResultPanel(decision, prior, rootEl) { decision.stakes === 'high' ? 'high-stakes mode · NIST-anchored' : 'casual mode', ]), buildProvenance(decision.provenance), - prior ? h('div', { class: 'panel-raised', style: { marginTop: '16px' } }, [ - h('div', { class: 'label' }, ['Previous draw (re-rolled)']), - h('p', { class: 'muted small' }, [ - `${humanDate(prior.drawn_at)} — was ${prior.chosen}. Re-roll honesty: the previous draw remains in the archive.`, + h('hr', { class: 'hr' }), + h('div', { class: 'label' }, ['The question']), + h('p', { class: 'cert-question' }, [decision.question]), + h('details', { class: 'decider-receipt-details' }, [ + h('summary', null, ['Decision details']), + h('div', { class: 'decider-receipt-details-body' }, [ + decision.weighted ? h('p', { class: 'small muted decider-allocation' }, ['Weighted allocation']) : null, + h('div', { class: 'label' }, ['Options']), + h('ul', { class: 'cert-options' }, + decision.options.map((o, index) => h('li', { + class: isChosenOption(decision, o, index) ? 'is-chosen' : '', + }, [ + h('span', { class: 'cert-mark' }, [isChosenOption(decision, o, index) ? '◆' : '○']), + h('span', null, [o.text]), + o.weight && o.weight !== 1 ? h('span', { class: 'small muted' }, [` · weight ${o.weight}`]) : null, + ])) + ), + prior ? h('div', { class: 'panel-raised', style: { marginTop: '16px' } }, [ + h('div', { class: 'label' }, ['Previous draw (re-rolled)']), + h('p', { class: 'muted small' }, [ + `${humanDate(prior.drawn_at)} — was ${prior.chosen}. Re-roll honesty: the previous draw remains in the archive.`, + ]), + ]) : null, ]), - ]) : null, - h('div', { class: 'row center', style: { marginTop: '20px', gap: '12px' } }, [ - h('button', { class: 'btn', onclick: () => saveDecision(decision, prior) }, ['Save to archive']), - h('button', { class: 'btn', onclick: () => exportCertificate(decision) }, ['Export as image']), - h('button', { class: 'btn btn-ghost', onclick: () => { _last = null; _prior = null; render(rootEl); } }, ['Clear']), ]), + ActionBar({ + label: 'Decision result actions', + primary: [h('button', { + class: 'btn btn-primary', + disabled: _deciding, + onclick: () => saveDecision(decision, prior), + }, ['Save'])], + secondary: [ + h('button', { + class: 'btn', + disabled: _deciding, + title: 'Repeat this receipt with the same question, options, stakes, and requested source', + onclick: () => decide(rootEl, repeatedDecisionInput(decision)), + }, [_deciding ? 'Repeating…' : 'Repeat']), + h('button', { class: 'btn', disabled: _deciding, onclick: () => exportReceipt(decision) }, ['Export']), + h('button', { + class: 'btn btn-ghost', + disabled: _deciding, + onclick: () => { _last = null; _prior = null; render(rootEl); }, + }, ['Clear']), + ], + }), ]; - return h('div', { class: 'cert reveal' }, lines); + return ResultStage({ className: 'cert reveal', label: 'Decision receipt', status: 'result', children: lines }); +} + +async function exportReceipt(decision) { + try { + await exportDecisionReceipt(decision); + } catch (e) { + toast(`Receipt export failed: ${e.message || e}`, 'danger'); + } +} + +function isChosenOption(decision, option, index) { + return Number.isInteger(decision.chosen_index) + ? decision.chosen_index === index + : option.text === decision.chosen; } async function saveDecision(decision, prior) { - if (!_ctx?.archive) return; - const stamp = fileSafeISO(new Date(decision.drawn_at)); - const rel = `archive/decider/${stamp}__decision_${decision.id}.json`; - const body = { - human_summary: `Decision: "${decision.question}" → ${decision.chosen} (${decision.provenance.source_id})`, - type: 'decider-decision', - ...decision, - rerolled_from: prior ? { id: prior.id, chosen: prior.chosen, drawn_at: prior.drawn_at } : null, - }; + if (!_ctx?.archive) { toast('Archive not available', 'danger'); return; } try { - await _ctx.archive.writeJSON(rel, body); + const rel = await writeDecision(decision, { + outcome_status: 'saved', + rerolled_from: decision.rerolled_from || (prior ? decisionRef(prior) : null), + }, _ctx.archive, _ctx.projects.relations(_route)); toast(`Saved → ${rel}`, 'success'); - if (prior) { - const priorRel = `archive/decider/${fileSafeISO(new Date(prior.drawn_at))}__decision_${prior.id}.json`; - await _ctx.archive.writeJSON(priorRel, { - human_summary: `Decision: "${prior.question}" → ${prior.chosen} (re-rolled)`, - type: 'decider-decision', - ...prior, - rerolled_to: { id: decision.id, chosen: decision.chosen, drawn_at: decision.drawn_at }, - }); - } } catch (e) { toast(`Save failed: ${e.message || e}`, 'danger'); } } + +function decisionRef(decision) { + return decision ? { id: decision.id, chosen: decision.chosen, drawn_at: decision.drawn_at } : null; +} + +async function writeDecision(decision, links = {}, repository = _ctx?.archive, relations = []) { + if (!repository) throw new Error('archive is not available'); + const { provenance, ...payload } = decision; + const saved = await repository.save({ + chamber: 'decider', + type: 'decider-decision', + createdAt: decision.drawn_at, + summary: `Decision: "${decision.question}" → ${decision.chosen} (${provenance.source_id})`, + payload: { + ...payload, + outcome_status: links.outcome_status || 'saved', + rerolled_from: links.rerolled_from || null, + rerolled_to: links.rerolled_to || null, + }, + provenance, + relations, + }); + return saved.path; +} diff --git a/src/chambers/diary/index.js b/src/chambers/diary/index.js index 152b406..7cd2b74 100644 --- a/src/chambers/diary/index.js +++ b/src/chambers/diary/index.js @@ -2,13 +2,21 @@ * The Diary chamber. * Daily-sticky prompt drawn from a NIST beacon pulse: question, word, number, * color, cardinal direction. Free-form Markdown entry below. Autosaves to - * archive/diary/.md with YAML frontmatter. + * Versioned archive records; legacy Markdown entries remain readable. */ import { h, clear } from '../../lib/dom.js'; import { CHAMBER_BY_ID } from '../manifest.js'; -import { localDateKey, humanDate, hexToBytes } from '../../lib/format.js'; +import { localDateKey, hexToBytes } from '../../lib/format.js'; import { buildProvenance, toast } from '../lottery/_shared.js'; +import { ActionBar, ResultStage } from '../../ui/primitives.js'; +import { convert } from '../../lib/entropy/convert.js'; +import { + dailyDerivedProvenance, + readDailyRouteContext, + relationsFromDailyRoute, +} from '../../features/today/route-context.js'; +import { parseLocalDate } from '../../features/today/time.js'; const meta = CHAMBER_BY_ID['diary']; export const id = 'diary'; @@ -29,29 +37,71 @@ let _drawing = false; let _saveTimer = null; let _prompts = []; let _words = []; +let _promptItems = []; +let _wordItems = []; +let _diaryLibraries = []; +let _libraryId = 'builtin.diary'; +let _librarySource = null; +let _root = null; +let _unsubscribeContent = null; let _entriesIndex = {}; // date → { hasPrompt, hasText } +let _route = null; -export async function mount(rootEl, ctx) { +export async function mount(rootEl, ctx, route) { _ctx = ctx; + _root = rootEl; + _route = route; + const previousToday = _today; + _today = localDateKey(); + if (_selectedDate === previousToday) _selectedDate = _today; + const daily = readDailyRouteContext(route); + if (daily?.params.daily_stream === 'diary') _libraryId = 'builtin.diary'; await loadLibraries(); + _unsubscribeContent?.(); + _unsubscribeContent = ctx.content.subscribe(async () => { + await loadLibraries(); + if (_ctx === ctx && _root) render(_root); + }); hydrateFromLocal(); + hydrateDailyPrompt(route); render(rootEl); } -export function unmount() { - if (_saveTimer) clearTimeout(_saveTimer); +export async function unmount() { + if (_saveTimer) { + clearTimeout(_saveTimer); + _saveTimer = null; + persistEntry(_selectedDate, _entryText); + if (_todayPrompt) await saveToArchive({ silent: true }); + } + _unsubscribeContent?.(); + _unsubscribeContent = null; + _root = null; + _route = null; _ctx = null; } async function loadLibraries() { try { - const pmod = await import('./prompts.json'); - _prompts = pmod.default || pmod; - } catch { _prompts = []; } - try { - const wmod = await import('./words.json'); - _words = wmod.default || wmod; - } catch { _words = []; } + _diaryLibraries = _ctx?.content?.diaryLibraries?.() || []; + let definition = _diaryLibraries.find((library) => library.id === _libraryId); + if (!definition) { + definition = _diaryLibraries[0]; + _libraryId = definition?.id || 'builtin.diary'; + } + const content = definition ? await definition.load() : { prompts: [], words: [] }; + _promptItems = content.prompts; + _wordItems = content.words; + _prompts = content.prompts.map((item) => item.text); + _words = content.words.map((item) => item.text); + _librarySource = definition?.source || null; + } catch { + _promptItems = []; + _wordItems = []; + _prompts = []; + _words = []; + _librarySource = null; + } } function hydrateFromLocal() { @@ -70,6 +120,61 @@ function hydrateFromLocal() { } } +function hydrateDailyPrompt(route) { + const daily = readDailyRouteContext(route); + if (!daily || daily.params.daily_stream !== 'diary') return false; + const date = daily.params.daily_date || ''; + const promptIndex = Number(daily.params.daily_prompt_index); + const wordIndex = Number(daily.params.daily_word_index); + const number = Number(daily.params.daily_number); + const color = daily.params.daily_color || ''; + const direction = daily.params.daily_direction || ''; + try { + parseLocalDate(date); + } catch { + throw new TypeError('Today Diary handoff has an invalid calendar date'); + } + if ( + !Number.isSafeInteger(promptIndex) + || promptIndex < 0 + || promptIndex >= _prompts.length + || !Number.isSafeInteger(wordIndex) + || wordIndex < 0 + || wordIndex >= _words.length + || !Number.isSafeInteger(number) + || number < 1 + || number > 100 + || !/^#[0-9a-f]{6}$/.test(color) + || !DIRECTIONS.includes(direction) + ) { + throw new TypeError('Today Diary handoff is invalid'); + } + const raw = `${daily.dailyRecordId}:${promptIndex}:${wordIndex}:${number}:${color}:${direction}`; + const provenance = dailyDerivedProvenance(route, raw, 'Diary coordinates'); + if (!provenance) throw new TypeError('Today Diary provenance is incomplete'); + _selectedDate = date; + _todayPrompt = { + date, + question: _prompts[promptIndex], + word: _words[wordIndex], + number, + color, + direction, + provenance, + drawn_at: provenance.fetched_at, + added_late: date !== _today, + archive_relations: relationsFromDailyRoute(route), + }; + try { + const entries = JSON.parse(localStorage.getItem(ENTRY_KEY) || '{}'); + _entryText = entries[date] || ''; + } catch { + _entryText = ''; + } + _entriesIndex[date] = { ...(_entriesIndex[date] || {}), hasPrompt: true, hasText: Boolean(_entryText) }; + return true; +} + function persistPrompt(date, prompt) { try { const all = JSON.parse(localStorage.getItem(PROMPT_KEY) || '{}'); @@ -88,7 +193,7 @@ function persistEntry(date, text) { function render(rootEl) { clear(rootEl); - const frame = h('div', { class: 'chamber-frame full-bleed reveal' }, [ + const frame = h('div', { class: `chamber-frame full-bleed reveal${_todayPrompt ? ' diary-has-prompt' : ''}` }, [ h('div', { class: 'diary-header' }, [ h('div', null, [ h('div', { class: 'chamber-id' }, ['Chamber · 03/08']), @@ -121,7 +226,7 @@ function render(rootEl) { h('ol', null, [ h('li', null, ['Click ', h('strong', null, ['Draw today’s prompt']), '. The five components appear together.']), h('li', null, ['Write below in Markdown — autosaves locally as you type.']), - h('li', null, ['Click ', h('strong', null, ['Save to archive']), ' to write a ', h('code', null, ['.md']), ' file (with the prompt in YAML frontmatter, readable in any text editor).']), + h('li', null, ['Click ', h('strong', null, ['Save to archive']), ' to write a versioned ', h('code', null, ['.json']), ' record (readable in any text editor; existing Markdown entries remain supported).']), ]), h('p', { class: 'muted small' }, [ 'Honesty rule: today’s prompt is ', h('strong', null, ['sticky for the calendar day']), '. Once drawn, no re-rolling for one you like better. Past dates can be opened from the timeline on the right; you can add late entries but they’re stamped with the date you actually wrote them.', @@ -131,8 +236,8 @@ function render(rootEl) { h('div', { class: 'diary-body' }, [ h('section', { class: 'diary-main' }, [ - _todayPrompt ? buildPromptCard() : buildDrawPrompt(rootEl), - _todayPrompt ? buildEntryArea() : null, + _todayPrompt ? buildEntryArea() : buildDrawPrompt(rootEl), + _todayPrompt ? buildPromptCard() : null, ]), h('aside', { class: 'diary-timeline' }, [buildTimeline(rootEl)]), ]), @@ -148,6 +253,22 @@ function buildDrawPrompt(rootEl) { ? 'No prompt drawn yet for today. The prompt is sticky for the calendar day — once drawn, it stays.' : 'No prompt was drawn for this date. You can draw one now; it will be stamped as added today.', ]), + _diaryLibraries.length > 1 ? h('label', { class: 'stack-1', style: { maxWidth: '360px', margin: '18px auto' } }, [ + h('span', { class: 'label' }, ['Prompt collection']), + h('select', { + class: 'select', + value: _libraryId, + disabled: _drawing, + onchange: async (event) => { + _libraryId = event.target.value; + await loadLibraries(); + if (_root) render(_root); + }, + }, _diaryLibraries.map((library) => h('option', { + value: library.id, + selected: library.id === _libraryId ? true : null, + }, [library.label]))), + ]) : null, h('div', { class: 'center' }, [ h('button', { class: 'btn btn-primary btn-big', onclick: () => doDraw(rootEl), disabled: _drawing }, [_drawing ? 'drawing…' : 'Draw today’s prompt']), @@ -157,27 +278,29 @@ function buildDrawPrompt(rootEl) { function buildPromptCard() { const p = _todayPrompt; - return h('section', { class: 'panel diary-prompt-card' }, [ - h('div', { class: 'diary-prompt-grid' }, [ - h('div', { class: 'prompt-question' }, [ - h('div', { class: 'label' }, ['The question']), - h('p', { class: 'prompt-q' }, [p.question]), - ]), - h('div', { class: 'prompt-aux' }, [ - promptAuxItem('word', p.word), - promptAuxItem('number', p.number), - promptAuxItem('direction', p.direction), - h('div', { class: 'aux-item' }, [ - h('div', { class: 'label' }, ['color']), - h('div', { class: 'color-swatch', style: { background: p.color } }), - h('div', { class: 'mono small muted', style: { marginTop: '4px' } }, [p.color]), + return h('section', { class: 'diary-prompt-result' }, [ + buildProvenance(p.provenance), + h('details', { class: 'diary-prompt-details' }, [ + h('summary', null, ['Prompt details']), + h('section', { class: 'panel diary-prompt-card' }, [ + h('div', { class: 'diary-prompt-grid' }, [ + h('div', { class: 'prompt-question' }, [ + h('div', { class: 'label' }, ['The question']), + h('p', { class: 'prompt-q' }, [p.question]), + ]), + h('div', { class: 'prompt-aux' }, [ + promptAuxItem('word', p.word), + promptAuxItem('number', p.number), + promptAuxItem('direction', p.direction), + h('div', { class: 'aux-item' }, [ + h('div', { class: 'label' }, ['color']), + h('div', { class: 'color-swatch', style: { background: p.color } }), + h('div', { class: 'mono small muted', style: { marginTop: '4px' } }, [p.color]), + ]), + ]), ]), ]), ]), - h('details', { class: 'diary-provenance' }, [ - h('summary', null, ['provenance']), - buildProvenance(p.provenance), - ]), ]); } @@ -189,22 +312,38 @@ function promptAuxItem(label, value) { } function buildEntryArea() { - return h('section', { class: 'diary-entry-area' }, [ + return ResultStage({ className: 'diary-entry-area', label: 'Diary entry editor', status: 'result', children: [ + h('div', { class: 'diary-writing-context' }, [ + h('div', { class: 'label' }, ['Writing against']), + h('p', { class: 'diary-writing-question' }, [_todayPrompt.question]), + h('div', { class: 'diary-writing-meta small muted' }, [ + h('span', null, [_todayPrompt.word]), + h('span', null, [String(_todayPrompt.number)]), + h('span', null, [_todayPrompt.direction]), + h('span', { + class: 'diary-writing-color', + role: 'img', + style: { background: _todayPrompt.color }, + 'aria-label': `Color ${_todayPrompt.color}`, + }), + ]), + ]), h('div', { class: 'spread' }, [ h('div', { class: 'label' }, ['Entry']), - h('div', { class: 'small muted' }, ['autosaves locally · written to archive on save']), + h('div', { class: 'small muted' }, ['autosaves locally and to archive']), ]), h('textarea', { class: 'textarea diary-textarea', - rows: 18, + rows: 12, placeholder: 'Write against the prompt. Markdown supported.', oninput: (e) => onInputEntry(e.target.value), value: _entryText, }), - h('div', { class: 'row', style: { gap: '12px', marginTop: '12px' } }, [ - h('button', { class: 'btn btn-primary', onclick: () => saveToArchive() }, ['Save to archive']), - ]), - ]); + ActionBar({ + label: 'Diary entry actions', + primary: [h('button', { class: 'btn btn-primary', onclick: () => saveToArchive() }, ['Save'])], + }), + ] }); } function buildTimeline(rootEl) { @@ -227,9 +366,14 @@ function buildTimeline(rootEl) { ]); } -function switchDate(rootEl, date) { +async function switchDate(rootEl, date) { // Persist any pending in-flight text - if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; persistEntry(_selectedDate, _entryText); } + if (_saveTimer) { + clearTimeout(_saveTimer); + _saveTimer = null; + persistEntry(_selectedDate, _entryText); + if (_todayPrompt) await saveToArchive({ silent: true }); + } _selectedDate = date; hydrateFromLocal(); render(rootEl); @@ -237,11 +381,13 @@ function switchDate(rootEl, date) { function onInputEntry(val) { _entryText = val; + if (_todayPrompt) persistPrompt(_selectedDate, _todayPrompt); _entriesIndex[_selectedDate] = { ..._entriesIndex[_selectedDate], hasText: !!val }; if (_saveTimer) clearTimeout(_saveTimer); - _saveTimer = setTimeout(() => { + _saveTimer = setTimeout(async () => { persistEntry(_selectedDate, _entryText); _saveTimer = null; + if (_todayPrompt) await saveToArchive({ silent: true }); }, 1500); } @@ -250,22 +396,28 @@ async function doDraw(rootEl) { _drawing = true; render(rootEl); try { - // 16 bytes used for the prompt's components (well within one pulse). - const result = await _ctx.entropy.request({ kind: 'bytes', count: 16, source: 'nist-beacon' }); - const bytes = Array.isArray(result.value) ? result.value : (result.value instanceof Uint8Array ? Array.from(result.value) : [result.value]); - // Fallback if returned as a single byte + // Allocate independent byte windows and use the engine's unbiased converter. + const result = await _ctx.entropy.request({ kind: 'bytes', count: 64, source: 'nist-beacon' }); const rawBytes = hexToBytes(result.provenance.raw); - const qIdx = rawBytes.length >= 2 ? ((rawBytes[0] << 8) | rawBytes[1]) % Math.max(1, _prompts.length) : 0; - const wIdx = rawBytes.length >= 4 ? ((rawBytes[2] << 8) | rawBytes[3]) % Math.max(1, _words.length) : 0; - const number = rawBytes.length >= 5 ? (rawBytes[4] % 100) + 1 : 0; - const dirIdx = rawBytes.length >= 6 ? rawBytes[5] % DIRECTIONS.length : 0; - const r = rawBytes[6] ?? 128; - const g = rawBytes[7] ?? 128; - const b = rawBytes[8] ?? 128; + const qIdx = _prompts.length ? convert(rawBytes.subarray(0, 8), { kind: 'integer', range: [0, _prompts.length - 1] }).value : 0; + const wIdx = _words.length ? convert(rawBytes.subarray(8, 16), { kind: 'integer', range: [0, _words.length - 1] }).value : 0; + const number = convert(rawBytes.subarray(16, 24), { kind: 'integer', range: [1, 100] }).value; + const dirIdx = convert(rawBytes.subarray(24, 32), { kind: 'integer', range: [0, DIRECTIONS.length - 1] }).value; + const r = rawBytes[32] ?? 128; + const g = rawBytes[33] ?? 128; + const b = rawBytes[34] ?? 128; const color = `#${toHex(r)}${toHex(g)}${toHex(b)}`; - const question = _prompts.length ? _prompts[qIdx] : 'What did today insist on, that you almost overlooked?'; - const word = _words.length ? _words[wIdx] : 'lacuna'; + const promptItem = _promptItems[qIdx] || null; + const wordItem = _wordItems[wIdx] || null; + const question = promptItem?.text || 'What did today insist on, that you almost overlooked?'; + const word = wordItem?.text || 'lacuna'; + const packReference = _librarySource?.type === 'pack' + ? _ctx.content.reference(_librarySource, promptItem?.id || wordItem?.id || 'prompt', { + prompt: promptItem, + word: wordItem, + }) + : undefined; _todayPrompt = { date: _selectedDate, @@ -274,52 +426,48 @@ async function doDraw(rootEl) { provenance: result.provenance, drawn_at: new Date().toISOString(), added_late: _selectedDate !== _today, + ...(packReference ? { pack_reference: packReference } : {}), }; persistPrompt(_selectedDate, _todayPrompt); _entriesIndex[_selectedDate] = { ...(_entriesIndex[_selectedDate] || {}), hasPrompt: true }; } catch (e) { - toast(`Draw failed: ${e.message || e}`, 'danger'); + if (_ctx) toast(`Draw failed: ${e.message || e}`, 'danger'); } _drawing = false; + if (!_ctx) return; render(rootEl); } -async function saveToArchive() { +async function saveToArchive({ silent = false } = {}) { if (!_ctx?.archive || !_todayPrompt) return; - const rel = `archive/diary/${_selectedDate}.md`; - const frontmatter = [ - '---', - `date: ${_selectedDate}`, - `drawn_at: ${_todayPrompt.drawn_at}`, - `question: ${jsonString(_todayPrompt.question)}`, - `word: ${jsonString(_todayPrompt.word)}`, - `number: ${_todayPrompt.number}`, - `color: ${jsonString(_todayPrompt.color)}`, - `direction: ${_todayPrompt.direction}`, - _todayPrompt.added_late ? 'added_on: ' + localDateKey() : null, - 'provenance:', - ` source_id: ${_todayPrompt.provenance.source_id}`, - ` source_name: ${jsonString(_todayPrompt.provenance.source_name)}`, - ` description: ${jsonString(_todayPrompt.provenance.description)}`, - ` fetched_at: ${_todayPrompt.provenance.fetched_at}`, - ` raw: ${_todayPrompt.provenance.raw}`, - _todayPrompt.provenance.signature ? ` signature: ${_todayPrompt.provenance.signature}` : null, - _todayPrompt.provenance.extra?.pulse_index ? ` pulse_index: ${_todayPrompt.provenance.extra.pulse_index}` : null, - _todayPrompt.provenance.extra?.pulse_uri ? ` pulse_uri: ${_todayPrompt.provenance.extra.pulse_uri}` : null, - '---', - '', - ].filter((l) => l !== null).join('\n'); - try { - await _ctx.archive.writeText(rel, frontmatter + '\n' + (_entryText || '')); - toast(`Saved → ${rel}`, 'success'); + persistPrompt(_selectedDate, _todayPrompt); + const { + provenance, + archive_relations: archiveRelations = [], + pack_reference: packReference, + ...prompt + } = _todayPrompt; + const saved = await _ctx.archive.save({ + chamber: 'diary', + type: 'diary-entry', + createdAt: _todayPrompt.drawn_at, + summary: `Diary: "${_todayPrompt.question.slice(0, 120)}"`, + payload: { + date: _selectedDate, + added_on: _todayPrompt.added_late ? localDateKey() : null, + prompt, + body: _entryText || '', + }, + provenance, + relations: _ctx.projects.relations(_route, archiveRelations), + pack: packReference, + ...(_ctx.projects.resolve(_route) ? {} : { logicalKey: _selectedDate }), + }); + if (!silent) toast(`Saved → ${saved.path}`, 'success'); } catch (e) { - toast(`Save failed: ${e.message || e}`, 'danger'); + toast(`${silent ? 'Autosave' : 'Save'} failed: ${e.message || e}`, 'danger'); } } function toHex(n) { return Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0'); } -function jsonString(s) { - // YAML-friendly quoted string: use JSON.stringify which handles escaping - return JSON.stringify(String(s)); -} diff --git a/src/chambers/lottery/_shared.js b/src/chambers/lottery/_shared.js index 290bc2c..8874138 100644 --- a/src/chambers/lottery/_shared.js +++ b/src/chambers/lottery/_shared.js @@ -4,9 +4,14 @@ * toast notifications. */ -import { h, svg } from '../../lib/dom.js'; -import { fileSafeISO, humanDate, shortHash } from '../../lib/format.js'; +import { h } from '../../lib/dom.js'; +import { humanDate } from '../../lib/format.js'; import { sha256Hex } from '../../lib/hash.js'; +import { toast } from '../../lib/notify.js'; +import { ActionBar, ErrorState, ProvenanceDisclosure, ResultStage } from '../../ui/primitives.js'; +import { exportLotteryReceipt } from './receipt.js'; + +export { toast }; export const CEREMONY = { quick: { drawDelay: 100, revealDelay: 0 }, @@ -17,7 +22,7 @@ export const CEREMONY = { /** Slow down a draw to the requested ceremony timing. * Runs drawFn and a delay in parallel; the longer wins. */ export async function ceremoniously(level, drawFn) { - const t = CEREMONY[level] || CEREMONY.ritual; + const t = reducedMotion() ? { drawDelay: 0 } : (CEREMONY[level] || CEREMONY.ritual); const result = await Promise.all([ drawFn(), new Promise((r) => setTimeout(r, t.drawDelay)), @@ -27,37 +32,87 @@ export async function ceremoniously(level, drawFn) { /** Wait the post-reveal beat. */ export function settleAfter(level) { - const t = CEREMONY[level] || CEREMONY.ritual; + const t = reducedMotion() ? { revealDelay: 0 } : (CEREMONY[level] || CEREMONY.ritual); return new Promise((r) => setTimeout(r, t.revealDelay)); } +function reducedMotion() { + return !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} + /** Build a provenance block for any draw. */ export function buildProvenance(provenance) { - if (!provenance) return h('div', { class: 'provenance' }, ['(no provenance)']); - const rows = [ - pvRow('source', provenance.source_name), - pvRow('flavor', provenance.flavor || '—'), - pvRow('description', provenance.description, { wrap: true }), - pvRow('fetched', provenance.fetched_at), - pvRow('raw', shortHash(provenance.raw, 12), { mono: true }), - ]; - if (provenance.signature) { - rows.push(pvRow('signature', shortHash(provenance.signature, 12), { mono: true, sig: true })); - } - if (provenance.extra?.pulse_index) { - rows.push(pvRow('pulse_index', `#${provenance.extra.pulse_index}`, { mono: true })); - } - if (provenance.extra?.pulse_uri) { - rows.push(pvRow('verify', provenance.extra.pulse_uri, { mono: true, small: true })); - } - return h('div', { class: 'provenance' }, rows); + return ProvenanceDisclosure(provenance); } -function pvRow(key, val, opts = {}) { - return h('div', { class: 'pv-row' + (opts.wrap ? ' pv-wrap' : '') }, [ - h('span', { class: 'pv-key' }, [key]), - h('span', { class: `pv-val ${opts.sig ? 'pv-sig' : ''} ${opts.small ? 'pv-small' : ''}` }, [String(val ?? '—')]), - ]); +/** Build the standard result/error shell used by every Lottery tool. */ +export function buildLotteryResult(options) { + const { + label, + result, + content = [], + meta = '', + busyAction = null, + ceremony = 'ritual', + receipt = null, + onSave, + onExport, + onRepeat, + onClear, + } = options; + const busy = Boolean(busyAction); + if (result?.error) { + return ResultStage({ + className: 'lottery-result lottery-result-stage reveal', + label: `${label} error`, + status: 'error', + children: [ErrorState( + `${label} could not complete`, + result.error, + ActionBar({ + label: `${label} error actions`, + primary: [h('button', { + class: 'btn btn-primary', + disabled: busy, + onclick: onRepeat, + }, ['Retry'])], + secondary: [h('button', { + class: 'btn', + disabled: busy, + onclick: onClear, + }, ['Clear'])], + }), + )], + }); + } + return ResultStage({ + className: 'lottery-result lottery-result-stage reveal', + label: `${label} result`, + status: 'result', + children: [ + ...content, + meta ? h('p', { class: 'muted center-x' }, [meta]) : null, + ProvenanceDisclosure(result?.provenance), + ActionBar({ + label: `${label} result actions`, + primary: [h('button', { + class: 'btn btn-primary', + disabled: busy || Boolean(result?.saved_path), + onclick: onSave, + }, [result?.saved_path ? 'Saved' : (busyAction === 'save' ? 'Saving…' : 'Save')])], + secondary: [ + ceremony === 'receipt' && receipt ? h('button', { + class: 'btn', + disabled: busy, + title: 'Export a portable PNG receipt.', + onclick: onExport, + }, [busyAction === 'export' ? 'Exporting…' : 'Export']) : null, + h('button', { class: 'btn', disabled: busy, onclick: onRepeat }, ['Repeat']), + h('button', { class: 'btn btn-ghost', disabled: busy, onclick: onClear }, ['Clear']), + ], + }), + ], + }); } /** Make a short hash id from any payload. */ @@ -67,44 +122,110 @@ export async function shortId(payload) { } /** Save a lottery item to the archive. Returns the relative path. */ -export async function saveLotteryItem(ctx, type, payload) { +export async function saveLotteryItem(ctx, type, payload, packReference) { if (!ctx?.archive) { toast('Archive not available in this context.', 'danger'); return null; } const drawnAt = payload.drawn_at || new Date().toISOString(); - const id = await shortId(payload); - const stamp = fileSafeISO(new Date(drawnAt)); - const rel = `archive/lottery/${stamp}__${type}_${id}.json`; - const body = { - human_summary: payload.human_summary, - type: `lottery-${type}`, - id, - drawn_at: drawnAt, - ...payload, - }; try { - await ctx.archive.writeJSON(rel, body); - toast(`Saved → ${rel}`, 'success'); - return rel; + const { provenance, human_summary: summary, ...result } = payload; + const saved = await ctx.archive.save({ + chamber: 'lottery', + type: `lottery-${type}`, + createdAt: drawnAt, + summary, + payload: result, + provenance, + pack: packReference, + }); + toast(`Saved → ${saved.path}`, 'success'); + document.dispatchEvent(new CustomEvent('lottery-item-saved', { + detail: { drawnAt, type, path: saved.path }, + })); + return saved.path; } catch (e) { toast(`Save failed: ${e.message || e}`, 'danger'); return null; } } -/** Flash a toast message. */ -export function toast(msg, kind = 'info') { - const el = h('div', { class: `toast toast-${kind}` }, [msg]); - document.body.appendChild(el); - // force reflow then mark visible - // eslint-disable-next-line no-unused-expressions - el.offsetWidth; - el.classList.add('visible'); - setTimeout(() => { - el.classList.remove('visible'); - setTimeout(() => el.remove(), 300); - }, 2800); +export function exportLotteryItemReceipt(item) { + return exportLotteryReceipt(item); +} + +/** Shared lifecycle and transactional Save/Export lock for every picker. */ +export function createLotteryActions({ ctx, type, ceremony, render, current, isRunning }) { + let busyAction = null; + let active = true; + const finish = () => { + if (!active) return; + busyAction = null; + render(); + }; + return { + get active() { return active; }, + get busy() { return Boolean(busyAction); }, + get busyAction() { return busyAction; }, + stop() { active = false; }, + async save(result, payload) { + if (!active || isRunning() || busyAction || result.saved_path) return; + busyAction = 'save'; + render(); + try { + const path = await saveLotteryItem(ctx, type, payload, result.pack_reference); + if (active && current() === result && path) result.saved_path = path; + } finally { + finish(); + } + }, + async export(receipt) { + if (!active || isRunning() || busyAction || ceremony !== 'receipt') return; + busyAction = 'export'; + render(); + try { + await exportLotteryItemReceipt(receipt); + if (active) toast('Receipt export ready.', 'success'); + } catch (error) { + if (active) toast(`Receipt export failed: ${error.message || error}`, 'danger'); + } finally { + finish(); + } + }, + }; +} + +/** Optional local content-pack preset selector shared by Lottery tools. */ +export function buildPresetControl(presets, activeId, onSelect, disabled = false) { + if (!Array.isArray(presets) || presets.length === 0) return null; + return h('label', { class: 'lottery-preset stack-1', style: { maxWidth: '360px', margin: '0 auto' } }, [ + h('span', { class: 'label' }, ['Preset']), + h('select', { + class: 'select', + disabled, + value: activeId || '', + onchange: (event) => { + const preset = presets.find((candidate) => `${candidate.source.id}:${candidate.id}` === event.target.value) || null; + onSelect(preset); + }, + }, [ + h('option', { value: '', selected: !activeId ? true : null }, ['Custom values']), + ...presets.map((preset) => h('option', { + value: `${preset.source.id}:${preset.id}`, + selected: `${preset.source.id}:${preset.id}` === activeId ? true : null, + }, [`${preset.name} · ${preset.source.label}`])), + ]), + ]); +} + +export function presetKey(preset) { + return preset ? `${preset.source.id}:${preset.id}` : ''; +} + +export function presetReference(ctx, preset) { + if (!preset) return undefined; + const { source, ...snapshot } = preset; + return ctx.content.reference(source, preset.id, snapshot); } /** Build the "Recent" side panel showing prior rolls in this session. */ @@ -117,8 +238,23 @@ export function buildRecent(items, onPick) { } return h('aside', { class: 'lottery-recent' }, [ h('div', { class: 'label' }, ['Recent in this session']), - h('ul', { class: 'recent-list' }, items.slice(-8).reverse().map((it) => - h('li', { class: 'recent-row', onclick: () => onPick?.(it) }, [ + h('ul', { + class: 'recent-list', + tabindex: 0, + 'aria-label': 'Recent Lottery results', + }, items.slice(-8).reverse().map((it) => + h('li', { + class: 'recent-row', + role: onPick ? 'button' : null, + tabindex: onPick ? 0 : null, + onclick: onPick ? () => onPick(it) : null, + onkeydown: onPick ? (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onPick(it); + } + } : null, + }, [ h('span', { class: 'recent-type' }, [it.type]), h('span', { class: 'recent-summary mono' }, [it.summary]), h('span', { class: 'recent-time muted small' }, [shortRelative(it.at)]), diff --git a/src/chambers/lottery/coin.js b/src/chambers/lottery/coin.js index 29b7823..c90a4dc 100644 --- a/src/chambers/lottery/coin.js +++ b/src/chambers/lottery/coin.js @@ -6,19 +6,30 @@ import { h, svg, clear } from '../../lib/dom.js'; import { humanDate } from '../../lib/format.js'; -import { ceremoniously, settleAfter, buildProvenance, saveLotteryItem } from './_shared.js'; +import { + buildLotteryResult, buildPresetControl, ceremoniously, createLotteryActions, presetKey, presetReference, settleAfter, +} from './_shared.js'; export const id = 'coin'; export const label = 'Coin'; +let stopActive = null; + export async function mount(rootEl, ctx, opts) { const ceremony = opts.ceremony; const onPushRecent = opts.onPushRecent; + const presets = opts.presets || []; let last = null; let flipping = false; + let activePreset = null; + const actions = createLotteryActions({ + ctx, type: 'coin', ceremony, render, current: () => last, isRunning: () => flipping, + }); + stopActive = () => actions.stop(); function render() { + if (!actions.active) return; clear(rootEl); const stage = h('div', { class: 'coin-stage' }, [coinFace(last ? last.side : 'unknown')]); if (flipping) stage.classList.add('flipping'); @@ -26,76 +37,80 @@ export async function mount(rootEl, ctx, opts) { const action = h('button', { class: 'btn btn-primary btn-big', onclick: () => doFlip(), - disabled: flipping, - }, [flipping ? 'flipping…' : (last ? 'Flip again' : 'Flip')]); - - const result = h('div', { class: 'lottery-result' }); - if (last && !flipping) { - result.appendChild(buildResultPanel(last)); - } + disabled: flipping || actions.busy, + }, [flipping ? 'Flipping…' : 'Flip']); - rootEl.appendChild(h('div', { class: 'lottery-picker coin-picker stack-5' }, [ + rootEl.appendChild(h('div', { class: `lottery-picker coin-picker stack-5${last && !flipping ? ' has-result' : ''}` }, [ h('div', { class: 'picker-intro center-x' }, [ h('p', { class: 'muted' }, ['One bit drawn from the universe. Heads or tails.']), ]), + buildPresetControl(presets, presetKey(activePreset), (preset) => { + activePreset = preset; + render(); + }, flipping || actions.busy), stage, - h('div', { class: 'center' }, [action]), - result, + !last ? h('div', { class: 'center' }, [action]) : null, + last && !flipping ? buildResultPanel(last) : null, ])); } - async function doFlip() { - if (flipping) return; + async function doFlip(repeatPreset = undefined) { + if (flipping || actions.busy) return; flipping = true; render(); try { const result = await ceremoniously(ceremony, () => ctx.entropy.request({ kind: 'integer', range: [0, 1], source: 'preferred' }) ); + if (!actions.active) return; const side = result.value === 0 ? 'heads' : 'tails'; + const preset = repeatPreset === undefined ? activePreset : repeatPreset; + const sideLabel = preset ? (side === 'heads' ? preset.heads : preset.tails) : side; last = { side, + side_label: sideLabel, + preset: preset ? structuredClone(preset) : null, provenance: result.provenance, drawn_at: new Date().toISOString(), + ...(preset ? { pack_reference: presetReference(ctx, preset) } : {}), }; onPushRecent({ type: 'coin', - summary: `${side} · ${result.provenance.source_id}`, + summary: `${sideLabel} · ${result.provenance.source_id}`, at: last.drawn_at, payload: last, }); } catch (e) { + if (!actions.active) return; last = { error: String(e?.message || e) }; } await settleAfter(ceremony); + if (!actions.active) return; flipping = false; render(); } function buildResultPanel(r) { - if (r.error) { - return h('div', { class: 'panel reveal' }, [ - h('h3', { class: 'amber' }, ['could not draw']), - h('p', { class: 'muted' }, [r.error]), - ]); - } - return h('div', { class: 'panel reveal stack-4' }, [ - h('h2', { class: 'coin-result-big' }, [r.side]), - h('p', { class: 'muted center-x' }, [`drawn at ${humanDate(r.drawn_at)}`]), - buildProvenance(r.provenance), - h('div', { class: 'row center', style: { gap: '12px' } }, [ - h('button', { class: 'btn', onclick: () => save(r) }, ['Save to archive']), - ]), - ]); - } - - async function save(r) { - const summary = `Coin flip: ${r.side}`; - await saveLotteryItem(ctx, 'coin', { - human_summary: summary, - side: r.side, - drawn_at: r.drawn_at, - provenance: r.provenance, + const receipt = r.error ? null : receiptFor(r); + return buildLotteryResult({ + label: 'Coin flip', + result: r, + content: r.error ? [] : [h('h2', { class: 'coin-result-big' }, [r.side_label || r.side])], + meta: r.error ? '' : `Drawn at ${humanDate(r.drawn_at)}`, + busyAction: actions.busyAction, + ceremony, + receipt, + onSave: () => actions.save(r, { + human_summary: `Coin flip: ${r.side_label || r.side}`, + side: r.side, label: r.side_label || r.side, drawn_at: r.drawn_at, provenance: r.provenance, + }), + onExport: () => actions.export(receiptFor(r)), + onRepeat: () => doFlip(r.preset), + onClear: () => { + if (flipping || actions.busy) return; + last = null; + render(); + }, }); } @@ -103,7 +118,12 @@ export async function mount(rootEl, ctx, opts) { } export function unmount() { - /* nothing to release */ + stopActive?.(); + stopActive = null; +} + +function receiptFor(r) { + return { type: 'coin', summary: `Coin flip: ${r.side_label || r.side}`, drawn_at: r.drawn_at, provenance: r.provenance }; } function coinFace(face) { diff --git a/src/chambers/lottery/dice.js b/src/chambers/lottery/dice.js index fc55d15..2cfd23d 100644 --- a/src/chambers/lottery/dice.js +++ b/src/chambers/lottery/dice.js @@ -5,115 +5,144 @@ import { h, svg, clear } from '../../lib/dom.js'; import { humanDate } from '../../lib/format.js'; -import { ceremoniously, settleAfter, buildProvenance, saveLotteryItem } from './_shared.js'; +import { + buildLotteryResult, buildPresetControl, ceremoniously, createLotteryActions, presetKey, presetReference, settleAfter, +} from './_shared.js'; export const id = 'dice'; export const label = 'Dice'; const SIDE_OPTIONS = [4, 6, 8, 10, 12, 20, 100]; +let stopActive = null; export async function mount(rootEl, ctx, opts) { const ceremony = opts.ceremony; const onPushRecent = opts.onPushRecent; + const presets = opts.presets || []; let count = 2; let sides = 6; let last = null; let rolling = false; + let activePreset = null; + const actions = createLotteryActions({ + ctx, type: 'dice', ceremony, render, current: () => last, isRunning: () => rolling, + }); + stopActive = () => actions.stop(); function render() { + if (!actions.active) return; clear(rootEl); - rootEl.appendChild(h('div', { class: 'lottery-picker dice-picker stack-5' }, [ + const displaySides = last?.sides || sides; + rootEl.appendChild(h('div', { class: `lottery-picker dice-picker stack-5${last && !rolling ? ' has-result' : ''}` }, [ h('div', { class: 'picker-intro center-x' }, [ h('p', { class: 'muted' }, [`${count}d${sides} · roll N dice of M sides; entropy gives each face.`]), ]), + buildPresetControl(presets, presetKey(activePreset), (preset) => { + activePreset = preset; + if (preset) { count = preset.count; sides = preset.sides; } + render(); + }, rolling || actions.busy), h('div', { class: 'dice-controls row center', style: { gap: '24px' } }, [ h('label', { class: 'stack-1' }, [ h('span', { class: 'label' }, ['Dice']), - h('input', { type: 'number', class: 'input', min: 1, max: 20, value: String(count), style: { width: '88px' }, oninput: (e) => { count = clamp(parseInt(e.target.value, 10) || 1, 1, 20); render(); } }), + h('input', { type: 'number', class: 'input', min: 1, max: 20, value: String(count), disabled: rolling || actions.busy, style: { width: '88px' }, oninput: (e) => { count = clamp(parseInt(e.target.value, 10) || 1, 1, 20); activePreset = null; render(); } }), ]), h('label', { class: 'stack-1' }, [ h('span', { class: 'label' }, ['Sides']), - h('select', { class: 'select', style: { width: '110px' }, onchange: (e) => { sides = parseInt(e.target.value, 10); render(); } }, - SIDE_OPTIONS.map((s) => h('option', { value: String(s), selected: s === sides ? true : null }, [`d${s}`])) + h('select', { class: 'select', disabled: rolling || actions.busy, style: { width: '110px' }, onchange: (e) => { sides = parseInt(e.target.value, 10); activePreset = null; render(); } }, + [...new Set([...SIDE_OPTIONS, sides])].sort((a, b) => a - b).map((s) => h('option', { value: String(s), selected: s === sides ? true : null }, [`d${s}`])) ), ]), ]), h('div', { class: 'dice-stage' + (rolling ? ' rolling' : '') }, - last && last.values ? last.values.map((v) => dieFace(v, sides)) : Array.from({ length: count }, () => dieFace(null, sides)) + last && last.values ? last.values.map((v) => dieFace(v, displaySides)) : Array.from({ length: count }, () => dieFace(null, sides)) ), h('div', { class: 'center' }, [ h('button', { class: 'btn btn-primary btn-big', onclick: () => doRoll(), - disabled: rolling, - }, [rolling ? 'rolling…' : (last ? 'Roll again' : 'Roll')]), + disabled: rolling || actions.busy, + }, [rolling ? 'Rolling…' : 'Roll']), ]), - last && !rolling ? h('div', { class: 'lottery-result' }, [buildResultPanel(last)]) : null, + last && !rolling ? buildResultPanel(last) : null, ])); } - async function doRoll() { - if (rolling) return; + async function doRoll(repeatRequest = null) { + if (rolling || actions.busy) return; + const request = repeatRequest + ? { count: repeatRequest.count, sides: repeatRequest.sides, preset: repeatRequest.preset || null } + : { count, sides, preset: activePreset ? structuredClone(activePreset) : null }; rolling = true; render(); try { const result = await ceremoniously(ceremony, () => - ctx.entropy.request({ kind: 'integer', range: [1, sides], count, source: 'preferred' }) + ctx.entropy.request({ kind: 'integer', range: [1, request.sides], count: request.count, source: 'preferred' }) ); + if (!actions.active) return; const values = Array.isArray(result.value) ? result.value : [result.value]; const sum = values.reduce((a, b) => a + b, 0); - last = { values, sum, sides, count, provenance: result.provenance, drawn_at: new Date().toISOString() }; + last = { + values, sum, sides: request.sides, count: request.count, request, + provenance: result.provenance, drawn_at: new Date().toISOString(), + ...(request.preset ? { pack_reference: presetReference(ctx, request.preset) } : {}), + }; onPushRecent({ type: 'dice', - summary: `${count}d${sides} → ${values.join(', ')} (Σ ${sum})`, + summary: `${request.count}d${request.sides} → ${values.join(', ')} (Σ ${sum})`, at: last.drawn_at, payload: last, }); } catch (e) { + if (!actions.active) return; last = { error: String(e?.message || e) }; } await settleAfter(ceremony); + if (!actions.active) return; rolling = false; render(); } function buildResultPanel(r) { - if (r.error) { - return h('div', { class: 'panel reveal' }, [ - h('h3', { class: 'amber' }, ['could not roll']), - h('p', { class: 'muted' }, [r.error]), - ]); - } - return h('div', { class: 'panel reveal stack-4' }, [ - h('div', { class: 'dice-result-row center' }, [ + const receipt = r.error ? null : receiptFor(r); + return buildLotteryResult({ + label: 'Dice roll', + result: r, + content: r.error ? [] : [h('div', { class: 'dice-result-row center' }, [ h('div', { class: 'dice-values mono' }, [r.values.join(' · ')]), h('div', { class: 'dice-sum' }, [`Σ ${r.sum}`]), - ]), - h('p', { class: 'muted center-x' }, [`drawn at ${humanDate(r.drawn_at)}`]), - buildProvenance(r.provenance), - h('div', { class: 'row center' }, [ - h('button', { class: 'btn', onclick: () => save(r) }, ['Save to archive']), - ]), - ]); - } - - async function save(r) { - await saveLotteryItem(ctx, 'dice', { - human_summary: `${r.count}d${r.sides}: ${r.values.join(', ')} (sum ${r.sum})`, - count: r.count, - sides: r.sides, - values: r.values, - sum: r.sum, - drawn_at: r.drawn_at, - provenance: r.provenance, + ])], + meta: r.error ? '' : `Drawn at ${humanDate(r.drawn_at)}`, + busyAction: actions.busyAction, + ceremony, + receipt, + onSave: () => actions.save(r, { + human_summary: `${r.count}d${r.sides}: ${r.values.join(', ')} (sum ${r.sum})`, + count: r.count, sides: r.sides, values: r.values, sum: r.sum, + drawn_at: r.drawn_at, provenance: r.provenance, + }), + onExport: () => actions.export(receiptFor(r)), + onRepeat: () => doRoll(r.request), + onClear: () => { + if (rolling || actions.busy) return; + last = null; + render(); + }, }); } render(); } -export function unmount() { /* */ } +export function unmount() { + stopActive?.(); + stopActive = null; +} + +function receiptFor(r) { + return { type: 'dice', summary: `${r.count}d${r.sides}: ${r.values.join(', ')} (sum ${r.sum})`, drawn_at: r.drawn_at, provenance: r.provenance }; +} function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } diff --git a/src/chambers/lottery/index.js b/src/chambers/lottery/index.js index 20dd21f..6f23c2c 100644 --- a/src/chambers/lottery/index.js +++ b/src/chambers/lottery/index.js @@ -30,15 +30,18 @@ let _activeMod = null; let _activeRoot = null; let _ceremony = 'ritual'; let _recent = []; +let _pickerRequest = 0; export async function mount(rootEl, ctx) { _ctx = ctx; - _recent = []; // chamber-scoped, per-session + document.addEventListener('lottery-item-saved', markRecentSaved); renderChrome(rootEl); await loadPicker(_activePicker); } export async function unmount() { + _pickerRequest++; + document.removeEventListener('lottery-item-saved', markRecentSaved); if (_activeMod?.unmount) { try { _activeMod.unmount(); } catch (e) { console.error(e); } } @@ -47,6 +50,16 @@ export async function unmount() { _ctx = null; } +function markRecentSaved(event) { + const drawnAt = event.detail?.drawnAt; + const item = _recent.find((entry) => entry.at === drawnAt); + if (item) { + item.saved = true; + item.path = event.detail?.path || null; + refreshRecent(); + } +} + function renderChrome(rootEl) { clear(rootEl); @@ -106,7 +119,7 @@ function refreshRecent() { const root = document.getElementById('lottery-recent-root'); if (!root) return; clear(root); - root.appendChild(buildRecent(_recent, () => {})); + root.appendChild(buildRecent(_recent)); } function pushRecent(item) { @@ -115,6 +128,8 @@ function pushRecent(item) { } async function loadPicker(id) { + if (!LOADERS[id]) return; + const requestId = ++_pickerRequest; if (_activeMod?.unmount) { try { _activeMod.unmount(); } catch (e) { console.error(e); } _activeMod = null; @@ -126,10 +141,16 @@ async function loadPicker(id) { _activeRoot.appendChild(h('div', { class: 'loading-line' })); try { const mod = await LOADERS[id](); + if (requestId !== _pickerRequest || !_activeRoot || !_ctx) return; _activeMod = mod; clear(_activeRoot); - await mod.mount(_activeRoot, _ctx, { ceremony: _ceremony, onPushRecent: pushRecent }); + await mod.mount(_activeRoot, _ctx, { + ceremony: _ceremony, + onPushRecent: pushRecent, + presets: _ctx.content.lotteryPresets(id), + }); } catch (e) { + if (requestId !== _pickerRequest || !_activeRoot) return; clear(_activeRoot); _activeRoot.appendChild(h('div', { class: 'panel', style: { color: 'var(--danger)' } }, [ `Failed to load picker '${id}': ${e.message || e}`, diff --git a/src/chambers/lottery/name-picker.js b/src/chambers/lottery/name-picker.js index 93983f9..dacd7d5 100644 --- a/src/chambers/lottery/name-picker.js +++ b/src/chambers/lottery/name-picker.js @@ -4,117 +4,164 @@ import { h, clear } from '../../lib/dom.js'; import { humanDate } from '../../lib/format.js'; -import { ceremoniously, settleAfter, buildProvenance, saveLotteryItem } from './_shared.js'; +import { + buildLotteryResult, buildPresetControl, ceremoniously, createLotteryActions, presetKey, presetReference, settleAfter, toast, +} from './_shared.js'; export const id = 'name-picker'; export const label = 'Names'; +let stopActive = null; + export async function mount(rootEl, ctx, opts) { const ceremony = opts.ceremony; const onPushRecent = opts.onPushRecent; + const presets = opts.presets || []; let names = ['Ada', 'Carl', 'Erin', 'Hua', 'Iliya', 'Marta', 'Noor', 'Riku']; let winnerCount = 1; let last = null; let picking = false; + let activePreset = null; + const actions = createLotteryActions({ + ctx, type: 'name-picker', ceremony, render, current: () => last, isRunning: () => picking, + }); + stopActive = () => actions.stop(); function render() { + if (!actions.active) return; clear(rootEl); - rootEl.appendChild(h('div', { class: 'lottery-picker name-picker stack-5' }, [ + rootEl.appendChild(h('div', { class: `lottery-picker name-picker stack-5${last && !picking ? ' has-result' : ''}` }, [ h('div', { class: 'picker-intro center-x' }, [ h('p', { class: 'muted' }, ['Paste a list of names. Get N winners drawn by physical randomness.']), ]), + buildPresetControl(presets, presetKey(activePreset), (preset) => { + activePreset = preset; + if (preset) names = preset.items.slice(); + render(); + }, picking || actions.busy), h('div', { class: 'row', style: { gap: '24px', alignItems: 'flex-start' } }, [ h('label', { class: 'stack-1', style: { flex: '1' } }, [ h('span', { class: 'label' }, ['Names (one per line)']), h('textarea', { class: 'textarea', rows: 8, - oninput: (e) => { names = parseList(e.target.value); }, + disabled: picking || actions.busy, + oninput: (e) => { names = parseList(e.target.value); activePreset = null; }, value: names.join('\n'), }), ]), h('label', { class: 'stack-1', style: { width: '110px' } }, [ h('span', { class: 'label' }, ['Winners']), h('input', { type: 'number', class: 'input', min: 1, max: 50, value: String(winnerCount), + disabled: picking || actions.busy, oninput: (e) => { winnerCount = Math.max(1, Math.min(50, parseInt(e.target.value, 10) || 1)); } }), ]), ]), + h('div', { class: 'center' }, [h('button', { + class: 'btn btn-ghost', type: 'button', disabled: picking || actions.busy || names.length < 1, + onclick: async () => { + try { + const saved = await ctx.packs.exportStarter({ + kind: 'lottery-presets', name: 'Name list', + content: { presets: [{ id: 'name-list', name: 'Name list', tool: 'name-picker', items: names.slice() }] }, + }); + if (saved) toast('Starter pack exported.', 'success'); + } catch (error) { toast(`Starter export failed: ${error.message || error}`, 'danger'); } + }, + }, ['Export starter pack'])]), h('div', { class: 'center' }, [ h('button', { class: 'btn btn-primary btn-big', onclick: () => doPick(), - disabled: picking, - }, [picking ? 'picking…' : (last ? 'Pick again' : 'Pick')]), + disabled: picking || actions.busy, + }, [picking ? 'Picking…' : 'Pick']), ]), - last && !picking ? h('div', { class: 'lottery-result' }, [buildResultPanel(last)]) : null, + last && !picking ? buildResultPanel(last) : null, ])); } - async function doPick() { - if (picking) return; - if (names.length < 1) { last = { error: 'Need at least 1 name' }; render(); return; } - const k = Math.min(winnerCount, names.length); + async function doPick(repeatRequest = null) { + if (picking || actions.busy) return; + const request = repeatRequest + ? { names: repeatRequest.names.slice(), winnerCount: repeatRequest.winnerCount, preset: repeatRequest.preset || null } + : { names: names.slice(), winnerCount, preset: activePreset ? structuredClone(activePreset) : null }; + if (request.names.length < 1) { last = { error: 'Need at least 1 name' }; render(); return; } + const k = Math.min(request.winnerCount, request.names.length); picking = true; render(); try { const result = await ceremoniously(ceremony, () => - ctx.entropy.request({ kind: 'permutation', choices: names, source: 'preferred' }) + ctx.entropy.request({ kind: 'permutation', choices: request.names, source: 'preferred' }) ); + if (!actions.active) return; const winners = result.value.slice(0, k); last = { - names: names.slice(), + names: request.names, winners, winnerCount: k, + request, provenance: result.provenance, drawn_at: new Date().toISOString(), + ...(request.preset ? { pack_reference: presetReference(ctx, request.preset) } : {}), }; onPushRecent({ type: 'name-picker', - summary: `picked ${k} of ${names.length} → ${winners.slice(0, 3).join(', ')}${k > 3 ? '…' : ''}`, + summary: `picked ${k} of ${request.names.length} → ${winners.slice(0, 3).join(', ')}${k > 3 ? '…' : ''}`, at: last.drawn_at, payload: last, }); } catch (e) { + if (!actions.active) return; last = { error: String(e?.message || e) }; } await settleAfter(ceremony); + if (!actions.active) return; picking = false; render(); } function buildResultPanel(r) { - if (r.error) { - return h('div', { class: 'panel reveal' }, [ - h('h3', { class: 'amber' }, ['could not pick']), - h('p', { class: 'muted' }, [r.error]), - ]); - } - return h('div', { class: 'panel reveal stack-4' }, [ - h('h3', { class: 'amber center-x', style: { textAlign: 'center' } }, [ - r.winnerCount === 1 ? 'Winner' : `Winners (${r.winnerCount})`, - ]), - h('ol', { class: 'name-winners' }, r.winners.map((n) => h('li', null, [String(n)]))), - h('p', { class: 'muted center-x' }, [`from ${r.names.length} names · drawn at ${humanDate(r.drawn_at)}`]), - buildProvenance(r.provenance), - h('div', { class: 'row center' }, [ - h('button', { class: 'btn', onclick: () => save(r) }, ['Save to archive']), - ]), - ]); - } - - async function save(r) { - await saveLotteryItem(ctx, 'name-picker', { - human_summary: `Picked ${r.winnerCount} of ${r.names.length}: ${r.winners.join(', ')}`, - names: r.names, winners: r.winners, winnerCount: r.winnerCount, - drawn_at: r.drawn_at, provenance: r.provenance, + const receipt = r.error ? null : receiptFor(r); + return buildLotteryResult({ + label: 'Name pick', + result: r, + content: r.error ? [] : [ + h('h3', { class: 'amber center-x', style: { textAlign: 'center' } }, [ + r.winnerCount === 1 ? 'Winner' : `Winners (${r.winnerCount})`, + ]), + h('ol', { class: 'name-winners' }, r.winners.map((name) => h('li', null, [String(name)]))), + ], + meta: r.error ? '' : `From ${r.names.length} names · drawn at ${humanDate(r.drawn_at)}`, + busyAction: actions.busyAction, + ceremony, + receipt, + onSave: () => actions.save(r, { + human_summary: `Picked ${r.winnerCount} of ${r.names.length}: ${r.winners.join(', ')}`, + names: r.names, winners: r.winners, winnerCount: r.winnerCount, + drawn_at: r.drawn_at, provenance: r.provenance, + }), + onExport: () => actions.export(receiptFor(r)), + onRepeat: () => doPick(r.request), + onClear: () => { + if (picking || actions.busy) return; + last = null; + render(); + }, }); } render(); } -export function unmount() { /* */ } +export function unmount() { + stopActive?.(); + stopActive = null; +} + +function receiptFor(r) { + return { type: 'name-picker', summary: `Winners: ${r.winners.join(', ')}`, drawn_at: r.drawn_at, provenance: r.provenance }; +} function parseList(text) { - return text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + return text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).slice(0, 1000); } diff --git a/src/chambers/lottery/number.js b/src/chambers/lottery/number.js index 798d147..517f3d6 100644 --- a/src/chambers/lottery/number.js +++ b/src/chambers/lottery/number.js @@ -4,39 +4,55 @@ import { h, clear } from '../../lib/dom.js'; import { humanDate } from '../../lib/format.js'; -import { ceremoniously, settleAfter, buildProvenance, saveLotteryItem } from './_shared.js'; +import { + buildLotteryResult, buildPresetControl, ceremoniously, createLotteryActions, presetKey, presetReference, settleAfter, +} from './_shared.js'; export const id = 'number'; export const label = 'Number'; +let stopActive = null; + export async function mount(rootEl, ctx, opts) { const ceremony = opts.ceremony; const onPushRecent = opts.onPushRecent; + const presets = opts.presets || []; let lo = 1; let hi = 100; let count = 1; let last = null; let drawing = false; + let activePreset = null; + const actions = createLotteryActions({ + ctx, type: 'number', ceremony, render, current: () => last, isRunning: () => drawing, + }); + stopActive = () => actions.stop(); function render() { + if (!actions.active) return; clear(rootEl); - rootEl.appendChild(h('div', { class: 'lottery-picker number-picker stack-5' }, [ + rootEl.appendChild(h('div', { class: `lottery-picker number-picker stack-5${last && !drawing ? ' has-result' : ''}` }, [ h('div', { class: 'picker-intro center-x' }, [ h('p', { class: 'muted' }, ['A number from the universe. Uniform across the range.']), ]), + buildPresetControl(presets, presetKey(activePreset), (preset) => { + activePreset = preset; + if (preset) { lo = preset.minimum; hi = preset.maximum; } + render(); + }, drawing || actions.busy), h('div', { class: 'number-controls row center', style: { gap: '20px' } }, [ h('label', { class: 'stack-1' }, [ h('span', { class: 'label' }, ['Min']), - h('input', { type: 'number', class: 'input', value: String(lo), style: { width: '110px' }, oninput: (e) => { lo = parseInt(e.target.value, 10) || 0; } }), + h('input', { type: 'number', class: 'input', value: String(lo), disabled: drawing || actions.busy, style: { width: '110px' }, oninput: (e) => { lo = parseInt(e.target.value, 10) || 0; activePreset = null; } }), ]), h('label', { class: 'stack-1' }, [ h('span', { class: 'label' }, ['Max']), - h('input', { type: 'number', class: 'input', value: String(hi), style: { width: '110px' }, oninput: (e) => { hi = parseInt(e.target.value, 10) || 0; } }), + h('input', { type: 'number', class: 'input', value: String(hi), disabled: drawing || actions.busy, style: { width: '110px' }, oninput: (e) => { hi = parseInt(e.target.value, 10) || 0; activePreset = null; } }), ]), h('label', { class: 'stack-1' }, [ h('span', { class: 'label' }, ['How many']), - h('input', { type: 'number', class: 'input', value: String(count), min: 1, max: 50, style: { width: '90px' }, oninput: (e) => { count = Math.max(1, Math.min(50, parseInt(e.target.value, 10) || 1)); } }), + h('input', { type: 'number', class: 'input', value: String(count), min: 1, max: 50, disabled: drawing || actions.busy, style: { width: '90px' }, oninput: (e) => { count = Math.max(1, Math.min(50, parseInt(e.target.value, 10) || 1)); } }), ]), ]), h('div', { class: 'number-display center' }, [ @@ -48,63 +64,80 @@ export async function mount(rootEl, ctx, opts) { h('button', { class: 'btn btn-primary btn-big', onclick: () => doDraw(), - disabled: drawing, - }, [drawing ? 'drawing…' : (last ? 'Draw again' : 'Draw')]), + disabled: drawing || actions.busy, + }, [drawing ? 'Drawing…' : 'Draw']), ]), - last && !drawing ? h('div', { class: 'lottery-result' }, [buildResultPanel(last)]) : null, + last && !drawing ? buildResultPanel(last) : null, ])); } - async function doDraw() { - if (drawing) return; - if (hi < lo) { last = { error: 'max must be ≥ min' }; render(); return; } + async function doDraw(repeatRequest = null) { + if (drawing || actions.busy) return; + const request = repeatRequest + ? { lo: repeatRequest.lo, hi: repeatRequest.hi, count: repeatRequest.count, preset: repeatRequest.preset || null } + : { lo, hi, count, preset: activePreset ? structuredClone(activePreset) : null }; + if (request.hi < request.lo) { last = { error: 'max must be ≥ min' }; render(); return; } drawing = true; render(); try { const result = await ceremoniously(ceremony, () => - ctx.entropy.request({ kind: 'integer', range: [lo, hi], count, source: 'preferred' }) + ctx.entropy.request({ kind: 'integer', range: [request.lo, request.hi], count: request.count, source: 'preferred' }) ); + if (!actions.active) return; const values = Array.isArray(result.value) ? result.value : [result.value]; - last = { values, lo, hi, count, provenance: result.provenance, drawn_at: new Date().toISOString() }; + last = { + values, ...request, request, provenance: result.provenance, drawn_at: new Date().toISOString(), + ...(request.preset ? { pack_reference: presetReference(ctx, request.preset) } : {}), + }; onPushRecent({ type: 'number', - summary: `[${lo},${hi}] → ${values.slice(0, 6).join(', ')}${values.length > 6 ? '…' : ''}`, + summary: `[${request.lo},${request.hi}] → ${values.slice(0, 6).join(', ')}${values.length > 6 ? '…' : ''}`, at: last.drawn_at, payload: last, }); } catch (e) { + if (!actions.active) return; last = { error: String(e?.message || e) }; } await settleAfter(ceremony); + if (!actions.active) return; drawing = false; render(); } function buildResultPanel(r) { - if (r.error) { - return h('div', { class: 'panel reveal' }, [ - h('h3', { class: 'amber' }, ['could not draw']), - h('p', { class: 'muted' }, [r.error]), - ]); - } - return h('div', { class: 'panel reveal stack-4' }, [ - h('p', { class: 'muted center-x' }, [`${r.count} number${r.count === 1 ? '' : 's'} in [${r.lo}, ${r.hi}] · drawn at ${humanDate(r.drawn_at)}`]), - buildProvenance(r.provenance), - h('div', { class: 'row center' }, [ - h('button', { class: 'btn', onclick: () => save(r) }, ['Save to archive']), - ]), - ]); - } - - async function save(r) { - await saveLotteryItem(ctx, 'number', { - human_summary: `${r.count} number${r.count === 1 ? '' : 's'} in [${r.lo}, ${r.hi}]: ${r.values.join(', ')}`, - lo: r.lo, hi: r.hi, count: r.count, values: r.values, - drawn_at: r.drawn_at, provenance: r.provenance, + const receipt = r.error ? null : receiptFor(r); + return buildLotteryResult({ + label: 'Number draw', + result: r, + content: r.error ? [] : [h('div', { class: 'number-result mono center-x' }, [r.values.join(' · ')])], + meta: r.error ? '' : `${r.count} number${r.count === 1 ? '' : 's'} in [${r.lo}, ${r.hi}] · drawn at ${humanDate(r.drawn_at)}`, + busyAction: actions.busyAction, + ceremony, + receipt, + onSave: () => actions.save(r, { + human_summary: `${r.count} number${r.count === 1 ? '' : 's'} in [${r.lo}, ${r.hi}]: ${r.values.join(', ')}`, + lo: r.lo, hi: r.hi, count: r.count, values: r.values, + drawn_at: r.drawn_at, provenance: r.provenance, + }), + onExport: () => actions.export(receiptFor(r)), + onRepeat: () => doDraw(r.request), + onClear: () => { + if (drawing || actions.busy) return; + last = null; + render(); + }, }); } render(); } -export function unmount() { /* */ } +export function unmount() { + stopActive?.(); + stopActive = null; +} + +function receiptFor(r) { + return { type: 'number', summary: `[${r.lo}, ${r.hi}] → ${r.values.join(', ')}`, drawn_at: r.drawn_at, provenance: r.provenance }; +} diff --git a/src/chambers/lottery/receipt.js b/src/chambers/lottery/receipt.js new file mode 100644 index 0000000..24d19cd --- /dev/null +++ b/src/chambers/lottery/receipt.js @@ -0,0 +1,96 @@ +/** Render and download a shareable Lottery result receipt. */ + +import { shortHash } from '../../lib/format.js'; + +const W = 1600; +const H = 1000; + +export async function exportLotteryReceipt(item) { + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const c = canvas.getContext('2d'); + if (!c) throw new Error('canvas rendering is unavailable'); + + c.fillStyle = '#0a0e14'; + c.fillRect(0, 0, W, H); + c.strokeStyle = '#29313d'; + c.lineWidth = 2; + c.strokeRect(48, 48, W - 96, H - 96); + + c.textBaseline = 'top'; + c.textAlign = 'left'; + c.fillStyle = '#d4a574'; + c.font = '600 28px system-ui, sans-serif'; + c.fillText('SORTILUNE', 88, 82); + c.fillStyle = '#8b94a3'; + c.font = '18px ui-monospace, Consolas, monospace'; + c.fillText(`LOTTERY RECEIPT · ${String(item.type || 'result').toUpperCase()}`, 88, 124); + + c.textAlign = 'right'; + c.fillText(item.drawn_at || new Date().toISOString(), W - 88, 124); + + c.textAlign = 'left'; + c.fillStyle = '#8b94a3'; + c.font = '18px ui-monospace, Consolas, monospace'; + c.fillText('RESULT', 88, 214); + c.fillStyle = '#e8ecf1'; + c.font = '400 52px system-ui, sans-serif'; + wrapText(c, item.summary || 'Result', 88, 252, W - 176, 66, 5); + + const provenance = item.provenance || {}; + const rows = [ + ['source', provenance.source_name || provenance.source_id || 'unknown'], + ['source_id', provenance.source_id || 'unknown'], + ['fetched_at', provenance.fetched_at || 'unknown'], + ['raw', provenance.raw || ''], + ['signature', provenance.signature || ''], + ['pulse_index', provenance.extra?.pulse_index != null ? String(provenance.extra.pulse_index) : ''], + ['pulse_uri', provenance.extra?.pulse_uri || ''], + ].filter(([, value]) => value); + + c.fillStyle = '#8b94a3'; + c.font = '18px ui-monospace, Consolas, monospace'; + c.fillText('PROVENANCE', 88, 610); + rows.forEach(([key, value], i) => { + c.fillStyle = '#8b94a3'; + c.fillText(key.padEnd(14), 88, 650 + i * 38); + c.fillStyle = '#e8ecf1'; + c.fillText(key === 'raw' || key === 'signature' ? shortHash(value, 42) : String(value), 330, 650 + i * 38); + }); + + c.textAlign = 'right'; + c.fillStyle = '#8b94a3'; + c.font = '16px ui-monospace, Consolas, monospace'; + c.fillText('The archive JSON contains the complete, untruncated provenance.', W - 88, H - 88); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) throw new Error('receipt export failed'); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sortilune-${item.type || 'lottery'}-${Date.now()}.png`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1500); + return blob; +} + +function wrapText(c, text, x, y, maxWidth, lineHeight, maxLines) { + const words = String(text).split(/\s+/); + let line = ''; + let lineNumber = 0; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (line && c.measureText(candidate).width > maxWidth) { + c.fillText(line, x, y + lineNumber * lineHeight); + lineNumber++; + line = word; + if (lineNumber >= maxLines - 1) break; + } else { + line = candidate; + } + } + if (lineNumber < maxLines) c.fillText(line, x, y + lineNumber * lineHeight); +} diff --git a/src/chambers/lottery/shuffle.js b/src/chambers/lottery/shuffle.js index 98fcc5a..0ea780a 100644 --- a/src/chambers/lottery/shuffle.js +++ b/src/chambers/lottery/shuffle.js @@ -4,106 +4,152 @@ import { h, clear } from '../../lib/dom.js'; import { humanDate } from '../../lib/format.js'; -import { ceremoniously, settleAfter, buildProvenance, saveLotteryItem } from './_shared.js'; +import { + buildLotteryResult, buildPresetControl, ceremoniously, createLotteryActions, presetKey, presetReference, settleAfter, toast, +} from './_shared.js'; export const id = 'shuffle'; export const label = 'Shuffle'; +let stopActive = null; + export async function mount(rootEl, ctx, opts) { const ceremony = opts.ceremony; const onPushRecent = opts.onPushRecent; + const presets = opts.presets || []; let items = ['ace', 'two', 'three', 'four', 'five']; let last = null; let shuffling = false; + let activePreset = null; + const actions = createLotteryActions({ + ctx, type: 'shuffle', ceremony, render, current: () => last, isRunning: () => shuffling, + }); + stopActive = () => actions.stop(); function render() { + if (!actions.active) return; clear(rootEl); - rootEl.appendChild(h('div', { class: 'lottery-picker shuffle-picker stack-5' }, [ + rootEl.appendChild(h('div', { class: `lottery-picker shuffle-picker stack-5${last && !shuffling ? ' has-result' : ''}` }, [ h('div', { class: 'picker-intro center-x' }, [ h('p', { class: 'muted' }, ['Paste a list (one per line). Get a new permutation.']), ]), + buildPresetControl(presets, presetKey(activePreset), (preset) => { + activePreset = preset; + if (preset) items = preset.items.slice(); + render(); + }, shuffling || actions.busy), h('label', { class: 'stack-1' }, [ h('span', { class: 'label' }, ['Items (one per line)']), h('textarea', { class: 'textarea', rows: 8, - oninput: (e) => { items = parseList(e.target.value); }, + disabled: shuffling || actions.busy, + oninput: (e) => { items = parseList(e.target.value); activePreset = null; }, value: items.join('\n'), }), ]), + h('div', { class: 'center' }, [h('button', { + class: 'btn btn-ghost', type: 'button', disabled: shuffling || actions.busy || items.length < 2, + onclick: async () => { + try { + const saved = await ctx.packs.exportStarter({ + kind: 'lottery-presets', name: 'Shuffle list', + content: { presets: [{ id: 'shuffle-list', name: 'Shuffle list', tool: 'shuffle', items: items.slice() }] }, + }); + if (saved) toast('Starter pack exported.', 'success'); + } catch (error) { toast(`Starter export failed: ${error.message || error}`, 'danger'); } + }, + }, ['Export starter pack'])]), h('div', { class: 'center' }, [ h('button', { class: 'btn btn-primary btn-big', onclick: () => doShuffle(), - disabled: shuffling, - }, [shuffling ? 'shuffling…' : (last ? 'Shuffle again' : 'Shuffle')]), + disabled: shuffling || actions.busy, + }, [shuffling ? 'Shuffling…' : 'Shuffle']), ]), - last && !shuffling ? h('div', { class: 'lottery-result' }, [buildResultPanel(last)]) : null, + last && !shuffling ? buildResultPanel(last) : null, ])); } - async function doShuffle() { - if (shuffling) return; - if (items.length < 2) { last = { error: 'Need at least 2 items' }; render(); return; } + async function doShuffle(repeatRequest = null) { + if (shuffling || actions.busy) return; + const request = repeatRequest + ? { items: repeatRequest.items.slice(), preset: repeatRequest.preset || null } + : { items: items.slice(), preset: activePreset ? structuredClone(activePreset) : null }; + if (request.items.length < 2) { last = { error: 'Need at least 2 items' }; render(); return; } shuffling = true; render(); try { const result = await ceremoniously(ceremony, () => - ctx.entropy.request({ kind: 'permutation', choices: items, source: 'preferred' }) + ctx.entropy.request({ kind: 'permutation', choices: request.items, source: 'preferred' }) ); + if (!actions.active) return; last = { - before: items.slice(), + before: request.items, after: result.value, - count: items.length, + count: request.items.length, + request, provenance: result.provenance, drawn_at: new Date().toISOString(), + ...(request.preset ? { pack_reference: presetReference(ctx, request.preset) } : {}), }; onPushRecent({ type: 'shuffle', - summary: `shuffled ${items.length} items`, + summary: `shuffled ${request.items.length} items`, at: last.drawn_at, payload: last, }); } catch (e) { + if (!actions.active) return; last = { error: String(e?.message || e) }; } await settleAfter(ceremony); + if (!actions.active) return; shuffling = false; render(); } function buildResultPanel(r) { - if (r.error) { - return h('div', { class: 'panel reveal' }, [ - h('h3', { class: 'amber' }, ['could not shuffle']), - h('p', { class: 'muted' }, [r.error]), - ]); - } - return h('div', { class: 'panel reveal stack-4' }, [ - h('h3', { class: 'amber center-x', style: { textAlign: 'center' } }, [`Shuffled · ${r.count} items`]), - h('ol', { class: 'shuffle-result' }, r.after.map((it) => h('li', null, [String(it)]))), - h('p', { class: 'muted center-x' }, [`drawn at ${humanDate(r.drawn_at)}`]), - buildProvenance(r.provenance), - h('div', { class: 'row center' }, [ - h('button', { class: 'btn', onclick: () => save(r) }, ['Save to archive']), - ]), - ]); - } - - async function save(r) { - await saveLotteryItem(ctx, 'shuffle', { - human_summary: `Shuffled ${r.count} items: ${r.after.slice(0, 5).join(', ')}${r.after.length > 5 ? '…' : ''}`, - before: r.before, after: r.after, count: r.count, - drawn_at: r.drawn_at, provenance: r.provenance, + const receipt = r.error ? null : receiptFor(r); + return buildLotteryResult({ + label: 'Shuffle', + result: r, + content: r.error ? [] : [ + h('h3', { class: 'amber center-x', style: { textAlign: 'center' } }, [`Shuffled · ${r.count} items`]), + h('ol', { class: 'shuffle-result' }, r.after.map((item) => h('li', null, [String(item)]))), + ], + meta: r.error ? '' : `Drawn at ${humanDate(r.drawn_at)}`, + busyAction: actions.busyAction, + ceremony, + receipt, + onSave: () => actions.save(r, { + human_summary: `Shuffled ${r.count} items: ${r.after.slice(0, 5).join(', ')}${r.after.length > 5 ? '…' : ''}`, + before: r.before, after: r.after, count: r.count, + drawn_at: r.drawn_at, provenance: r.provenance, + }), + onExport: () => actions.export(receiptFor(r)), + onRepeat: () => doShuffle(r.request), + onClear: () => { + if (shuffling || actions.busy) return; + last = null; + render(); + }, }); } render(); } -export function unmount() { /* */ } +export function unmount() { + stopActive?.(); + stopActive = null; +} + +function receiptFor(r) { + return { type: 'shuffle', summary: `Shuffled ${r.count} items: ${r.after.slice(0, 8).join(', ')}`, drawn_at: r.drawn_at, provenance: r.provenance }; +} function parseList(text) { - return text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + return text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).slice(0, 1000); } diff --git a/src/chambers/lottery/wheel.js b/src/chambers/lottery/wheel.js index 0d20146..201900f 100644 --- a/src/chambers/lottery/wheel.js +++ b/src/chambers/lottery/wheel.js @@ -4,61 +4,110 @@ import { h, svg, clear } from '../../lib/dom.js'; import { humanDate } from '../../lib/format.js'; -import { ceremoniously, settleAfter, buildProvenance, saveLotteryItem } from './_shared.js'; +import { + buildLotteryResult, buildPresetControl, createLotteryActions, presetKey, presetReference, settleAfter, toast, +} from './_shared.js'; export const id = 'wheel'; export const label = 'Wheel'; +let stopActive = null; + export async function mount(rootEl, ctx, opts) { const ceremony = opts.ceremony; const onPushRecent = opts.onPushRecent; + const presets = opts.presets || []; let items = ['North', 'South', 'East', 'West', 'Above', 'Below']; let last = null; let spinning = false; let rotation = 0; + let activePreset = null; + const actions = createLotteryActions({ + ctx, type: 'wheel', ceremony, render, current: () => last, isRunning: () => spinning, + }); + stopActive = () => actions.stop(); function render() { + if (!actions.active) return; clear(rootEl); - const wheelEl = wheelSVG(items, rotation, last?.winner); - rootEl.appendChild(h('div', { class: 'lottery-picker wheel-picker stack-5' }, [ + const displayItems = last && !spinning && !last.error ? last.items : items; + const displayRotation = last && !spinning && !last.error ? last.rotation : rotation; + const wheelEl = wheelSVG(displayItems, displayRotation, last?.winner); + rootEl.appendChild(h('div', { class: `lottery-picker wheel-picker stack-5${last && !spinning ? ' has-result' : ''}` }, [ h('div', { class: 'picker-intro center-x' }, [ h('p', { class: 'muted' }, ['Enter options. The wheel lands on one.']), ]), + buildPresetControl(presets, presetKey(activePreset), (preset) => { + activePreset = preset; + if (preset) items = preset.items.slice(); + rotation = 0; + render(); + }, spinning || actions.busy), h('label', { class: 'stack-1' }, [ h('span', { class: 'label' }, ['Options (one per line)']), h('textarea', { class: 'textarea', rows: 5, - oninput: (e) => { items = parseList(e.target.value); rotation = 0; last = null; render(); }, + disabled: spinning || actions.busy, + oninput: (e) => { items = parseList(e.target.value); activePreset = null; rotation = 0; render(); }, value: items.join('\n') }), ]), + h('div', { class: 'center' }, [h('button', { + class: 'btn btn-ghost', type: 'button', disabled: spinning || actions.busy || items.length < 2, + onclick: async () => { + try { + const saved = await ctx.packs.exportStarter({ + kind: 'lottery-presets', name: 'Wheel options', + content: { presets: [{ id: 'wheel-options', name: 'Wheel options', tool: 'wheel', items: items.slice() }] }, + }); + if (saved) toast('Starter pack exported.', 'success'); + } catch (error) { toast(`Starter export failed: ${error.message || error}`, 'danger'); } + }, + }, ['Export starter pack'])]), h('div', { class: 'wheel-stage center' }, [wheelEl]), h('div', { class: 'center' }, [ - h('button', { class: 'btn btn-primary btn-big', onclick: () => doSpin(), disabled: spinning }, - [spinning ? 'spinning…' : (last ? 'Spin again' : 'Spin')]), + h('button', { class: 'btn btn-primary btn-big', onclick: () => doSpin(), disabled: spinning || actions.busy }, + [spinning ? 'Spinning…' : 'Spin']), ]), - last && !spinning ? h('div', { class: 'lottery-result' }, [buildResultPanel(last)]) : null, + last && !spinning ? buildResultPanel(last) : null, ])); } - async function doSpin() { - if (spinning) return; - if (items.length < 2) { last = { error: 'Need at least 2 options' }; render(); return; } + async function doSpin(repeatRequest = null) { + if (spinning || actions.busy) return; + const request = repeatRequest + ? { items: repeatRequest.items.slice(), preset: repeatRequest.preset || null } + : { items: items.slice(), preset: activePreset ? structuredClone(activePreset) : null }; + if (request.items.length < 2) { last = { error: 'Need at least 2 options' }; render(); return; } spinning = true; + render(); try { // Resolve the winner first (so animation lands honestly on the entropy result). - const result = await ctx.entropy.request({ kind: 'integer', range: [0, items.length - 1], source: 'preferred' }); + const result = await ctx.entropy.request({ kind: 'integer', range: [0, request.items.length - 1], source: 'preferred' }); + if (!actions.active) return; const winnerIdx = result.value; - const segAngle = 360 / items.length; - const targetDeg = 360 * 4 + (360 - (winnerIdx * segAngle + segAngle / 2)); + const segAngle = 360 / request.items.length; + const previousRotation = rotation; + const desiredAngle = (360 - (winnerIdx * segAngle + segAngle / 2)) % 360; + const currentAngle = ((previousRotation % 360) + 360) % 360; + const landingDelta = (desiredAngle - currentAngle + 360) % 360; + const targetDeg = previousRotation + (360 * 4) + landingDelta; rotation = targetDeg; render(); - await new Promise((r) => setTimeout(r, ceremony === 'quick' ? 100 : 1800)); + const duration = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + ? 0 + : (ceremony === 'quick' ? 100 : 1800); + animateWheel(rootEl, previousRotation, targetDeg, duration); + await new Promise((r) => setTimeout(r, duration)); + if (!actions.active) return; last = { - items: items.slice(), - winner: items[winnerIdx], + items: request.items, + winner: request.items[winnerIdx], winnerIdx, + rotation: targetDeg, + request, provenance: result.provenance, drawn_at: new Date().toISOString(), + ...(request.preset ? { pack_reference: presetReference(ctx, request.preset) } : {}), }; onPushRecent({ type: 'wheel', @@ -67,48 +116,72 @@ export async function mount(rootEl, ctx, opts) { payload: last, }); } catch (e) { + if (!actions.active) return; last = { error: String(e?.message || e) }; } await settleAfter(ceremony); + if (!actions.active) return; spinning = false; render(); } function buildResultPanel(r) { - if (r.error) { - return h('div', { class: 'panel reveal' }, [ - h('h3', { class: 'amber' }, ['could not spin']), - h('p', { class: 'muted' }, [r.error]), - ]); - } - return h('div', { class: 'panel reveal stack-4' }, [ - h('div', { class: 'wheel-winner center-x', style: { textAlign: 'center' } }, [ + const receipt = r.error ? null : receiptFor(r); + return buildLotteryResult({ + label: 'Wheel spin', + result: r, + content: r.error ? [] : [h('div', { class: 'wheel-winner center-x', style: { textAlign: 'center' } }, [ h('div', { class: 'label' }, ['Landed on']), h('h2', { class: 'amber wheel-winner-big' }, [String(r.winner)]), - ]), - h('p', { class: 'muted center-x' }, [`from ${r.items.length} options · ${humanDate(r.drawn_at)}`]), - buildProvenance(r.provenance), - h('div', { class: 'row center' }, [ - h('button', { class: 'btn', onclick: () => save(r) }, ['Save to archive']), - ]), - ]); - } - - async function save(r) { - await saveLotteryItem(ctx, 'wheel', { - human_summary: `Wheel landed on: ${r.winner} (of ${r.items.length})`, - items: r.items, winner: r.winner, winnerIdx: r.winnerIdx, - drawn_at: r.drawn_at, provenance: r.provenance, + ])], + meta: r.error ? '' : `From ${r.items.length} options · ${humanDate(r.drawn_at)}`, + busyAction: actions.busyAction, + ceremony, + receipt, + onSave: () => actions.save(r, { + human_summary: `Wheel landed on: ${r.winner} (of ${r.items.length})`, + items: r.items, winner: r.winner, winnerIdx: r.winnerIdx, + drawn_at: r.drawn_at, provenance: r.provenance, + }), + onExport: () => actions.export(receiptFor(r)), + onRepeat: () => doSpin(r.request), + onClear: () => { + if (spinning || actions.busy) return; + last = null; + render(); + }, }); } render(); } -export function unmount() { /* */ } +function animateWheel(rootEl, from, to, duration) { + const group = rootEl.querySelector('.wheel-svg g'); + if (!group) return; + group.style.transition = 'none'; + group.style.transform = `rotate(${from}deg)`; + if (duration === 0) { + group.style.transform = `rotate(${to}deg)`; + return; + } + requestAnimationFrame(() => requestAnimationFrame(() => { + group.style.transition = `transform ${duration}ms cubic-bezier(0.2, 0.95, 0.32, 1)`; + group.style.transform = `rotate(${to}deg)`; + })); +} + +export function unmount() { + stopActive?.(); + stopActive = null; +} + +function receiptFor(r) { + return { type: 'wheel', summary: `Wheel landed on: ${r.winner}`, drawn_at: r.drawn_at, provenance: r.provenance }; +} function parseList(text) { - return text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean); + return text.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).slice(0, 100); } function wheelSVG(items, rotation, winner) { @@ -139,7 +212,7 @@ function wheelSVG(items, rotation, winner) { transform="rotate(${midA + 90} ${lx.toFixed(1)} ${ly.toFixed(1)})">${escape(items[i])}`); } return svg(` - + ${segs.join('')} diff --git a/src/chambers/manifest.js b/src/chambers/manifest.js index fd7f9f5..7fd6167 100644 --- a/src/chambers/manifest.js +++ b/src/chambers/manifest.js @@ -1,6 +1,6 @@ /** * The eight chambers. The single source of truth for navigation order, - * display names, taglines, phase-in-roadmap, and SVG line icons. + * display names, taglines, descriptions, and SVG line icons. * All icons: 24×24, single 1.5px stroke, currentColor, cosmic-instrumentation * vocabulary (dials, crosshairs, orbital lines, brackets, tick marks). */ @@ -13,7 +13,6 @@ export const CHAMBERS = [ displayName: 'The Oracle', tagline: 'Daily draw from the universe.', description: 'Pick a deck, draw a card with physical randomness, see where the entropy came from. Tarot, I-Ching, Runes, and an original cosmic deck. Every draw is archived forever, with its provenance.', - phase: 4, icon: ` @@ -24,8 +23,7 @@ export const CHAMBERS = [ id: 'decider', displayName: 'The Decider', tagline: 'A choice cast by entropy.', - description: 'Pose a question, list the options, pick a source. The universe answers. NIST-anchored "high stakes" mode generates a cryptographically signed decision certificate.', - phase: 3, + description: 'Pose a question, list the options, pick a source. High-stakes mode requires NIST with no silent fallback and generates a shareable decision receipt.', icon: ` @@ -39,7 +37,6 @@ export const CHAMBERS = [ displayName: 'The Diary', tagline: 'Write against the day’s pulse.', description: 'Each entry anchored to a NIST beacon pulse. The universe gives you a question, a color, a number, a direction, a word — you write against it. Markdown, plain files, calendar browse.', - phase: 5, icon: ` @@ -53,7 +50,6 @@ export const CHAMBERS = [ displayName: 'The Constraint', tagline: 'A discipline drawn at random.', description: 'Inspired by Eno’s Oblique Strategies. Creative, behavioral, perceptual, linguistic, whimsical — drawn from physical entropy. Accept or pass; what you accept is archived.', - phase: 4, icon: ` @@ -66,7 +62,6 @@ export const CHAMBERS = [ displayName: 'The Canvas', tagline: 'Generative artwork from real entropy.', description: 'Constellations, stellar spectra, particle traces, Lissajous figures, Voronoi cosmic webs, wave interference. Each piece is a portrait of this moment in the universe. Export PNG; set as wallpaper.', - phase: 6, icon: ` @@ -80,8 +75,7 @@ export const CHAMBERS = [ id: 'symphony', displayName: 'The Symphony', tagline: 'Live sound from a living planet.', - description: 'Earthquakes, weather, beacon pulses interpreted as tones in D Dorian. The planet is the orchestra. Save a session log; replay deterministically.', - phase: 7, + description: 'Earthquakes, weather, and beacon pulses interpreted as tones in D Dorian. Save, replay, and export complete session scores.', icon: ` @@ -92,9 +86,8 @@ export const CHAMBERS = [ { id: 'beacon', displayName: 'The Beacon', - tagline: 'Tamper-evident timestamps.', - description: 'Write an entry. Seal it against a NIST beacon pulse with SHA-256. Anyone can verify, later, that you wrote it before that public, unforgeable moment. Proves timing, not secrecy.', - phase: 5, + tagline: 'Public-pulse checksum recipes.', + description: 'Bind text to a public NIST pulse with SHA-256 and later recompute the checksum. This checks internal consistency; it does not prove when the text was written.', icon: ` @@ -106,7 +99,6 @@ export const CHAMBERS = [ displayName: 'The Lottery', tagline: 'A tiny ritual for everyday picks.', description: 'Coin, dice, wheel, name-picker, random number, list shuffle. Quick, ritual, or receipt — three ceremony levels. Every flip carries its provenance.', - phase: 3, icon: ` diff --git a/src/chambers/oracle/decks/generated/cosmic.generated.js b/src/chambers/oracle/decks/generated/cosmic.generated.js new file mode 100644 index 0000000..dc63ba7 --- /dev/null +++ b/src/chambers/oracle/decks/generated/cosmic.generated.js @@ -0,0 +1,6 @@ +// Generated by scripts/generate-built-in-content.mjs. Do not edit by hand. +import { inflateDeck } from './inflate.js'; +const keys=["id","name","category","keywords","description"]; +const dictionaries={"2":["interior","motion","boundary","light","matter","scale"]}; +const rows=[[1,"Stillness",0,["pause","equilibrium","rest","held breath"],"The conditiona system whose forcestemporarily agreed. Stillnessthe absencemotionits perfect balance;looks like silencea negotiation heldzero. The cardyou can remain quiet long enoughhearthe equilibriumpaying for."],[2,"Quiescence",0,["dormancy","low-energy state","biding","winter mind"],"The chosen low-energy state, banked  spent. Quiescence isbeardenseedcold soil, machinery turned downnot switched off. The cardnoticepartyouhibernatingpurpose anditwaitingthe worldprovide."],[3,"Latency",0,["delay","stored response","incubation","unspoken"],"The intervalthe causevisible effect. Latency isheld charge,answer formedmouth before itvoiced,photographbath beforeimage appears. The cardsomethingalready happenedyou,is simply waitingits travel timecomplete."],[4,"Hush",0,["lowered signal","intimacyquiet","muffled field"],"The deliberate loweringworld's volume,snow-fall hush infootsteps disappear. Hushsilencea softer register,frequency atsubtle things finally become audible. recommends turninggain down onloud soquiet instrument can be heard."],[5,"Pulse",0,["beat","rhythmic insistence","vital sign","metronome"],"The repeating beatprovessystemstill alive. A pulsesmall, periodic,indifferentwhether anyonelistening;asksto continue. draws attentionslow steady thing youstopped counting, andthat itbeen counting you."],[6,"Resonance",0,["sympathetic vibration","tuning","matched frequency"],"What happensone body's vibration findsmatchanothertwo beginamplifyotherfree. Resonance isempty wineglasssingsthe right noteplayed,friendshiprequires no maintenancedeepen. pointsa frequencynowrange of."],[7,"Drift",1,["passive transport","current-borne","slow displacement"],"Movementbelongsmedium body. Drift isice floe carried bygyre,spore onwind,conversationwanderedanyone steering. The cardyour current trajectoryyours or merelyroom's,whetherdistinction matters yet."],[8,"Orbit",1,["bound return","captured path","gravitational habit"],"Motionreturnsitself, captureda masscannot escapecannot strike. Orbit islong elliptical pattern:relationshipcircles back to,questionkeeps re-askinga different vantage. observes thatnot stuck —bound,is different."],[9,"Cascade",1,["chain reaction","downhill momentum","step-fall"],"Energy releasedstages,step pullingnext loose. A cascadea stairwayfailures or releases — a snow shelf, an argument, a market —no single force couldcaused alone. warnssmall thingaboutdislodgealready touching several others."],[10,"Spiral",1,["winding path","return-with-difference","involution"],"The pathreturns toorigina different altitude. A spiralneither a circle nor a line;isshapelearning,galaxies,stairs climbeda tower. The cardyoupassing over familiar ground, andfamiliarity islesson destination."],[11,"Tide",1,["periodic pull","lunar obligation","ebbreturn"],"The slow oceanic obediencea body too far awayargue with. Tide isrhythm imposedoutsidesystemlearnedlive by, retreatingreturninga scheduledidset. The cardidentifydistant mass currently shapingshoreline."],[12,"Wake",1,["trailing disturbance","aftermath","signaturemedium"],"The disturbance a moving body leaves behindmediumpassed through. A wakeevidencehaving been here, a brief autobiography writtenwatercloses again within minutes. The cardis still rippling behind youyoualready stopped thinking about."],[13,"Threshold",2,["doorway","phase edge","transition point"],"The doorwaythe previous state no longer appliesnew onenot yet committed. A thresholda thinunstable region, often warmer or colder than either side. The cardstand here long enoughfeelwayheatmoving."],[14,"Horizon",2,["visible limit","perceptual edge","curvatureseeing"],"The line beyondinformation cannot yet reach you. A horizona walla function ofyou happenbe standing; another step changes it. The cardthatlies beyondabsent — itsimply onother side ofpresent elevation."],[15,"Veil",2,["partial occlusion","translucence","intervening film"],"The thin layerobscuresentirely hiding, leaving shapemovement legible while denying detail. A veilhonest about being a veil;tells you somethingthere. The cardthe questionnothiddenwhycovering itselfbeen chosen so carefully."],[16,"Aperture",2,["controlled opening","iris","let-in"],"The adjustable openingdecides how muchoutsideallowed insidethis moment. An aperturea disciplineintake —pupil narrowinglight,door cracked just wide enoughthe messenger. The cardsize opening this situation actually requiresyou."],[17,"Asymptote",2,["approached limit","never quite","infinite nearing"],"The line a curve approaches forevertouching. An asymptote describes a kindnearness thatbecome arrival:apologyalmost lands,masterykeeps refining. The cardyouwillinglive closea thingrequiringit closefinal gap."],[18,"Penumbra",2,["partial shadow","soft edge","grey margin"],"The ringpartial shadow around a bodyfull shadow,the eclipse ishalf-believed. A penumbraa regionambivalence — lightdark both presentmeasurable amounts. draws attentionsoft margin you keep tryingresolvea hard line."],[19,"Eclipse",3,["occultation","alignment-shadow","interposed body"],"The brief geometry inone body slides exactlya watchera source. An eclipsethe losslightthe prooftheresomething massiveyouit. The cardhas comeyou andusual illumination, andits silhouetteteaching you."],[20,"Refraction",3,["bent path","medium change","apparent displacement"],"The bendinga ray ascrossesone mediumanother, sofish appearsbeitnot. Refraction islawful distortioncomespassing through. The cardthat whatseeingalready been bent once bysubstanceyousource."],[21,"Halo",3,["refracted ring","ice-crystal corona","encircling glow"],"The luminous ringforms around a sourcethe airis fullice. A haloa signmedium more thanlight —tells you abouthigh cloud,the moon. The cardthe glow aroundpresent thing belongsconditions thing itself."],[22,"Albedo",3,["reflectivity","given-back light","surface candor"],"The fractionlight a surface returns  absorbs. Albedoa body's degreebrightness as a function ofit refusestake—snowfield's dazzle,dark sea's drinking. asks how much ofarrivesyouactually entering,how muchsending back unread."],[23,"Heliotrope",3,["sun-turning","tracked source","obedient orientation"],"The slow turna living thing towardlight sourcecoursea day. Heliotrope issunflower's allegianceseedling's lean, a motion so patientrequires time-lapsebe seen. asksyoubeen quietly orientingmorningnoticing."],[24,"Echo",3,["delayed return","reflected report","second hearing"],"A signal returned toorigin after a delay long enoughthe original tofinished. An echo lets you hear yourselfoutside, slightly laterslightly weaker. observessomething you said or didonway backyoudelay built in."],[25,"Crystallization",4,["ordered solidification","lattice forming","decisionmatter"],"The moment a solution gives upambiguitychooses a geometry. Crystallization ishappensa saturated medium finally accepts a seed;orderproduces was already implicit,waitingan excuse. The carda long suspensionaboutcommit toshape."],[26,"Lattice",4,["regular structure","repeated unit","load-bearing pattern"],"The repeating arrangementmakes a solid solid. A lattice isagreementidentical unitshold position relative toanother sowhole can bear weight. The cardlook atunderlying patternhas been doingstructural work inlife unannounced."],[27,"Granule",4,["small grain","irreducible unit","discrete particle"],"The smallest piece intoa thing usefully divides. A granule issand grain,pixel,syllable —unit beneaththe whole stops behaving like itself. asks youfindlevelgrain atthe problem becomes describable,thenwork there."],[28,"Filament",4,["thread","connecting strand","thin continuity"],"The slender threadholds something larger together, often invisible untilbreaks. A filament can betungstenbulb,spider's bridge line,cosmic strand ongalaxiesstrung. draws attentionthin connection currently doing more work thandiameter suggests."],[29,"Catenary",4,["hanging curve","load distribution","graceful sag"],"The curve a chain assumeshungtwo pointsleft toown weight. A catenary iselegant shapea heavy thing supportedatends;mathitolder than anyone who first noticed. The cardwhatcarryingalready arranged itself optimally,resists rearranging."],[30,"Decay",4,["half-life","useful breakdown","scheduled return"],"Useful breakdown. Decay isslow legitimate process bya structure returnsborrowed atoms;radioactive matterin grief,follows a half-lifeis steeperfirstgentler later. observeshalf-lifea thing youbeen holdingshorter thanemotional weight."],[31,"Convergence",5,["paths meeting","summation","agreed point"],"The gatheringmultiple pathsa single point. Convergence happensindependent trajectories — particles, rivers, ideas, persons — finally agreea location. marks a moment inseveral otherwise unrelated lines oflifearrived atsame coordinates."],[32,"Singularity",5,["pointbreakdown","infinite density","unmodelable"],"The point atthe equations describingsystem stop returning a finite answer. A singularity isthe model surrenders;onother side cannot be approachedtoolsbrought you here. warnsa juncture insituationwill require a different language entirely."],[33,"Apogee",5,["farthest point","maximum distance","outermost arc"],"The pointan orbit atthe bodyfarthestwhatcircles. At apogee, motionslowed almosta hesitation;returnnot begunhas become inevitable. observesyoureachedlong edgean arc, andsilence herestagnationthe topa swing."],[34,"Perigee",5,["closest approach","nearest pass","speed atbottom"],"The pointan orbit atthe bodynearest toit circles,therefore moving fastest. Perigee isswift low pass,brief intense proximityorbit was designedallowrarely. marks a passageunusual closenesswill not, bygeometrysituation, hold."],[35,"Inflection",5,["curvature change","sign flip","subtle turning"],"The point ata curve changesdirectionbendingchangingdirectiontravel. Inflection isquiet pivot thatannounce itself —moment growth beginsdecelerate, or griefsoften, whilesurface trajectory continues. pointsa turning already underwaysecond derivative."],[36,"Cartography",5,["mapping","scaled representation","chosen projection"],"The artcompressing a territory onto a sheet small enoughconsultlamplight. Cartography requires choosingtruthspreserve andto distort; every mapan argument aboutmatters. The cardprojectioncurrently usingrepresentown life, andit costs youkeep using it."]]; +export default inflateDeck(keys,dictionaries,rows); diff --git a/src/chambers/oracle/decks/generated/i-ching.generated.js b/src/chambers/oracle/decks/generated/i-ching.generated.js new file mode 100644 index 0000000..ef81d39 --- /dev/null +++ b/src/chambers/oracle/decks/generated/i-ching.generated.js @@ -0,0 +1,6 @@ +// Generated by scripts/generate-built-in-content.mjs. Do not edit by hand. +import { inflateDeck } from './inflate.js'; +const keys=["id","name_en","name_zh","pinyin","lines","trigrams","keywords","meaning"]; +const dictionaries={"5":["qian","kun","zhen","kan","gen","xun","dui","li"]}; +const rows=[[1,"The Creative","乾","Qián","111111",[0,0],["heaven","creative force","leadership","origination"],"Pure yang. Heaven movesceaseless vigor,, taking heaven as model, makes themselves stronguntiring. The creative principlesublime success —onlyone's purposeupright."],[2,"The Receptive","坤","Kūn","000000",[1,1],["earth","receptive","devotion","yielding"],"Pure yin. The earth, inbreadth, supportscarriesthings. The  yieldsfollows,by responding  initiating,workheavencompleted below."],[3,"Difficulty atBeginning","屯","Zhūn","100010",[2,3],["sprouting","birth pangs","chaos","perseverance"],"A bladegrass pusheshard ground. Thunder rolls beneathabyss,newborn amid difficulty — supreme success comestowho perseveresseeks helpers. Doadvance rashly; appoint assistantsbring order outconfusion."],[4,"Youthful Folly","蒙","Méng","010001",[3,4],["inexperience","instruction","humility","learning"],"Water gushesbeneathmountain — eager, unguided, ignorant. Itthe teacher who seeksyouth,the youth who must come, sincere, asking once. The first oracle informs; persistent questioning cloudswell."],[5,"Waiting","需","Xū","111010",[0,3],["patience","nourishment","trust","the right moment"],"Clouds riseheaventhe rainnot yet fallen. The strong waitinner certainty, eatingdrinking, gathering strength —danger lies aheadforce willhastenhour. Towhosincere,appointed time comes."],[6,"Conflict","訟","Sòng","010111",[3,0],["dispute","litigation","caution","compromise"],"Heaven moves upward, water flows downward — their paths diverge,contention arises. Halt halfway; letmatter be settled bywhofair,dopressclaimbitter end. To carry conflict toconclusion brings misfortune."],[7,"The Army","師","Shī","010000",[3,1],["discipline","leadership","the masses","righteous force"],"Water lies hidden withinearth, as armieshidden withinpeople. Only a leaderproven worth may leadmultitude;a just cause may take upsword. Discipline must be severe,the war must be necessary."],[8,"Holding Together","比","Bǐ","000010",[1,3],["union","alliance","loyalty","belonging"],"Water spreads overearth, minglingjoining. The people who hesitate find themselves left behind; come now,sincerity, while therestill time. Seekcentral one,letunion be foundedtruth,in expedience."],[9,"The Taming PowerSmall","小畜","Xiǎo Chù","111011",[0,5],["restraint","gentleness","accumulation","small influence"],"Dense clouds gatherwestern suburb,the rainyet fall. A small force restrainsstronggentlenesspersistence,by opposition. Work upon character;timegreat deedsnot arrived."],[10,"Treading","履","Lǚ","110111",[6,0],["conduct","courtesy","tigers","right comportment"],"One treads upontailtiger; itbite. Where strengthmetcheerful courtesy, even peril yields. The  discriminateshighlow,so establisheswillpeople."],[11,"Peace","泰","Tài","111000",[0,1],["harmony","prosperity","communion","flourishing"],"Heaven descends, earth ascends — their forces meet andthings flourish. The small departs,great approaches;ruler alignsseasonsaidspeople. Yet within peace,seeddecline already stirs; cultivatewhiletimegood."],[12,"Standstill","否","Pǐ","000111",[1,0],["stagnation","obstruction","separation","withdrawal"],"Heaven above ascends, earth below descends — they part ways,creation halts. The  withdrawsvirtueavoids honorsbring danger. The wayis patienceinward order;timegreatness will return."],[13,"FellowshipMen","同人","Tóng Rén","101111",[7,0],["community","shared aim","openness","kinship"],"Fire reaches upheaven; flamelight joinbright sky. Fellowshipopen furthers —the gatheringfactions,the company ofwho labor under a common sun. The  distinguishes things accordingtheir kinds."],[14,"PossessionGreat Measure","大有","Dà Yǒu","111101",[0,7],["abundance","great possessions","clarity","stewardship"],"Fire blazes high above heaven, illuminating everything beneath. Great ispossession; greater must bemodestyholds it. The  curbs evilfurthers good,accordheaven's benevolent decree."],[15,"Modesty","謙","Qiān","001000",[4,1],["humility","balance","moderation","concealed worth"],"The mountainhidden withinearth — towering substance veiledlowliness. Heaven emptiesfullfillsmodest;spirits harmproudprosperhumble. The  reducesexcessiveaugmentsscant."],[16,"Enthusiasm","豫","Yù","000100",[1,2],["inspiration","music","movement","shared joy"],"Thunder bursts forthearthsudden vigor;things moveanswer. The ancient kings made musichonor meritoffered itsupreme deity. To rousepeople,must movethem,over them."],[17,"Following","隨","Suí","100110",[2,6],["adaptability","yieldingtime","discipleship","rest"],"Thunder lies withinlake;stormspent itselfstillness returns. To followrightweaknesswisdom; nightfall enters withinrests. One who would lead must first learn howserve."],[18,"WorkWhat Has Been Spoiled","蠱","Gǔ","011001",[5,4],["decay","repair","remedy","ancestral burden"],"Wind lies still beneathmountain;air grows stagnantdecay sets in. Whatfathers spoiled,sons must mend; three days beforeturning, three days after, attendmatter. Great undertakings prosperone who correctshas rotted."],[19,"Approach","臨","Lín","110000",[6,1],["nearing","advance","favorable hour","warning"],"The lake rises towardbankearth; springapproaching. Now ishouradvancegladness, yet withinfavorable time a warning glints:eighth month, misfortune. The  teachesprotectspeopleend."],[20,"Contemplation","觀","Guān","000011",[1,5],["observation","perspective","the watch tower","reverence"],"Wind moves overearth, touching allbeing grasped. The ablutionbeen made,the offeringyet brought;heartfilledreverence. The ancient kings beheld heaven's wayarranged their teachings accordingly."],[21,"Biting Through","噬嗑","Shì Kè","100101",[2,7],["decision","judgment","obstacle","law"],"Thunderlightning meet — claritymovement together. When an obstruction liesmouth,must be bitten through; letlaws be definitepenalties just. To half-actto remain divided; resolution comescutting cleanly."],[22,"Grace","賁","Bì","101001",[7,4],["adornment","form","beauty","ritual"],"Fire atfootmountain illumines brieflyformthings. Gracesuccesssmall matters;great affairs,substance avails. The  attendsornamentitfitting, butlet beauty decidecase."],[23,"Splitting Apart","剝","Bō","000001",[1,4],["disintegration","erosion","endurance","withdrawal"],"The mountain rests uponearthis steadily worn away. The dark forces areascendant; nothing furthers any undertaking. The , attendingfoundation below, securespositionthose abovequiet generosity."],[24,"Return","復","Fù","100000",[2,1],["renewal","turning point","winter solstice","small beginnings"],"Thunder stirs beneathearth; a single light returns atturningyear. The friends arrive; thereno fault. Aftersolstice,kings closedpasses — let movement be undertakendue time,before."],[25,"Innocence","無妄","Wú Wàng","100111",[2,0],["spontaneity","the unexpected","primal nature","alignment"],"Thunder rolls beneath heaven, andthings receivebreathinnocence. To actaccordone's true nature furthers;scheme apartit brings calamity, eventhe deed seems right. The ancient kings, richvirtue, nourishedbeingstheir season."],[26,"The Taming PowerGreat","大畜","Dà Chù","111001",[0,4],["accumulation","discipline","great restraint","stored strength"],"Heavenheld withinmountain — vast force gatheredstillness. The  learnssayingsdeedsantiquity,virtue may be stored up. Crossgreat water;has been disciplined within may now serve abroad."],[27,"The CornersMouth","頤","Yí","100001",[2,4],["nourishment","speech","providing","discernment"],"Thunder beneathmountain —stirs withinheld without. Observea person takes in,mouthby mind,you will know their character. The carefulspeech,temperateeatingdrinking."],[28,"PreponderanceGreat","大過","Dà Guò","011110",[5,6],["overburden","critical mass","exceptional measures","the ridgepole bends"],"The lake rises abovetrees;weighttoo greatthe beam. The ridgepole sags;must find a goalmove toward ithesitation. The  stands alone unafraid and, if need be, withdrawsworldsorrow."],[29,"The Abysmal","坎","Kǎn","010010",[3,3],["danger","the deep","perseverance","flowing through"],"Water flowsunceasinglyabyss andit — repeating, never piling up. Through danger,heartholds trueitself succeeds init undertakes. The  walks evervirtueinstructsweariness."],[30,"The Clinging","離","Lí","101101",[7,7],["fire","brightness","dependence","clarity"],"Brightness rises twice over — fire clingswood, sun clingsheaven. Thatis luminous depends uponsustains it; carethe cow,theregood fortune. The great person,doubled clarity, illuminesfour quarters."],[31,"Influence","咸","Xián","001110",[4,6],["wooing","attraction","courtship","mutual response"],"The lake rests uponmountain —younger drawnelder,elder stoopingyounger. Influence worksby forceby readinessreceive. The ,keepingheart openempty, allows othersenter."],[32,"Duration","恆","Héng","011100",[5,2],["constancy","endurance","marriage","steadfastness"],"Thunder above, wind below — they move together always, neversamenever apart. What endureswhat stands still, butrenews itselfunfailing motion. The  stands firm andchange direction."],[33,"Retreat","遯","Dùn","001111",[4,0],["withdrawal","strategic retreat","dignity","timing"],"The mountain reaches upheaven retreats higher still. Wheninferior advances,  retires —in defeat,to preserve strengththe timereturn. Dignitykeptgoing first,by being driven."],[34,"The PowerGreat","大壯","Dà Zhuàng","111100",[0,2],["great vigor","strength","restraintstrength","perseverance"],"Thunder rolls above heaven;strongin motion. Yet tread pathsviolate decorum, evenstrength permits. Power held within right formthepowerendures."],[35,"Progress","晉","Jìn","000101",[1,7],["advance","sunrise","recognition","ascending"],"The sun rises overearth;princegiven fine horseslarge numbersis received three timesa single day. The ,their own accord, brightens their bright virtue. Easy advancegrantedthose whose worth precedes them."],[36,"DarkeningLight","明夷","Míng Yí","101000",[7,1],["concealment","wounded brilliance","enduranceadversity","veiled wisdom"],"The lightsunkearth — a sage among fools, brightness hiddendim ground. In timedarkness,veils one's lightyet remains steadfast within. Towardmasses,  appears simple, while inwardly retaining clear understanding."],[37,"The Family","家人","Jiā Rén","101011",[7,5],["household","order","right relationship","perseverancewoman"],"Wind comes forthfire;warmthhearth spreads outward towho gather near. Whenmemberfamily holdstheir proper place,household stands;this,orderkingdom follows. The substancewordsdurationconduct."],[38,"Opposition","睽","Kuí","110101",[6,7],["estrangement","duality","small matters","reconciliation"],"Fire moves upward,lake downward — sisters who dwell inhouse yetseparate hearts. In small affairs, fortune;great,yet. The , while seeking concordgeneral, retains individualityparticulars."],[39,"Obstruction","蹇","Jiǎn","001010",[4,3],["impediment","hindrance","introspection","seeking aid"],"Water uponmountain —waysteeppath uncertain. The southwest furthers,northeast does not; itwellseegreat person. The  turns inwardfindcauseobstaclemolds characterovercome it."],[40,"Deliverance","解","Xiè","010100",[3,2],["release","thunderrain","forgiveness","movement outdanger"],"Thunder bursts forthrain falls — danger gives way,tension breaks. To returnordinary brings good fortune; if anything remainsbe done, hastening accomplishes it. The  pardons mistakesforgives misdeeds."],[41,"Decrease","損","Sǔn","110001",[6,4],["diminution","sacrifice","restraint","the lower giveshigher"],"Atfootmountain lieslake;lake gives upwatersnourishheights. Where sinceritypresent, two small bowlsgrain sufficethe offering. The  restrains wrathcurbsinstincts."],[42,"Increase","益","Yì","100011",[2,5],["augmentation","generosity","the higher giveslower","great undertakings"],"Windthunder together — they strengthenothertheir movement. When those above givethose below,people's joy isbound;furthersto undertake somethingto crossgreat water. If you see good, imitate it; if youfaults, lay them aside."],[43,"Breakthrough","夬","Guài","111110",[0,6],["resolution","decisive action","exposure","removalevil"],"The lakerisen upheaven;breachimminent. One must announcematter truthfully atcourtking,warn before resortingarms. The  bestows wealththose below, yet refusesdwelltheir own merits."],[44,"ComingMeet","姤","Gòu","011111",[5,0],["encounter","temptation","the unbidden","vigilance"],"Heaven moves above; a wind blows beneath, touching everythingpassing. A maiden comes forth boldlymeetman —shouldmarry such a maiden. The prince disseminates his commandsproclaims themfour quarters."],[45,"Gathering Together","萃","Cuì","000110",[1,6],["assembly","congregation","shared aim","sacrifice"],"The lake gathers aboveearth — waters drawn togethertheir own weight. The king approaches his temple; itwellseegreat person,great offering brings fortune. The  renews weapons,unforeseen maysurprise."],[46,"Pushing Upward","升","Shēng","011000",[5,1],["ascent","gradual growth","sapling","effortful rising"],"Withinearth,tree pushes upward — slow, unhurried, undeniable. One must seegreat person; dofear,a journeysouth brings good fortune. The  heaps up small thingsachievehighgreat."],[47,"Oppression","困","Kùn","010110",[3,6],["exhaustion","adversity","the dry lake","trustspeech"],"Waterdrained outlake —bed lies crackedparched. In timesexhaustion, wordsnot believed;  stakes lifefollowingwill,accepts adversitysilence. Success comestowhose inner light remains unshaken."],[48,"The Well","井","Jǐng","011010",[5,3],["the source","nourishmentall","whatchange","the inexhaustible"],"Wood draws water upbelow;town may be moved,the wellmoved. Those who comethose who go drinksame source; misfortune arises ifropetoo short orjug breaks. The  encouragespeopletheir workexhorts themmutual aid."],[49,"Revolution","革","Gé","101110",[7,6],["overthrow","molting","necessary change","the right moment"],"Fire withinlake — two elementsdestroyanother,revolutionborn. Onappointed day,is believed; sublime success comesto revolutions undertakenaccordtime andtruth. The  regulatescalendarmakesseasons clear."],[50,"The Caldron","鼎","Dǐng","011101",[5,7],["the sacred vessel","cultivation","nourishingworthy","consecration"],"Fire kindled beneath wood —holy vessel set uponhearth. Whatwithintransformed, andnourishesmanypreparedreverence. The ,stabilizing fate, holds firmdecreeheaven."],[51,"The Arousing","震","Zhèn","100100",[2,2],["thunder","shock","awakening","fearpurifies"],"Thunder rollsrolls again —sage trembles outwardlylaughstalks within. The shock comesa hundred li; onelet fallsacrificial spoonchalice. The ,feartrembling, sets lifeorderexaminesheart."],[52,"Keeping Still","艮","Gèn","001001",[4,4],["stillness","the mountain","meditation","boundaries"],"Mountain upon mountain — stillness within stillness. Keepingback still sobodyno longer felt; walkingcourtyard yetseeingpeople. The let thoughts go beyondplacethey are."],[53,"Development","漸","Jiàn","001011",[4,5],["gradual progress","marriage","the tree onmountain","lawful order"],"Onmountain, a tree grows slowly —inches,seasons,years. The maidengivenmarriage; good fortune liesperseverance. The  abidesdignified virtue,so improvescustomspeople."],[54,"The Marrying Maiden","歸妹","Guī Mèi","110100",[6,2],["secondary position","passion","irregular union","knowing one's place"],"Thunder overlake —younger sister entershouse as concubine,as wife. Undertakings bring misfortune; nothing furthers, exceptlawful arrangement. The  understandstransitorylighteternityend."],[55,"Abundance","豐","Fēng","101100",[7,2],["fullness","high noon","transient zenith","judgment"],"Thunderlightningtheir height;sun standsmidday. Greatnesscome; dobe sad,be likesunnoon — knowing thatfull must wane. The  decides lawsuitscarries out punishments."],[56,"The Wanderer","旅","Lǚ","001101",[4,7],["sojourn","the stranger","transience","modest path"],"Fire uponmountain — burning briefly, moving on, never settled. Successsmall matters; perseverance brings good fortunewanderer. The ,claritycaution, imposes penalties anddelay lawsuits."],[57,"The Gentle","巽","Xùn","011011",[5,5],["wind","penetration","gradual influence","humility"],"Wind follows upon wind — gentle, ceaseless, going everywherewaysmall openings. It furtherstosomewherego;furthersto seegreat person. The  spreads his commandscarries out his undertakings."],[58,"The Joyous","兌","Duì","110110",[6,6],["lake","joy","encouragement","friendsconversation"],"Lake resting upon lake — joy redoubledjoy shared. The  joinsfriendsdiscussionpractice;comeswithingenuineso persuades others. True joyrootedstrength within,in outer pleasure."],[59,"Dispersion","渙","Huàn","010011",[3,5],["dissolution","scatteringrigidity","reunion","the ice breaks"],"Wind moves overwaters;icebound them breaks apart. The king approaches his temple;furthersto crossgreat water. The ancient kings dispersedhardened heartspeopleoffering sacrificeerecting temples."],[60,"Limitation","節","Jié","110010",[6,3],["restriction","the jointbamboo","thrift","due measure"],"Water abovelake — limitedvolume,lake cannot hold more thancan hold. Galling limitation mustbe persevered in; letmeasure be moderate,keepingtime. The  creates numbermeasureexaminesnaturevirtuecorrect conduct."],[61,"Inner Truth","中孚","Zhōng Fú","110011",[6,5],["sincerity","trust","the craneshade","deep accord"],"Wind overlake —surface stirs,the truth runs deep beneath. Pigsfishes — fortune;inmost sincerity reaches even those most difficultinfluence. The  discusses criminal casesorderdelay executions."],[62,"PreponderanceSmall","小過","Xiǎo Guò","001100",[4,2],["small excess","modest deeds","the flying bird","humilityconduct"],"Thunder uponmountain — sound thattravel far. Itwelldo small things; great things shouldbe done. The bird mustfly upwarddownward,great good fortune attends those who descendhumility."],[63,"After Completion","既濟","Jì Jì","101010",[7,3],["accomplishment","the workdone","vigilance","decline begins"],"Water above fire —kettle boils,workfinished. Successsmall matters atbeginning, disorder atend;  takes thoughtmisfortunearms against itadvance. Whatcompleteonvergeunraveling."],[64,"Before Completion","未濟","Wèi Jì","010101",[3,7],["the not-yet-finished","transition","the little fox","careful crossing"],"Fire above water —elementsyettheir proper places. The little fox, almost acrossstream, wetstail; nothing furthers. The ,careful discrimination, keepsthing inplaceso makescrossing possible."]]; +export default inflateDeck(keys,dictionaries,rows); diff --git a/src/chambers/oracle/decks/generated/inflate.js b/src/chambers/oracle/decks/generated/inflate.js new file mode 100644 index 0000000..1ced39c --- /dev/null +++ b/src/chambers/oracle/decks/generated/inflate.js @@ -0,0 +1,33 @@ +import { phrases } from './phrases.generated.js'; + +const TOKEN_START = 0xE000; +const TOKEN_PATTERN = /[\uE000-\uE0FF]/gu; + +/** Restore the public record shape of trusted, build-generated deck data. */ +export function inflateDeck(keys, dictionaries, rows) { + return rows.map((row) => { + const record = {}; + for (let index = 0; index < keys.length; index += 1) { + const dictionary = dictionaries[index]; + const encoded = row[index]; + const value = dictionary + ? Array.isArray(encoded) + ? encoded.map((entry) => dictionary[entry]) + : dictionary[encoded] + : encoded; + record[keys[index]] = expandPhrases(value); + } + return record; + }); +} + +function expandPhrases(value) { + if (typeof value === 'string') { + return value.replace(TOKEN_PATTERN, (token) => phrases[token.codePointAt(0) - TOKEN_START]); + } + if (Array.isArray(value)) return value.map(expandPhrases); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expandPhrases(entry)])); + } + return value; +} diff --git a/src/chambers/oracle/decks/generated/phrases.generated.js b/src/chambers/oracle/decks/generated/phrases.generated.js new file mode 100644 index 0000000..c6ba6c6 --- /dev/null +++ b/src/chambers/oracle/decks/generated/phrases.generated.js @@ -0,0 +1,2 @@ +// Generated by scripts/generate-built-in-content.mjs. Do not edit by hand. +export const phrases=["the superior person"," invites you to "," suggests that ","superior person"," asks whether "," reminds you "," through the "," asks what ","rather than"," does not "," from the "," into the "," that the "," with the "," and the "," between "," through "," what is "," without "," you are ","The card "," in the "," is not "," of the "," to the "," where "," which "," each "," from "," have "," into "," only "," that "," what "," when "," with "," your "," all "," and "," are "," but "," for "," has "," its "," not "," one "," the "," at "," by "," in "," is "," it "," of "," on "," to "]; diff --git a/src/chambers/oracle/decks/generated/runes.generated.js b/src/chambers/oracle/decks/generated/runes.generated.js new file mode 100644 index 0000000..6e803d7 --- /dev/null +++ b/src/chambers/oracle/decks/generated/runes.generated.js @@ -0,0 +1,6 @@ +// Generated by scripts/generate-built-in-content.mjs. Do not edit by hand. +import { inflateDeck } from './inflate.js'; +const keys=["id","name","glyph","aett","phoneme","keywords","meaning"]; +const dictionaries={"3":["freya","heimdall","tyr"]}; +const rows=[["fehu","Fehu","ᚠ",0,"F",["cattle","wealth","abundance","movable property"],"Fehu nameswealthwalksfour legs — cattle, herds,goodscan drive home before nightfall. Itearned increase  inherited land,runelivelihooddutycirculateonegathered. Hoarded,sours; given,multiplies."],["uruz","Uruz","ᚢ",0,"U",["aurochs","raw strength","vitality","untamed"],"Uruz iswild oxnorthern forests, a beasthunter faces aloneprove his manhood. Itunrefined power — healthbody, saptree, mineral force pushed upbeneathsoil. The runeyou can ride a strength thatyet knowserves you."],["thurisaz","Thurisaz","ᚦ",0,"Th",["thorn","giant","defense","reactive force"],"Thurisaz isthorn onhedgegiant onfar side of— a forceprotectswoundingwould presscarelessly. ItThor's hammer striking outward,necessary violencekeeps a hall standing. Approach itrespect or doapproachall."],["ansuz","Ansuz","ᚨ",0,"A",["god","Odin","breath","speech","ancestral counsel"],"Ansuz isbreathAesir madespeech —mouthnames a thingso givesweight. It carriescounselancestors,inspired wordskald,binding forceoath. To draw itto be toldanswer will arrivelanguage, spoken aloud."],["raidho","Raidho","ᚱ",0,"R",["riding","journey","wagon","right order"],"Raidho iswheel underwagonhoofbeatcarries a chieftaincouncil. Itjourney undertakenpurpose —wandering,the deliberate movementgoods, kin, or judgementone place toitneeded. The rune teachesa well-set rhythm carries farther than haste."],["kenaz","Kenaz","ᚲ",0,"K",["torch","hearth-fire","craft","controlled flame"],"Kenazfire brought indoors —pine-knot torchlonghouse,smith's forge,lamp bya craftsman finishes his work after dark. Itknowledgekindlivesskilled hands, passed downdemonstration. Whereshines,night yields a workable space."],["gebo","Gebo","ᚷ",0,"G",["gift","exchange","bond","guest-right"],"Gebo isgift placedhandobligationarrivesit. Inold reckoning, no giftfree —weavesgiverreceivera single clothdebtreturn. The rune marks every union sealedexchange: marriage, alliance, hospitality,cup passed atlong table."],["wunjo","Wunjo","ᚹ",0,"W",["joy","kinship","harmony","clan-belonging"],"Wunjo isjoybeing among one's own —kindred gathered,harvest in,fields beyonddoorpeace. Itprivate happinesscommunal well-being,contentmenta hallholds together. The rune promiseswhatbeen scattered may yet come backone room."],["hagalaz","Hagalaz","ᚺ",1,"H",["hail","destructive weather","crisis","seedgrain"],"Hagalaz ishailstormflattensstanding crop — a violenceskynobargained for. Yet hailwaterdisguise, andit meltsfeedssoilbruised. The rune namesdisasters that,time, prove tobeen ingredients."],["nauthiz","Nauthiz","ᚾ",1,"N",["need","constraint","necessity","friction-fire"],"Nauthiz ishard pinchnecessity —long winter,empty larder,ropechafes because thereno slack. Italsofriction stick that, twirled long enough against need-wood, produces fireno other flame can be had. Constraint sharpensabundance dulls."],["isa","Isa","ᛁ",1,"I",["ice","stillness","halt","preservation"],"Isa isriver heldplacewinter — motion arrestedbeing undone. Beneathicecurrent still runs,nothing onsurface can be moved. The rune counsels patience withhas frozen,warns against trusting a footinghasyet been testedweight."],["jera","Jera","ᛃ",1,"J/Y",["year","harvest","cycle","right season"],"Jera isturningyearbringsgrain inlast. It isrewardpatient labor distributed acrosstwelve months — sowing, weeding, watchingsky, sharpeningsickle. The rune insistssome yields cannot be hurried; they ripenwhenseason agrees."],["eihwaz","Eihwaz","ᛇ",1,"Ei",["yew","world-tree","endurance","passage between"],"Eihwaz isyew tree, evergreenbow-wood, rootedunderworldcrownedsky. Fromtrunk warriors cut their bowstheir grave-staves alike. The rune marksstandslivingdying,spineendurancerunsboth."],["perthro","Perthro","ᛈ",1,"P",["lot-cup","chance","hidden fate","wyrd"],"Perthro isdice-cup,leather bag fromthe lotsshaken. Whatholdswyrd itself —woven outcome no man sees untilfalls. The rune addressesseerher own work,reminds herevencasting handbeing cast."],["algiz","Algiz","ᛉ",1,"Z",["elk","sanctuary","warding","raised hand"],"Algiz iselkantlers spread againstwolf,hand raisedwarding atthreshold. It isprotective enclosure —sacred grove,marked boundary no enemy may crossconsequence. The rune offers cover, buttowho keepsprecinct clean."],["sowilo","Sowilo","ᛋ",1,"S",["sun","victory","guidance","wheellight"],"Sowilo issun-wheel rolling acrossnorthern sky, briefwintergeneroussummer. Tosailorisbearingbrings him home;farmeriswarmthripensrye. The rune namessteady, undefeated lightreturns no matter how longdark."],["tiwaz","Tiwaz","ᛏ",2,"T",["Tyr","justice","oath","self-sacrificelaw"],"Tiwaz isspearTyr,god who put his handwolf's mouthkeep an oath. Itlaw placed above self,willingnesslose a partpreservewhole. The rune marksjudge,warriorjust cause,any commitment keptpersonal cost."],["berkano","Berkano","ᛒ",2,"B",["birch","mother","fertility","quiet growth"],"Berkano isbirch —first treereturnscorched ground, slenderpatient. It isrunemothermidwife,householdsslow swellingprecedes any birth. Whereappears, somethinggestating thatyet wishbe examined."],["ehwaz","Ehwaz","ᛖ",2,"E",["horse","partnership","trust","paired motion"],"Ehwaz ishorserider become a single creature,trustingother's weight. It isrunetrue partnership — marriage, comradeship,bonda mananimalcarries him. Neither party leads alone;journey belongspair."],["mannaz","Mannaz","ᛗ",2,"M",["man","humanity","kindred","the mortal lot"],"Mannaz ishuman being among other human beings — neither beast nor god,the mortal madecompany. It nameswe sharebeing bornsame stock: speech, mortality,needone another's witness. The rune turnsquestion outward, towardcommunity ina selfshaped."],["laguz","Laguz","ᛚ",2,"L",["water","lake","tide","the unconscious deep"],"Laguz islakedusksea-road traveledoar. Itwater as elementas omen — thatcarries shipsdrowns them,gives fishhides them. The rune speakscurrents belowsurfacea matter,counsels readingwater before stepping in."],["ingwaz","Ingwaz","ᛜ",2,"Ng",["Ing","seed","stored potential","fertile rest"],"Ingwaz isseed laidfurrowgod Ing who departed eastward overwaves. Itpotential gathereda small, dense kernelset asidewait. The rune marksworkis finishednowlong, dark period before sprouting innothing should be disturbed."],["dagaz","Dagaz","ᛞ",2,"D",["day","dawn","breakthrough","clarifying light"],"Dagaz ismomenteastern sky palesnight ends —thresholdtwo states made visible. It isbreakthrough afterlong vigil,sudden clarityreorganizes everything seenyesterday's light. The rune promises thathiddendark willstay hiddenday."],["othala","Othala","ᛟ",2,"O",["ancestral land","inheritance","homestead","kindred soil"],"Othala isancestral estate —patchground helda family long enoughbones beneath itkin. Itinheritanceas coinas place, custom, name. The runeyoubeen giventhose who came before, andpart ofyou intendleave intact."]]; +export default inflateDeck(keys,dictionaries,rows); diff --git a/src/chambers/oracle/decks/generated/tarot.generated.js b/src/chambers/oracle/decks/generated/tarot.generated.js new file mode 100644 index 0000000..117d558 --- /dev/null +++ b/src/chambers/oracle/decks/generated/tarot.generated.js @@ -0,0 +1,6 @@ +// Generated by scripts/generate-built-in-content.mjs. Do not edit by hand. +import { inflateDeck } from './inflate.js'; +const keys=["id","name","arcana","number","keywords","meaning"]; +const dictionaries={"2":["major","wands","cups","swords","pentacles"]}; +const rows=[["major-0","The Fool",0,0,["beginnings","innocence","leap","openness","unmarked path"],"A stepunfolded. The cliff's edgeappearseeker's vision;the horizon does. Risk takeninventory, trust extended before evidence,first breatha lifeyet shapedconsequence."],["major-1","The Magician",0,1,["will","manifestation","conduit","skill","concentration"],"The handgathersfour elementsaims them. Power exists notimplements ontable butoperator's capacitypoint intention atworldflinching. Whatabovedrawn downa single coherent self."],["major-2","The High Priestess",0,2,["intuition","mystery","silence","interior knowledge","veil"],"The keeperseamthe seenunseen. Shespeakanswer; she waits untilseekerquieted enoughhearforming. Knowledgemust be received,seized."],["major-3","The Empress",0,3,["fertility","abundance","sensuality","generative force","nurture"],"The world rendered fecund. She isrich soil,ripening grain,bodybecomes a vessellife. Creation as ongoing labor — generous, embodied, sometimes unruly, always producing more than was asked for."],["major-4","The Emperor",0,4,["structure","authority","order","stewardship","limits"],"The architectureholds a life upright. He iswall,law,agreementwhat was promised will be kept. Stability earnedwillingnesssetdefend an edge."],["major-5","The Hierophant",0,5,["tradition","transmission","doctrine","lineage","instruction"],"Thewho carries a teaching forward intact. He stands atjunctionthe personal must consultinherited —rites,texts,elders whowalkedcorridor before. Wisdom as a thing handed across hands."],["major-6","The Lovers",0,6,["union","choice","values","alignment","covenant"],"Two distinct beings stand insidesame fieldmeaning. is less about romance than about election: choosingonebound toacceptingshapebinding will giverestlife. The blessing above isintegritychoice itself."],["major-7","The Chariot",0,7,["willpower","momentum","direction","discipline","victory"],"Two opposing animals yoked,a driver competent enoughhold thema single line. Forward motion achievedrefusinglet anyimpulse seizereins. Triumphcoordinated tension eliminationit."],["major-8","Strength",0,8,["fortitude","patience","gentleness","inner mastery","courage"],"The lionslain; itcalmed. Real strength issteady hand laid onopen mouthwild thing,willingnessstay closeone's own ferocitybeing eatenit. Courageis quiet because itneed a witness."],["major-9","The Hermit",0,9,["solitude","search","inner light","withdrawal","guidance"],"The lampdistancecity. A choicestep outchorusfind whatsilence can say. The light he carriessmallhis own,itenoughnavigate by."],["major-10","WheelFortune",0,10,["cycles","turning","fate","fortune","revolution"],"The mechanism turns whetherseeker assents or not. What was buried surfaces;stood proudbrought low. The instructionto stopwheelto learnonyou currently stand."],["major-11","Justice",0,11,["truth","balance","consequence","law","accountability"],"The scales donegotiate. What was putworldweighed againstcame back,discrepancynamed. Fairness as a coldexact instrument — neither cruel nor merciful, simply correct."],["major-12","The Hanged Man",0,12,["suspension","reversal","surrender","perspective","pause"],"The world seen upside down bywhostopped struggling againstrope. Suspension thatpunishmentoffering — a willing pause ina different geometrysituation finally becomes visible. Movement tradedsight."],["major-13","Death",0,13,["ending","transformation","release","passage","necessary loss"],"The clean cut. A formdissolved somatter insidecan be reorganizedsomethingold shape could notheld. Not annihilation — translation,brief darknesslanguages."],["major-14","Temperance",0,14,["measure","synthesis","patience","alchemy","integration"],"Two vessels pouring intootherspilling. The artholding contradictionscorrect proportion until they become a third thing, more useful than either. Slow work; precise hand."],["major-15","The Devil",0,15,["bondage","compulsion","shadow","materialism","attachment"],"The chains aroundnecksfiguresloose enoughlift,yet theynot lifted. namesseductive comfortone's own captivity —appetite,contract,habithas begunwearfacea self. Liberation beginsthe loosenesschainfinally noticed."],["major-16","The Tower",0,16,["collapse","revelation","rupture","awakening","shock"],"The structurewas builta misalignmentstruck byvery skytriedignore. Sudden, unsubtle, often catastrophic —the rubble clears a foundationcan finally be laid honestly. Truth arrives as lightning."],["major-17","The Star",0,17,["hope","renewal","guidance","serenity","replenishment"],"AfterTower,night sky. The figure kneelswaterpoursmeasuring, restored bycannot be earned,received. The light overheaddistantreliable — orientation returns."],["major-18","The Moon",0,18,["illusion","subconscious","dream","uncertainty","tide"],"The pathreal,it runsterritory whose features willhold still. Things heardthis hournotto be believed,yet they carry information. Trustbody's compass overeye's report."],["major-19","The Sun",0,19,["clarity","vitality","joy","exposure","warmth"],"Full daylighteverything. Nothing hidden, nothing needingbe. The simple, almost embarrassing gladnessbeing alivea body under a generous sky — a gladness no irony can quite dismantle."],["major-20","Judgement",0,20,["reckoning","calling","awakening","absolution","ascent"],"The horn soundsdeadtheir boxes sit up. A summoning —a truer name,a selfhas been waiting underlived one. The pasterased; itfinally addressed."],["major-21","The World",0,21,["completion","wholeness","integration","return","consummation"],"The dancer atcenterwreath, encircled byfour living creaturescorners. A cycle closes cleanly;was scatteredbeen gathered. Arrival, briefly —then, beneath it,soft pressurenext beginning."],["wands-1","AceWands",1,1,["spark","inception","creative fire","impulse","potential"],"A hand offered outcloud, holding a branchhasyet decided whetherburn orbloom. isfirst surgewill toward making — undirected, fierce, requiring a vessel. Takebeforemoment closes."],["wands-2","TwoWands",1,2,["planning","horizon","dominion","decision","scope"],"The figure stands onparapet holding a globe, looking out overterritory ofmight be done. The fireAcecooleddeliberation. A choicethe safe interiorlarger, more difficult country."],["wands-3","ThreeWands",1,3,["expansion","foresight","voyage","expectation","launched venture"],"The shipsleftharborfigure watches their sails recede. Effortbeen committed;returnsno longer fullyone's hands. A stanceconfident patience whileworld processesoffer."],["wands-4","FourWands",1,4,["celebration","homecoming","milestone","shelter","communal joy"],"A canopygarlanded stavesfigures lifting their hands beneath it. A discrete stretchlaborreached a threshold worth marking. is permissionstopletwork be received."],["wands-5","FiveWands",1,5,["friction","rivalry","scuffle","rehearsal","competing voices"],"Five figures swinging stavesdoquite connect — half quarrel, half practice. Energies bumpingclose quartersthe disciplinea shared aim. The disordergenerative ifcan be organized; corrosive ifcannot."],["wands-6","SixWands",1,6,["victory","recognition","procession","vindication","public esteem"],"The rider returns crownedlaurel,stavescompany raisedsalute. A specific contestbeen wonwinbeen witnessed. Enjoycleanly, then dismount —laurela moment,a residence."],["wands-7","SevenWands",1,7,["defense","high ground","conviction","perseverance","embattled stance"],"One figure onrise, six staves rising up against himbelow. The positiondefensible because itelevated, butas long aslegs hold. Stand foryours —check, occasionally,itstill worth standing for."],["wands-8","EightWands",1,8,["swiftness","transmission","messages","acceleration","arrowsflight"],"Eight staves flying acrossopen sky, nearendtheir arc. After a long delay, several things moveonce. The windowbrief;correct action isonemeetsspeedair."],["wands-9","NineWands",1,9,["resilience","vigilance","last stand","wariness","battered persistence"],"The figure leansa staffeight more arrayed behind him, bandagedwatching. Hebeen struck beforeexpectsbe struck again,still heupright. The final stretch isoneasksmost ofremains."],["wands-10","TenWands",1,10,["burden","overextension","load","duty","weariness"],"A man bent under an armfulstaves he can barely see around, walking toward a houseis still distant. The firebeganAceaccreted obligations. Set someload down or carry itrestway —dopretend itlight."],["wands-page","PageWands",1,11,["curiosity","novelty","messenger","fledgling vision","raw enthusiasm"],"A young figure regards a staffhas begunput forth leaves. The first stirringsa personal calling, still more wonder than craft. Encourage it; doyet askto be useful."],["wands-knight","KnightWands",1,12,["adventure","boldness","impatience","drive","headlong motion"],"Armorfire, a horse mid-rear, distance burningbe crossed. The Knight iswillcommit before deliberationcomplete — magnificentmotion, costlywrong. Best harnessedan aimgenuinely deserves his speed."],["wands-queen","QueenWands",1,13,["confidence","charisma","warmth","self-possession","magnetism"],"She sitssunflowerblack cat, holding her staffceremony. Her authoritygranted; itradiated. issteady inner heatdraws others toward a purposedemanding they come."],["wands-king","KingWands",1,14,["vision","leadership","enterprise","command","directed fire"],"He holdsflowering staff like a question healready answered. The elementfire, mastered: vision sustained over years, will refinedstrategy. Leadershipbuilds something larger thanleader."],["cups-1","AceCups",2,1,["love","opening","grace","feeling","overflow"],"A cup held outcloud, brimming,dove descending toward it. The first liftingheart's lida long time. What poursthis vesselearned; itreceivedthen, ifis wise, passed along."],["cups-2","TwoCups",2,2,["partnership","attraction","reciprocity","covenant","meeting"],"Two figures lift their cups towardother underwinged lion's head. An exchange inneitherdiminished —offeredalso received. of pact,merely encounter."],["cups-3","ThreeCups",2,3,["community","celebration","friendship","shared joy","circle"],"Three figures raise their cupsa small ring under an open sky. Joyrequires more thanbodyhold. arguessome happinessessimplysolo events,asks who iscircleyou."],["cups-4","FourCups",2,4,["apathy","discontent","introspection","overlooked gift","weariness"],"A figure under a tree, arms folded, refusingcup offeredcloud. Three cups alreadyfronthimnonethem satisfying. ismomentmelancholyclosesdoor onnext thing — sometimes restorative, sometimes a habitwould be wisebreak."],["cups-5","FiveCups",2,5,["grief","loss","regret","remaining choice","spilled feeling"],"Three cups spilledforeground; two upright behind, unattended. The figure mournsgonehasyet turned his head. Griefcorrect here,so iseventual quiet question ofstill standing."],["cups-6","SixCups",2,6,["memory","nostalgia","innocence","kindness","returning"],"Two children among flower-filled cupsa sunlit courtyard. The past handed forward, simply, as a small gift. Tender —watchnostalgiabecome a placemoves visits."],["cups-7","SevenCups",2,7,["choices","fantasy","illusion","options","scattered desire"],"Seven cups float againstcloud,holding a different vision — wealth, victory, a veiled figure, a serpent. The dangerthe imagining;isfailurechoose. The workto decide whichoffered futuresis actually willingcarry."],["cups-8","EightCups",2,8,["departure","renunciation","deeper search","leaving","moonlit walk"],"A figure walks awayeight carefully arranged cups, under a moonwatchescomment. What was builtrealis no longer enough. isquiet decisionseekthe visible inventory cannot supply."],["cups-9","NineCups",2,9,["satisfaction","contentment","wish granted","abundance","fullness"],"A figure seated before nine cups arrangeda high shelf, hands folded, a small smile. The wishbeen answered;appetitemet. asks, gently, whethernext wishbeing formed already, or whethercan simply sithaving."],["cups-10","TenCups",2,10,["harmony","family","belonging","blessing","shared life"],"The rainbowcups arcs over a small household; figures lift their arms beneath it. Domestic happinessa smaller happiness than any other —itreal, itonelarger ones. islong, lit afternoona lifefits."],["cups-page","PageCups",2,11,["sensitivity","imagination","curiosity","tender message","young heart"],"A young figure bysea holds a cup froma fishimprobably emerged. The unguarded reception ofthe inner world offers — strange gifts, sudden tendernesses, news thatarriveordinary channels."],["cups-knight","KnightCups",2,12,["romance","invitation","questheart","idealism","offering"],"A knighta quiet horse, holdingcup forward like a question. Movement carried out atpacefeeling,urgency. isoffer extended — sometimesanother, sometimesa callinghas begunsound."],["cups-queen","QueenCups",2,13,["empathy","depth","intuition","compassion","receptivity"],"She gazesan ornate covered cup thatsheopened. The elementwater, mastered: feelinghas become wisdom, sensitivity thatcollapse underit picks up. isinner self large enoughhold another's weatherlosingown."],["cups-king","KingCups",2,14,["composure","emotional mastery","diplomacy","steady heart","depthcommand"],"He sitsa stone thronea churning sea, cup inhand, untroubled. The capacityfeel fullybeing movedevery wave. Authoritycomes notsuppressing emotion butbeingspeaking termsit."],["swords-1","AceSwords",3,1,["clarity","breakthrough","truth","incision","mental dawn"],"A handcloud lifts a sword wreathedlaurelpalm. A clean idea, a single right word,cutseparatesfrommerely pretendedbe. Use itthe work; dowavearound."],["swords-2","TwoSwords",3,2,["stalemate","considered pause","blindfold","equilibrium","withheld judgement"],"The blindfoldconcealment — itconcentration. Two equal pressures, held still whileright answer surfacesa placeeyes cannot reach. honorsdiscipline ofyet deciding."],["swords-3","ThreeSwords",3,3,["heartbreak","sorrow","honestypain","rupture","necessary grief"],"Three bladesa heart against a grey sky. Thereno metaphor strong enoughsoften it;card sitsseekera real hurt. The instruction isthis: dodenywound while itstill bleeding."],["swords-4","FourSwords",3,4,["rest","convalescence","retreat","stillness","recuperation"],"A figure laid outa tomban attitudeprayer, three swords onwallone beneath him. Not death — pause. prescribes withdrawalfield long enoughthe bodymindknit."],["swords-5","FiveSwords",3,5,["hollow victory","conflict","discord","cost","winning poorly"],"A figure gathers swordsa small smile while two others walk awaytheir heads down. The argument was won; something else was lost. The cardthe prize was actually worthwaywas taken."],["swords-6","SixSwords",3,6,["transition","passage","ferry","moving on","calmer waters"],"A ferryman moves figures across a stretchgrey water, six swords plantedboat. Leavingrenouncing;difficulty carried along,to a placeit can be addressed. Quiet, deliberate motion awayharm."],["swords-7","SevenSwords",3,7,["strategy","stealth","deception","selective truth","indirect approach"],"A figure tiptoes awaya campfive swords, leaving two behind. issimply theft;isuseindirection — sometimes shrewd, sometimes shabby. Examine whose interestscleverness actually serves."],["swords-8","EightSwords",3,8,["restriction","self-imposed limit","feeling trapped","narrow vision","binding"],"A boundblindfolded figure standsmud, hemmedeight planted swords — though theregaps wide enoughwalk through. The cagepartly a storymindtelling. Findgap; testcloth oneyes."],["swords-9","NineSwords",3,9,["anxiety","anguish","nightmare","rumination","the dark room"],"A figure sits upbedhandsface, nine swords onblack wall above. The hour atfears compoundmind rehearses every possible loss. isdenialdread —isreminderdawnstill a real event."],["swords-10","TenSwords",3,10,["finality","bottom","ruin","ending","first light"],"A figure pinned face-downten swords beneath a black sky —onhorizon, a yellow bandsunrise. The worstthis particular pattern can dobeen done. Nothing remains exceptrisea different shape."],["swords-page","PageSwords",3,11,["watchfulness","inquiry","sharp wit","vigilance","fresh ideas"],"A young figurea windswept hill holds a sword aloft, hair whipping behind. The mind newly armed, restlesstest itself onworld. Brilliant;yet seasoned. Watchtendencycutthe pleasurecutting."],["swords-knight","KnightSwords",3,12,["charge","intellectmotion","haste","argument","decisive force"],"Armor, drawn sword, a horsefull gallop. The thought aimed attargetcommitted before second thought can intervene. Magnificent onright battlefield, dangerousa parlor. Aim well."],["swords-queen","QueenSwords",3,13,["discernment","candor","independence","clarity earned","principled mind"],"She sitsa throne aboveclouds,hand raisedmeasured welcome,other holdingsword upright. The elementair, mastered: thought refined byshesurvived. Sheunkind; shesimply unwillinglie."],["swords-king","KingSwords",3,14,["judgement","authority","intellectual command","law","principled rule"],"He sits frontalsteady,sword held verticallyhimworld. Authorityderivesdisciplined usemind — fair, exact, willingbe unpopular. isjudgechairsituationbeen waiting for."],["pentacles-1","AcePentacles",4,1,["opportunity","seed","tangible gift","prosperity","beginningsubstance"],"A handcloud offers a single golden disc above a garden gate. A real beginningphysical world — a sum, an offer, a pieceground. asksgift be honoredbeing used,merely admired."],["pentacles-2","TwoPentacles",4,2,["balance","juggling","adaptability","flux","managed motion"],"A figure dances while keeping two pentacles alofta lemniscate, ships rocking onswell behind him. Multiple demands heldmotionacceptingnone can be set downlong. Grace under continuous adjustment."],["pentacles-3","ThreePentacles",4,3,["craft","collaboration","competence","apprenticeship","skilled labor"],"A mason consultstwo figures holdingplans, three pentacles setarch above. Work ineach rolegenuinely neededqualitywhole reflectsqualitycooperation. honorsunsung disciplinedoingjob well."],["pentacles-4","FourPentacles",4,4,["holding","security","possessiveness","conservation","tight grip"],"A crowned figure clutchespentaclehis chest, two underfoot,on his head. Security obtainedclosinghand. Usefulshort term;long term, a postureprevents both givingreceiving. Notice whatunableset down."],["pentacles-5","FivePentacles",4,5,["hardship","exclusion","scarcity","lit window","endurance"],"Two figures pass beneath a stained-glass windowsnow,of them limping. sitsmaterial lackcolder lonelinessfeeling outsidewarmth. The windowreal;dooralso real, ifseekerwillingaskit."],["pentacles-6","SixPentacles",4,6,["generosity","exchange","balancegiving","patronage","fairness"],"A merchant weighs coinsa scaledrops themhandstwo kneeling figures. isethicsflow —to give, how much,unsettling questionwho holdsscale todaywho will holdtomorrow."],["pentacles-7","SevenPentacles",4,7,["assessment","patience","long view","tending","yieldprogress"],"A farmer leanshis hoelooks atpentacles ripening onvine. Notharvest —pause beforeharvest,honest accounting ofthe laborso far produced. Time itselfpartcrop."],["pentacles-8","EightPentacles",4,8,["diligence","practice","masterymotion","focus","the workshop"],"A craftsman seatedhis bench, strikingsame disc againagain,finished ones hung neatly beside him. isunglamorous truthexcellencelargely repetition doneattention. The work itself isteacher."],["pentacles-9","NinePentacles",4,9,["self-sufficiency","earned ease","refinement","garden","solitary plenty"],"A figure standsa vineyarda hooded falconher gloved hand. The wealth around her isresultlong disciplineis hersa way no inheritance could be. isquiet, well-lit pleasurea life arrangedone's own hand."],["pentacles-10","TenPentacles",4,10,["legacy","lineage","establishment","inheritance","rooted abundance"],"An elder, a couple, a child,dogs beneath an archway hungten pentacles. Wealthas an eventas a structureholds multiple generations. The cardis being builtwill outlastbuilder."],["pentacles-page","PagePentacles",4,11,["study","apprenticeship","diligence","earnestness","new venture"],"A young figure standsa green field, contemplating a single pentacle heldboth hands like a problem worth taking seriously. The willingnessbegin atbeginning, learnmaterials,not skip steps. Modest; promising."],["pentacles-knight","KnightPentacles",4,12,["perseverance","method","reliability","patient labor","stewardship"],"A knighta stationary horse,pentacle held forward, a ploughed field behind him. He moves atpacework actually requires,faster. isunglamorous virtuefinishingwas started,a scheduleworld can count on."],["pentacles-queen","QueenPentacles",4,13,["nurture","practicality","abundance","embodied wisdom","tended life"],"She sitsa flowering bower,pentacle restingher lap like a sleeping animal. The elementearth, mastered: caretakesformfood ontable, a clean room, a body looked after. ishome as a working ecosystem."],["pentacles-king","KingPentacles",4,14,["prosperity","provision","commandresources","established mastery","steady hand"],"He sits enthroned among vines,pentacle balancedhis knee, his castle visible behind him. The resultdecadescompetent workvisible world — wealthsupports more thanowner. iselder wholearnedmakematerial serve."]]; +export default inflateDeck(keys,dictionaries,rows); diff --git a/src/chambers/oracle/index.js b/src/chambers/oracle/index.js index b9bef75..779ee2d 100644 --- a/src/chambers/oracle/index.js +++ b/src/chambers/oracle/index.js @@ -11,10 +11,17 @@ import { h, svg, clear } from '../../lib/dom.js'; import { CHAMBER_BY_ID } from '../manifest.js'; -import { fileSafeISO, humanDate, shortHash, hexToBytes } from '../../lib/format.js'; +import { hexToBytes, bytesToHex } from '../../lib/format.js'; import { sha256Hex } from '../../lib/hash.js'; import { buildProvenance, toast } from '../lottery/_shared.js'; import { makeIllustration } from './illustration.js'; +import { selectSpreadIndices, spreadSelectionByteCount } from './selection.js'; +import { ActionBar, Dialog, ResultStage } from '../../ui/primitives.js'; +import { + dailyDerivedProvenance, + readDailyRouteContext, + relationsFromDailyRoute, +} from '../../features/today/route-context.js'; const meta = CHAMBER_BY_ID['oracle']; export const id = 'oracle'; @@ -22,51 +29,13 @@ export const displayName = meta.displayName; export const tagline = meta.tagline; export const icon = meta.icon; -const DECKS = [ - { - id: 'tarot', - label: 'Tarot', - count: 78, - hint: '78 cards · Major + Minor Arcana', - origin: 'Rider–Waite tradition', - blurb: - 'The classic 78-card divination deck. 22 Major Arcana for life’s larger movements, plus four suits (Wands, Cups, Swords, Pentacles) of 14 Minor Arcana each — daily currents, choices, and pressures.', - }, - { - id: 'i-ching', - label: 'I-Ching', - count: 64, - hint: '64 hexagrams', - origin: 'Wilhelm / Baynes tradition', - blurb: - 'The Chinese Book of Changes. Sixty-four hexagrams — each a stack of six broken or unbroken lines — whose patterns describe the character of a moment. Contemplative, archaic, exact.', - }, - { - id: 'runes', - label: 'Runes', - count: 24, - hint: '24 runes · Elder Futhark', - origin: 'Germanic Iron Age tradition', - blurb: - 'The Elder Futhark — 24 carved symbols from pre-Christian Germanic culture. Each rune names something material or communal: cattle, journey, ice, hall, gift, the ancestral land.', - }, - { - id: 'cosmic', - label: 'Cosmic', - count: 36, - hint: '36 original cards', - origin: 'Original to Sortilune', - blurb: - 'An original deck of 36 cards designed for Sortilune. Each card names a concept drawn from astronomy and philosophy — Stillness, Threshold, Penumbra, Catenary, Inflection — across six categories of mind and matter.', - }, -]; - const SPREADS = { single: { label: 'One card', positions: ['Draw'] }, three: { label: 'Three cards', positions: ['Past', 'Present', 'Future'] }, }; let _ctx = null; +let _dailyDraw = false; let _deck = 'cosmic'; let _spread = 'single'; let _customPositions = ['', '', '']; @@ -75,26 +44,99 @@ let _drawing = false; let _drawingStatus = ''; let _lastDraw = null; let _reflection = ''; +let _revealTimer = null; +let _illustrationDialog = null; let _deckCache = {}; +let _decks = []; +let _root = null; +let _unsubscribeContent = null; +let _route = null; -export async function mount(rootEl, ctx) { +export async function mount(rootEl, ctx, route) { _ctx = ctx; + _root = rootEl; + _route = route; + _decks = ctx.content.oracleDecks(); + _unsubscribeContent?.(); + _unsubscribeContent = ctx.content.subscribe(() => { + _deckCache = {}; + _decks = ctx.content.oracleDecks(); + if (!_decks.some((deck) => deck.id === _deck)) _deck = 'cosmic'; + if (_ctx === ctx && _root) render(_root); + }); const s = ctx.state?.get?.(); if (s?.lastUsedDeck) _deck = s.lastUsedDeck; - // Preload the chosen deck so the first draw feels instant. - loadDeck(_deck).catch(() => {}); + if (!_decks.some((deck) => deck.id === _deck)) _deck = 'cosmic'; + const hydrated = await hydrateDailyDraw(route); + if (!hydrated) { + if (_dailyDraw) { + _dailyDraw = false; + _lastDraw = null; + _reflection = ''; + } + // Preload the chosen deck so the first draw feels instant. + loadDeck(_deck).catch(() => {}); + } render(rootEl); } export function unmount() { + if (_revealTimer) { + clearTimeout(_revealTimer); + _revealTimer = null; + } + _illustrationDialog?.close(); + _illustrationDialog = null; + _unsubscribeContent?.(); + _unsubscribeContent = null; + _root = null; + _route = null; _ctx = null; } +async function hydrateDailyDraw(route) { + const daily = readDailyRouteContext(route); + if (!daily || daily.params.daily_stream !== 'oracle') return false; + const index = Number(daily.params.daily_card_index); + const raw = daily.params.daily_seed || ''; + if (!Number.isSafeInteger(index) || !/^[0-9a-f]{128}$/.test(raw)) { + throw new TypeError('Today Oracle handoff is invalid'); + } + const deck = await loadDeck('cosmic'); + if (index < 0 || index >= deck.length) throw new RangeError('Today Oracle card is outside the built-in deck'); + const provenance = dailyDerivedProvenance(route, raw, 'Oracle card and illustration'); + if (!provenance) throw new TypeError('Today Oracle provenance is incomplete'); + const card = deck[index]; + const bytes = hexToBytes(raw); + _deck = 'cosmic'; + _spread = 'single'; + _useCustom = false; + _reflection = ''; + _dailyDraw = true; + _lastDraw = { + deckId: 'cosmic', + spread: 'single', + positions: ['Daily signal'], + cards: [{ + position: 'Daily signal', + card, + provenance, + illustration: makeIllustration({ rawBytes: bytes, deck: 'cosmic', card, size: 380 }), + raw_hex: raw, + }], + drawn_at: provenance.fetched_at, + id: (await sha256Hex(`${raw}:cosmic:${index}`)).slice(0, 12), + archive_relations: relationsFromDailyRoute(route), + }; + return true; +} + async function loadDeck(deckId) { if (_deckCache[deckId]) return _deckCache[deckId]; try { - const mod = await import(`./decks/${deckId}.json`); - const arr = mod.default || mod; + const definition = _decks.find((deck) => deck.id === deckId); + if (!definition) throw new RangeError(`unknown deck: ${deckId}`); + const arr = await definition.load(); _deckCache[deckId] = arr; return arr; } catch (e) { @@ -105,7 +147,7 @@ async function loadDeck(deckId) { function render(rootEl) { clear(rootEl); - const frame = h('div', { class: 'chamber-frame full-bleed reveal' }, [ + const frame = h('div', { class: `chamber-frame full-bleed reveal${_lastDraw ? ' oracle-has-result' : ''}` }, [ h('div', { class: 'oracle-header' }, [ h('div', null, [ h('div', { class: 'chamber-id' }, ['Chamber · 01/08']), @@ -118,10 +160,10 @@ function render(rootEl) { ]), ]), - h('section', { class: 'oracle-config-bar' }, [ + !_lastDraw ? h('section', { class: 'oracle-config-bar' }, [ buildDeckRow(rootEl), buildSpreadRow(rootEl), - ]), + ]) : null, !_lastDraw ? buildStage(rootEl) : null, _lastDraw ? buildResult(rootEl) : null, @@ -133,7 +175,7 @@ function buildDeckRow(rootEl) { return h('div', { class: 'oracle-row' }, [ h('span', { class: 'oracle-row-label' }, ['Deck']), h('div', { class: 'oracle-deck-pills' }, - DECKS.map((d) => h('button', { + _decks.map((d) => h('button', { class: 'oracle-deck-pill', 'aria-current': d.id === _deck ? 'true' : 'false', title: d.hint, @@ -170,14 +212,17 @@ function buildSpreadRow(rootEl) { type: 'text', class: 'input', placeholder: 'Position names, comma separated (e.g. body, mind, spirit)', style: { marginLeft: '12px', flex: '1' }, value: _customPositions.join(', '), - oninput: (e) => { _customPositions = e.target.value.split(',').map((s) => s.trim()).filter(Boolean); }, + oninput: (e) => { + _customPositions = e.target.value.split(',').map((s) => s.trim()).filter(Boolean).slice(0, 12); + }, }) : null, ]); } function buildStage(rootEl) { - const deck = DECKS.find((d) => d.id === _deck) || DECKS[0]; + const deck = _decks.find((d) => d.id === _deck) || _decks[0]; + if (!deck) return h('div', { class: 'panel' }, ['No Oracle decks are available.']); const positionCount = _useCustom ? Math.max(1, _customPositions.length || 1) : SPREADS[_spread].positions.length; @@ -191,7 +236,7 @@ function buildStage(rootEl) { h('p', { class: 'oracle-deck-blurb-text' }, [deck.blurb]), ]), h('div', { class: 'oracle-card-backs' }, - Array.from({ length: positionCount }, (_, i) => cardBack(_drawing, _deck)) + Array.from({ length: positionCount }, () => cardBack(_drawing, _deck)) ), h('div', { class: 'oracle-stage-action' }, [ _drawing @@ -276,10 +321,20 @@ const DECK_MOTIFS = { }; function buildResult(rootEl) { - return h('section', { class: 'oracle-result' }, [ - h('div', { class: 'row center', style: { gap: '12px', marginBottom: '16px' } }, [ - h('button', { class: 'btn', onclick: () => { _lastDraw = null; _reflection = ''; render(rootEl); setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 50); } }, ['← Back to deck']), - h('button', { class: 'btn', onclick: () => doDraw(rootEl) }, ['Draw again']), + const deck = _decks.find((candidate) => candidate.id === _lastDraw.deckId); + return ResultStage({ + className: 'oracle-result', + label: 'Oracle draw result', + status: 'result', + children: [ + h('div', { class: 'oracle-result-summary spread' }, [ + h('div', null, [ + h('div', { class: 'label' }, ['Draw complete']), + h('div', { class: 'small muted' }, [ + `${deck?.label ?? _lastDraw.deckId} · ${_lastDraw.cards.length} ${_lastDraw.cards.length === 1 ? 'card' : 'cards'}`, + ]), + ]), + h('span', { class: 'badge' }, ['Result']), ]), ..._lastDraw.cards.map((c) => buildCardPanel(c)), h('section', { class: 'oracle-reflection panel-flush' }, [ @@ -291,18 +346,38 @@ function buildResult(rootEl) { value: _reflection, }), ]), - h('div', { class: 'row center', style: { gap: '12px', marginTop: '20px', marginBottom: '40px' } }, [ - h('button', { class: 'btn btn-primary', onclick: () => save() }, ['Save to archive']), - h('button', { class: 'btn', onclick: () => doDraw(rootEl) }, ['Draw again']), - ]), - ]); + ActionBar({ + label: 'Oracle result actions', + primary: [h('button', { class: 'btn btn-primary', onclick: () => save() }, ['Save'])], + secondary: [ + h('button', { class: 'btn', onclick: () => doDraw(rootEl) }, ['Repeat']), + h('button', { + class: 'btn btn-ghost', + onclick: () => { + _lastDraw = null; + _reflection = ''; + render(rootEl); + setTimeout(() => document.querySelector('.oracle-config-bar')?.scrollIntoView({ block: 'start' }), 50); + }, + }, ['Back']), + ], + }), + ] }); } function buildCardPanel(c) { return h('article', { class: 'oracle-card panel reveal' }, [ h('div', { class: 'oracle-card-position small muted' }, [c.position]), h('div', { class: 'oracle-card-body' }, [ - h('div', { class: 'oracle-card-art' }, [svg(c.illustration)]), + h('button', { + class: 'oracle-card-art', + type: 'button', + 'aria-label': `Enlarge illustration for ${cardName(c.card, _lastDraw.deckId)}`, + onclick: () => openIllustration(c), + }, [ + svg(c.illustration), + h('span', { class: 'oracle-art-expand', 'aria-hidden': 'true' }, ['Enlarge']), + ]), h('div', { class: 'oracle-card-info' }, [ h('h2', { class: 'oracle-card-name' }, [cardName(c.card, _lastDraw.deckId)]), c.card.keywords ? h('div', { class: 'oracle-card-keywords' }, c.card.keywords.map((k) => h('span', { class: 'badge' }, [k]))) : null, @@ -313,6 +388,25 @@ function buildCardPanel(c) { ]); } +function openIllustration(drawCard) { + const name = cardName(drawCard.card, _lastDraw?.deckId); + _illustrationDialog?.close(); + const dialog = Dialog({ + title: `${name} illustration`, + className: 'oracle-art-dialog', + closeLabel: 'Close enlarged illustration', + content: [h('figure', { class: 'oracle-art-focus' }, [ + svg(drawCard.illustration), + h('figcaption', null, [name]), + ])], + onClose: () => { + if (_illustrationDialog === dialog) _illustrationDialog = null; + }, + }); + _illustrationDialog = dialog; + dialog.open(); +} + function cardName(card, deckId) { if (deckId === 'i-ching') return `${card.id}. ${card.name_en} ${card.name_zh ? `· ${card.name_zh}` : ''}`; if (deckId === 'runes') return `${card.glyph} ${card.name}`; @@ -322,7 +416,7 @@ function cardMeaning(card, deckId) { if (deckId === 'i-ching') return card.meaning; if (deckId === 'runes') return card.meaning; if (deckId === 'cosmic') return card.description; - return card.meaning || ''; + return card.meaning || card.description || ''; } async function doDraw(rootEl) { @@ -354,33 +448,42 @@ async function doDraw(rootEl) { : SPREADS[_spread].positions; const cards = []; - const drawnIndices = new Set(); try { + const deckIndices = Array.from({ length: deck.length }, (_, index) => index); + const selectionByteCount = spreadSelectionByteCount(deckIndices.length); + const illustrationByteCount = positions.length * 64; + _drawingStatus = 'querying entropy source for the complete spread…'; + render(rootEl); + const result = await _ctx.entropy.request({ + kind: 'bytes', + count: selectionByteCount + illustrationByteCount, + source: 'preferred', + }); + const rawBytes = hexToBytes(result.provenance.raw); + const selectedIndices = selectSpreadIndices(rawBytes, deckIndices.length, positions.length); for (let i = 0; i < positions.length; i++) { - _drawingStatus = `${positions[i]}: querying entropy source…`; - render(rootEl); - let pickIdx; - let attempts = 0; - let result; - while (true) { - attempts++; - result = await _ctx.entropy.request({ kind: 'integer', range: [0, deck.length - 1], source: 'preferred' }); - pickIdx = result.value; - if (!drawnIndices.has(pickIdx) || attempts > 20) break; - } - drawnIndices.add(pickIdx); + _drawingStatus = `${positions[i]}: composing illustration…`; + const pickIdx = selectedIndices[i]; const card = deck[pickIdx]; - const rawBytes = hexToBytes(result.provenance.raw); - const illustration = makeIllustration({ rawBytes, deck: _deck, card, size: 380 }); + const artStart = selectionByteCount + i * 64; + const artBytes = rawBytes.subarray(artStart, artStart + 64); + const illustration = makeIllustration({ rawBytes: artBytes, deck: _deck, card, size: 380 }); cards.push({ position: positions[i], card, provenance: result.provenance, illustration, - raw_hex: result.provenance.raw, + raw_hex: bytesToHex(artBytes), }); } - const drawId = (await sha256Hex(JSON.stringify(cards.map((c) => c.raw_hex)))).slice(0, 12); + const drawId = (await sha256Hex(result.provenance.raw)).slice(0, 12); + const definition = _decks.find((candidate) => candidate.id === _deck); + const packReference = definition?.source.type === 'pack' + ? _ctx.content.reference(definition.source, cards.length === 1 ? String(cards[0].card.id) : 'spread', { + deck: definition.label, + cards: cards.map((entry) => entry.card), + }) + : undefined; _lastDraw = { deckId: _deck, spread: _useCustom ? 'custom' : _spread, @@ -388,16 +491,20 @@ async function doDraw(rootEl) { cards, drawn_at: new Date().toISOString(), id: drawId, + ...(packReference ? { pack_reference: packReference } : {}), }; } catch (e) { - toast(`Draw failed: ${e.message || e}`, 'danger'); + if (_ctx) toast(`Draw failed: ${e.message || e}`, 'danger'); console.error('oracle draw failed', e); } _drawing = false; _drawingStatus = ''; + if (!_ctx) return; render(rootEl); // Smooth-scroll the first card panel into view so the reveal is obvious. - setTimeout(() => { + _revealTimer = setTimeout(() => { + _revealTimer = null; + if (!_ctx) return; const first = document.querySelector('.oracle-result .oracle-card'); if (first) first.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 80); @@ -405,17 +512,33 @@ async function doDraw(rootEl) { async function save() { if (!_ctx?.archive || !_lastDraw) return; - const stamp = fileSafeISO(new Date(_lastDraw.drawn_at)); - const rel = `archive/oracle/${stamp}__draw_${_lastDraw.id}.json`; - const body = { - human_summary: `Oracle (${_lastDraw.deckId}) draw: ${_lastDraw.cards.map((c) => `${c.position}=${cardName(c.card, _lastDraw.deckId)}`).join('; ')}`, - type: 'oracle-draw', - ..._lastDraw, - reflection: _reflection, - }; try { - await _ctx.archive.writeJSON(rel, body); - toast(`Saved → ${rel}`, 'success'); + const summary = `Oracle (${_lastDraw.deckId}) draw: ${_lastDraw.cards.map((c) => `${c.position}=${cardName(c.card, _lastDraw.deckId)}`).join('; ')}`; + const provenance = _lastDraw.cards.map((card) => card.provenance); + const { archive_relations: archiveRelations = [], pack_reference: packReference, ...drawPayload } = _lastDraw; + const cards = _lastDraw.cards.map(({ illustration: _illustration, provenance: _provenance, ...card }, index) => ({ + ...card, + illustration_asset_role: `card-illustration-${index + 1}`, + })); + const saved = await _ctx.archive.save({ + chamber: 'oracle', + type: 'oracle-draw', + createdAt: _lastDraw.drawn_at, + summary, + payload: { ...drawPayload, cards, reflection: _reflection }, + provenance, + relations: _ctx.projects.relations(_route, archiveRelations), + pack: packReference, + assets: _lastDraw.cards.map((card, index) => ({ + role: `card-illustration-${index + 1}`, + mediaType: 'image/svg+xml', + extension: 'svg', + content: card.illustration, + width: 380, + height: 380, + })), + }); + toast(`Saved → ${saved.path}`, 'success'); } catch (e) { toast(`Save failed: ${e.message || e}`, 'danger'); } diff --git a/src/chambers/oracle/selection.js b/src/chambers/oracle/selection.js new file mode 100644 index 0000000..720a9f9 --- /dev/null +++ b/src/chambers/oracle/selection.js @@ -0,0 +1,27 @@ +import { convert, neededBytes } from '../../lib/entropy/convert.js'; + +export function spreadSelectionByteCount(deckLength) { + validateDeckAndCount(deckLength, 1); + return neededBytes({ + kind: 'permutation', + choices: Array.from({ length: deckLength }, (_, index) => index), + }); +} + +export function selectSpreadIndices(bytes, deckLength, count) { + validateDeckAndCount(deckLength, count); + if (!(bytes instanceof Uint8Array)) throw new TypeError('selection bytes must be a Uint8Array'); + const choices = Array.from({ length: deckLength }, (_, index) => index); + const required = neededBytes({ kind: 'permutation', choices }); + if (bytes.length < required) throw new RangeError(`selection requires at least ${required} bytes`); + return convert(bytes.subarray(0, required), { kind: 'permutation', choices }).value.slice(0, count); +} + +function validateDeckAndCount(deckLength, count) { + if (!Number.isSafeInteger(deckLength) || deckLength < 1 || deckLength > 10_000) { + throw new RangeError('deck length must be an integer from 1 through 10000'); + } + if (!Number.isSafeInteger(count) || count < 1 || count > deckLength) { + throw new RangeError('spread count must be an integer from 1 through the deck length'); + } +} diff --git a/src/chambers/symphony/audio-engine.js b/src/chambers/symphony/audio-engine.js index 9405669..c02f78e 100644 --- a/src/chambers/symphony/audio-engine.js +++ b/src/chambers/symphony/audio-engine.js @@ -12,23 +12,7 @@ * Everything quantized to D Dorian. */ -const D_DORIAN_HZ = (() => { - const semis = [-9, -7, -6, -4, -2, 0, 1, 3]; // relative to A4=440 - const out = []; - for (let oct = -2; oct <= 1; oct++) { - for (const s of semis) out.push(440 * Math.pow(2, (s + 12 * oct) / 12)); - } - return out; -})(); - -function nearestScalePitch(target) { - let best = D_DORIAN_HZ[0], bestD = Infinity; - for (const p of D_DORIAN_HZ) { - const d = Math.abs(Math.log2(p / target)); - if (d < bestD) { bestD = d; best = p; } - } - return best; -} +import { SYMPHONY_ATMOSPHERE } from '../../symphony/score.js'; export class Engine { constructor() { @@ -40,8 +24,6 @@ export class Engine { this.padOscs = []; this.running = false; this.volume = 0.45; - // Throttle quake events so they don't pile up - this._lastQuakeAt = 0; } start() { @@ -65,14 +47,14 @@ export class Engine { this.bus.connect(this.master); // Pad: two sine oscillators on the same fundamental, detuned ±7 cents - const fundamental = nearestScalePitch(98); // ~G2 + const fundamental = SYMPHONY_ATMOSPHERE.fundamental_hz; const detunes = [-7, +7]; this.padOscs = []; this.padGain = this.ctx.createGain(); this.padGain.gain.value = 0; this.padFilter = this.ctx.createBiquadFilter(); this.padFilter.type = 'lowpass'; - this.padFilter.frequency.value = 600; + this.padFilter.frequency.value = SYMPHONY_ATMOSPHERE.filter_hz; this.padFilter.Q.value = 0.4; this.padFilter.connect(this.padGain); this.padGain.connect(this.bus); @@ -100,7 +82,7 @@ export class Engine { // Pad swells in over 6 s; final pad level is modest const t = this.ctx.currentTime; this.padGain.gain.setValueAtTime(0, t); - this.padGain.gain.linearRampToValueAtTime(0.07, t + 6); + this.padGain.gain.linearRampToValueAtTime(SYMPHONY_ATMOSPHERE.gain, t + 6); this.running = true; } @@ -108,9 +90,9 @@ export class Engine { stop() { if (!this.running) return; const t = this.ctx.currentTime; - try { this.padGain.gain.cancelScheduledValues(t); this.padGain.gain.linearRampToValueAtTime(0, t + 1.2); } catch {} + try { this.padGain.gain.cancelScheduledValues(t); this.padGain.gain.linearRampToValueAtTime(0, t + 1.2); } catch { /* audio context may already be closed */ } const oldCtx = this.ctx; - setTimeout(() => { try { oldCtx.close(); } catch {} }, 1400); + setTimeout(() => { try { oldCtx.close(); } catch { /* already closed */ } }, 1400); this.running = false; this.ctx = null; this.master = null; @@ -125,86 +107,42 @@ export class Engine { if (this.master) this.master.gain.value = this.volume; } - /** Soft quake tone. Sine wave; narrow audible-but-warm band; long release. */ - playQuake({ mag = 4, depth = 30, lon = 0 } = {}) { - if (!this.running || !this.ctx) return; - const now = Date.now(); - if (now - this._lastQuakeAt < 1200) return; // throttle so events don't pile up - this._lastQuakeAt = now; + /** Consume a complete deterministic SymphonyScore event. */ + playScoreEvent(event) { + if (!this.running || !this.ctx || !this.bus || !event) return false; + if (event.modulation?.filter_hz != null) this.setFilterFrequency(event.modulation.filter_hz); + for (const voice of event.voices || []) this.playVoice(voice); + return true; + } + playVoice(voice) { + if (!this.running || !this.ctx || !this.bus) return; const ctx = this.ctx; - // Narrow pitch range: 196–523 Hz (G3 to C5). Deeper = lower. - const baseHz = 196 + (Math.max(0, Math.min(700, depth))) * (-1) * (196 - 196) // keep base - + Math.max(0, Math.min(700, 700 - depth)) * (523 - 196) / 700; - const hz = nearestScalePitch(Math.max(196, Math.min(523, baseHz))); - // Loudness: small range, never harsh. Capped at 0.22 even for huge quakes. - const loud = Math.max(0.03, Math.min(0.22, (mag - 1.5) / 8)); - const pan = Math.max(-1, Math.min(1, lon / 180)); - - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.value = hz; - // A touch of detune so it doesn't sound sterile - osc.detune.value = (Math.random() - 0.5) * 4; - const filt = ctx.createBiquadFilter(); - filt.type = 'lowpass'; - filt.frequency.value = 1400; - filt.Q.value = 0.3; + const start = ctx.currentTime + Math.max(0, voice.offset_ms || 0) / 1000; + const duration = Math.max(0.001, voice.duration_ms / 1000); + const attack = Math.min(duration, Math.max(0.001, voice.attack_ms / 1000)); + const oscillator = ctx.createOscillator(); + oscillator.type = voice.waveform || 'sine'; + oscillator.frequency.value = voice.frequency_hz; + oscillator.detune.value = voice.detune_cents || 0; const gain = ctx.createGain(); gain.gain.value = 0; const panner = ctx.createStereoPanner ? ctx.createStereoPanner() : null; - if (panner) panner.pan.value = pan; - osc.connect(filt); filt.connect(gain); + if (panner) panner.pan.value = Math.max(-1, Math.min(1, voice.pan || 0)); + oscillator.connect(gain); if (panner) { gain.connect(panner); panner.connect(this.bus); } - else { gain.connect(this.bus); } - - const t = ctx.currentTime; - const ATTACK = 0.6; - const DUR = 8; - gain.gain.setValueAtTime(0, t); - gain.gain.linearRampToValueAtTime(loud, t + ATTACK); - gain.gain.exponentialRampToValueAtTime(0.0001, t + DUR); - osc.start(t); - osc.stop(t + DUR + 0.1); - } - - /** Beacon chime. Integer harmonics → bell-like, in tune. */ - playChime() { - if (!this.running || !this.ctx) return; - const ctx = this.ctx; - const fundamental = nearestScalePitch(523); // ~C5 - // True integer harmonics: f, 2f, 3f, 4f, weighted with decaying amplitude - const partials = [ - { mult: 1, amp: 0.10, dur: 6 }, - { mult: 2, amp: 0.05, dur: 4.5 }, - { mult: 3, amp: 0.025, dur: 3 }, - { mult: 4, amp: 0.012, dur: 2.5 }, - ]; - const t = ctx.currentTime; - for (const p of partials) { - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.value = fundamental * p.mult; - const g = ctx.createGain(); - g.gain.value = 0; - osc.connect(g); - g.connect(this.bus); - g.gain.setValueAtTime(0, t); - g.gain.linearRampToValueAtTime(p.amp, t + 0.02); - g.gain.exponentialRampToValueAtTime(0.0001, t + p.dur); - osc.start(t); - osc.stop(t + p.dur + 0.05); - } + else gain.connect(this.bus); + gain.gain.setValueAtTime(0, start); + gain.gain.linearRampToValueAtTime(Math.max(0.0001, voice.gain), start + attack); + gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); + oscillator.start(start); + oscillator.stop(start + duration + 0.02); } - /** Wind modulates pad filter frequency. Wind in m/s. Subtle. */ - setWindLevel(metersPerSec) { + setFilterFrequency(target) { if (!this.running || !this.padFilter) return; - const norm = Math.max(0, Math.min(1, metersPerSec / 20)); - // Modest range: 400–1100 Hz so pad stays warm - const target = 400 + norm * 700; const t = this.ctx.currentTime; this.padFilter.frequency.cancelScheduledValues(t); - this.padFilter.frequency.linearRampToValueAtTime(target, t + 2); + this.padFilter.frequency.linearRampToValueAtTime(Math.max(20, Math.min(24000, target)), t + 2); } } diff --git a/src/chambers/symphony/index.js b/src/chambers/symphony/index.js index 8a98442..5b31f80 100644 --- a/src/chambers/symphony/index.js +++ b/src/chambers/symphony/index.js @@ -8,10 +8,22 @@ import { h, clear } from '../../lib/dom.js'; import { CHAMBER_BY_ID } from '../manifest.js'; import { fetchJSON } from '../../lib/http.js'; -import { fileSafeISO, humanDate } from '../../lib/format.js'; +import { humanDate } from '../../lib/format.js'; import { toast } from '../lottery/_shared.js'; import { Engine } from './audio-engine.js'; import { PlanetDisplay, PLANET_CITIES } from './planet-display.js'; +import { ActionBar, ResultStage } from '../../ui/primitives.js'; +import { readDailyRouteContext, relationsFromDailyRoute } from '../../features/today/route-context.js'; +import { + beaconScoreEvent, + buildSymphonyScore, + markerScoreEvent, + motifScoreEvent, + quakeScoreEvent, + windScoreEvent, +} from '../../symphony/score.js'; +import { encodeSymphonyMidi, encodeSymphonyWav } from '../../symphony/export.js'; +import { validateSymphonyScore } from '../../schemas/validate.js'; const meta = CHAMBER_BY_ID['symphony']; export const id = 'symphony'; @@ -32,16 +44,45 @@ const symphonyState = { engine: null, display: null, running: false, + sessionId: 0, startedAt: null, + stoppedAt: null, + lastPulseIndex: null, volume: 0.6, seenQuakes: new Set(), log: [], pollTimers: [], - domEls: { sessionLabel: null, eventLog: null, statusDot: null }, + dailyMotif: null, + sessionDailyMotifs: [], + sessionRelations: [], + scoreEvents: [], + rootEl: null, + revealNext: true, + replay: { + mode: 'idle', + positionMs: 0, + speed: 1, + anchorPositionMs: 0, + anchorWallMs: 0, + nextEventIndex: 0, + activeEventId: null, + engine: null, + timer: null, + }, + domEls: { sessionLabel: null, eventLog: null, statusDot: null, replayRange: null, replayTime: null }, }; -export function mount(rootEl, ctx) { +export function mount(rootEl, ctx, route) { symphonyState.ctx = ctx; + symphonyState.rootEl = rootEl; + symphonyState.revealNext = true; + const motif = readDailyMotif(route); + if (motif) symphonyState.dailyMotif = motif; + else if (!symphonyState.running) { + symphonyState.dailyMotif = null; + symphonyState.sessionDailyMotifs = []; + symphonyState.sessionRelations = []; + } render(rootEl); } @@ -49,12 +90,17 @@ export function unmount() { // Keep the engine running across chamber switches per acceptance criterion; // only the visible display is paused. symphonyState.display = null; - symphonyState.domEls = { sessionLabel: null, eventLog: null, statusDot: null }; + haltReplay(); + symphonyState.rootEl = null; + symphonyState.revealNext = true; + symphonyState.domEls = { sessionLabel: null, eventLog: null, statusDot: null, replayRange: null, replayTime: null }; } function render(rootEl) { clear(rootEl); - const frame = h('div', { class: 'chamber-frame full-bleed reveal' }, [ + const frameClass = `chamber-frame full-bleed${symphonyState.revealNext ? ' reveal' : ''}`; + symphonyState.revealNext = false; + const frame = h('div', { class: frameClass }, [ h('div', { class: 'symphony-header' }, [ h('div', null, [ h('div', { class: 'chamber-id' }, ['Chamber · 07/08']), @@ -64,26 +110,32 @@ function render(rootEl) { h('div', { class: 'symphony-status row', style: { gap: '12px' } }, [ h('span', { class: 'status-dot', id: 'sym-status-dot' }, []), h('span', { class: 'mono small muted', id: 'sym-session-label' }, [ - symphonyState.running ? sessionStr(symphonyState.startedAt) : 'idle', + symphonyState.running ? sessionStr(symphonyState.startedAt) + : symphonyState.replay.mode !== 'idle' ? `replay · ${formatDuration(symphonyState.replay.positionMs)}` : 'idle', ]), ]), ]), + symphonyState.dailyMotif ? buildDailyMotif(rootEl, symphonyState.dailyMotif) : null, h('section', { class: 'symphony-body' }, [ - h('section', { class: 'symphony-stage' }, [ - h('div', { class: 'planet-display', id: 'sym-planet' }), - ]), + ResultStage({ + className: 'symphony-stage', + label: 'Live planetary soundscape', + status: symphonyState.running ? 'live' : symphonyState.replay.mode === 'playing' ? 'replay' : 'idle', + children: [h('div', { class: 'planet-display', id: 'sym-planet' })], + }), h('section', { class: 'symphony-controls panel' }, [ - h('div', { class: 'row center', style: { gap: '12px' } }, [ - h('button', { + ActionBar({ + label: 'Symphony controls', + primary: [h('button', { class: 'btn btn-primary btn-big', onclick: () => toggle(rootEl), - }, [symphonyState.running ? 'Stop' : 'Play']), - h('button', { + }, [symphonyState.running ? 'Stop' : 'Play'])], + secondary: [h('button', { class: 'btn', onclick: () => saveSession(), disabled: !symphonyState.log.length, - }, ['Save session log']), - ]), + }, ['Save'])], + }), h('label', { class: 'stack-1', style: { marginTop: '20px' } }, [ h('span', { class: 'label' }, ['Volume']), h('input', { @@ -95,17 +147,13 @@ function render(rootEl) { }, }), ]), + buildReplayPanel(rootEl), h('div', { class: 'sym-libretto' }, [ h('div', { class: 'spread', style: { alignItems: 'baseline' } }, [ h('div', { class: 'label' }, ['Timeline']), h('span', { class: 'small muted' }, ['the soundscape’s libretto']), ]), - h('ul', { class: 'sym-log', id: 'sym-event-log' }, - symphonyState.log.slice(-12).reverse().map((ev) => h('li', { class: 'sym-log-row' }, [ - h('span', { class: 'mono small muted' }, [new Date(ev.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })]), - h('span', null, [ev.text]), - ])) - ), + h('ul', { class: 'sym-log', id: 'sym-event-log' }, timelineRows()), ]), ]), ]), @@ -116,8 +164,10 @@ function render(rootEl) { symphonyState.domEls.sessionLabel = document.getElementById('sym-session-label'); symphonyState.domEls.eventLog = document.getElementById('sym-event-log'); symphonyState.domEls.statusDot = document.getElementById('sym-status-dot'); + symphonyState.domEls.replayRange = document.getElementById('sym-replay-range'); + symphonyState.domEls.replayTime = document.getElementById('sym-replay-time'); if (symphonyState.domEls.statusDot) { - symphonyState.domEls.statusDot.className = 'status-dot' + (symphonyState.running ? ' running' : ''); + symphonyState.domEls.statusDot.className = 'status-dot' + (symphonyState.running || symphonyState.replay.mode === 'playing' ? ' running' : ''); } // Wire planet display @@ -126,13 +176,131 @@ function render(rootEl) { refreshDisplay(); } +function readDailyMotif(route) { + const daily = readDailyRouteContext(route); + if (!daily || daily.params.daily_stream !== 'symphony') return null; + let value; + try { + value = JSON.parse(daily.params.daily_motif || ''); + } catch { + throw new TypeError('Today Symphony motif is not valid JSON'); + } + const tempo = value?.tempo; + const notes = value?.notes; + const durations = value?.durations; + if ( + !Number.isSafeInteger(tempo) + || tempo < 54 + || tempo > 84 + || !Array.isArray(notes) + || notes.length !== 8 + || notes.some((note) => !Number.isSafeInteger(note) || note < 0 || note > 127) + || !Array.isArray(durations) + || durations.length !== 8 + || durations.some((duration) => !Number.isSafeInteger(duration) || duration < 1 || duration > 4) + ) { + throw new TypeError('Today Symphony motif is invalid'); + } + return { + dailyRecordId: daily.dailyRecordId, + tempo, + notes: [...notes], + durations: [...durations], + relations: relationsFromDailyRoute(route), + }; +} + +function buildDailyMotif(rootEl, motif) { + return h('section', { class: 'symphony-daily-motif panel', 'aria-labelledby': 'symphony-daily-title' }, [ + h('div', { class: 'stack-1' }, [ + h('div', { class: 'label' }, ['From Today']), + h('h2', { id: 'symphony-daily-title' }, [`${motif.tempo} BPM · D Dorian`]), + h('p', { class: 'small muted' }, ['An eight-note motif derived from the archived daily stream. Playing it adds that DailyRecord relation to this session.']), + ]), + h('div', { class: 'symphony-motif-notes', 'aria-hidden': 'true' }, motif.notes.map((note, index) => h('span', { + style: { + height: `${18 + (note - Math.min(...motif.notes)) * 5}px`, + width: `${6 + motif.durations[index] * 5}px`, + }, + }))), + h('button', { + class: 'btn btn-primary', + onclick: () => playDailyMotif(rootEl), + }, ['Play daily motif']), + ]); +} + +function buildReplayPanel(rootEl) { + if (!symphonyState.scoreEvents.length || !symphonyState.startedAt) { + return h('section', { class: 'sym-replay empty' }, [ + h('div', { class: 'label' }, ['Replay & export']), + h('p', { class: 'small muted' }, ['Play a session to create a deterministic score.']), + ]); + } + const score = currentScore(); + const mode = symphonyState.replay.mode; + return h('section', { class: 'sym-replay', 'aria-labelledby': 'sym-replay-title' }, [ + h('div', { class: 'spread', style: { alignItems: 'baseline' } }, [ + h('div', { class: 'label', id: 'sym-replay-title' }, ['Replay & export']), + h('span', { class: 'small muted' }, [`Exact v1 score · ${score.events.length} events`]), + ]), + h('div', { class: 'sym-replay-buttons' }, [ + h('button', { + class: 'btn btn-small', + onclick: () => mode === 'playing' ? pauseReplay(rootEl) : startReplay(rootEl), + }, [mode === 'playing' ? 'Pause replay' : mode === 'paused' ? 'Resume replay' : 'Replay']), + h('button', { + class: 'btn btn-small btn-ghost', + disabled: mode === 'idle', + onclick: () => stopReplay(rootEl), + }, ['Stop replay']), + h('label', { class: 'sym-speed small' }, [ + h('span', null, ['Speed']), + h('select', { + onchange: (event) => setReplaySpeed(Number(event.target.value)), + }, [0.5, 1, 1.5, 2].map((speed) => h('option', { + value: String(speed), + selected: speed === symphonyState.replay.speed, + }, [`${speed}×`]))), + ]), + ]), + h('label', { class: 'sym-seek' }, [ + h('span', { class: 'sr-only' }, ['Replay position']), + h('input', { + id: 'sym-replay-range', + type: 'range', + min: 0, + max: score.duration_ms, + step: 100, + value: Math.min(score.duration_ms, symphonyState.replay.positionMs), + oninput: (event) => previewReplayPosition(Number(event.target.value)), + onchange: (event) => seekReplay(rootEl, Number(event.target.value)), + }), + h('span', { class: 'mono small muted', id: 'sym-replay-time' }, [ + `${formatDuration(symphonyState.replay.positionMs)} / ${formatDuration(score.duration_ms)}`, + ]), + ]), + h('div', { class: 'sym-export-buttons' }, [ + h('button', { class: 'btn btn-small', onclick: downloadWav }, ['Download WAV']), + h('button', { class: 'btn btn-small', onclick: downloadMidi }, ['Download MIDI']), + ]), + score.duration_ms > 5 * 60 * 1000 + ? h('p', { class: 'small muted' }, ['WAV is limited to five-minute sessions; MIDI and Archive saving remain available.']) + : null, + ]); +} + function toggle(rootEl) { if (symphonyState.running) stop(); - else start(); + else { + if (symphonyState.replay.mode !== 'idle') haltReplay(); + start(); + } render(rootEl); } function start() { + if (symphonyState.replay.mode !== 'idle') haltReplay(); try { symphonyState.engine = new Engine(); symphonyState.engine.start(); @@ -142,9 +310,15 @@ function start() { return; } symphonyState.running = true; + symphonyState.sessionId++; symphonyState.startedAt = new Date().toISOString(); + symphonyState.stoppedAt = null; + symphonyState.lastPulseIndex = null; symphonyState.seenQuakes = new Set(); symphonyState.log = []; + symphonyState.scoreEvents = []; + symphonyState.sessionDailyMotifs = []; + symphonyState.sessionRelations = []; // Polls symphonyState.pollTimers.push(setInterval(pollQuakes, QUAKE_POLL_MS)); @@ -156,7 +330,49 @@ function start() { pollQuakes(); pollBeacon(); pollWeather(); - appendLog({ time: Date.now(), text: 'session opened.' }); + recordEvent(markerScoreEvent({ atMs: 0, sourceId: `session-${symphonyState.sessionId}`, label: 'session opened.' })); + const opening = motifScoreEvent({ + atMs: 350, + sourceId: `opening-${symphonyState.sessionId}`, + source: 'session', + label: 'opening phrase · D Dorian.', + tempo: 72, + notes: [62, 65, 69, 72, 69, 65, 64, 62], + durations: [1, 1, 1, 2, 1, 1, 1, 2], + }); + symphonyState.engine.playScoreEvent(opening); + recordEvent(opening); +} + +function playDailyMotif(rootEl) { + const motif = symphonyState.dailyMotif; + if (!motif) return; + if (!symphonyState.running) start(); + if (!symphonyState.running || !symphonyState.engine) return; + const event = motifScoreEvent({ + atMs: scoreOffset(), + sourceId: motif.dailyRecordId, + label: `Today motif · ${motif.tempo} BPM.`, + tempo: motif.tempo, + notes: motif.notes, + durations: motif.durations, + }); + symphonyState.engine.playScoreEvent(event); + if (!symphonyState.sessionDailyMotifs.some((entry) => entry.dailyRecordId === motif.dailyRecordId)) { + symphonyState.sessionDailyMotifs.push({ + dailyRecordId: motif.dailyRecordId, + tempo: motif.tempo, + notes: [...motif.notes], + durations: [...motif.durations], + }); + } + for (const relation of motif.relations) { + if (!symphonyState.sessionRelations.some((entry) => entry.kind === relation.kind && entry.target_id === relation.target_id)) { + symphonyState.sessionRelations.push(relation); + } + } + recordEvent(event); + render(rootEl); } function stop() { @@ -164,12 +380,20 @@ function stop() { for (const t of symphonyState.pollTimers) clearInterval(t); symphonyState.pollTimers = []; symphonyState.running = false; - appendLog({ time: Date.now(), text: 'session closed.' }); + symphonyState.sessionId++; + symphonyState.stoppedAt = new Date().toISOString(); + recordEvent(markerScoreEvent({ + atMs: scoreOffset(symphonyState.stoppedAt), + sourceId: `session-${symphonyState.sessionId}`, + label: 'session closed.', + })); } async function pollQuakes() { + const sessionId = symphonyState.sessionId; try { const data = await fetchJSON(USGS); + if (!symphonyState.running || sessionId !== symphonyState.sessionId) return; const features = Array.isArray(data?.features) ? data.features : []; const fresh = features.filter((f) => !symphonyState.seenQuakes.has(f.id)); // First poll: register everything as seen, then play the 3 most significant @@ -187,7 +411,7 @@ async function pollQuakes() { place: f.properties.place, })); if (symphonyState.display) symphonyState.display.setQuakes(list); - appendLog({ time: Date.now(), text: `loaded ${features.length} quakes from the past hour.` }); + appendMessage(`loaded ${features.length} quakes from the past hour.`, 'usgs', 'opening-feed'); const opening = [...features] .filter((f) => typeof f.properties.mag === 'number') @@ -195,14 +419,18 @@ async function pollQuakes() { .slice(0, 3); opening.forEach((f, i) => { setTimeout(() => { - if (!symphonyState.running || !symphonyState.engine) return; + if (!symphonyState.running || !symphonyState.engine || sessionId !== symphonyState.sessionId) return; const lat = f.geometry.coordinates[1]; const lon = f.geometry.coordinates[0]; const depth = f.geometry.coordinates[2]; const mag = f.properties.mag; const place = f.properties.place || `(${lat.toFixed(1)}, ${lon.toFixed(1)})`; - symphonyState.engine.playQuake({ mag, depth, lon }); - appendLog({ time: Date.now(), text: `M${(mag ?? 0).toFixed(1)} · ${place} (opening)` }); + const event = quakeScoreEvent({ + atMs: scoreOffset(), sourceId: f.id, label: `M${(mag ?? 0).toFixed(1)} · ${place} (opening)`, + magnitude: mag, depth, lat, lon, + }); + symphonyState.engine.playScoreEvent(event); + recordEvent(event); if (symphonyState.display) symphonyState.display.pingQuake({ lat, lon, mag }); }, 1500 + i * 2200); }); @@ -215,9 +443,12 @@ async function pollQuakes() { const depth = f.geometry.coordinates[2]; const mag = f.properties.mag; const place = f.properties.place || `(${lat.toFixed(1)}, ${lon.toFixed(1)})`; - symphonyState.engine.playQuake({ mag, depth, lon }); - const ev = { time: new Date(f.properties.time).toISOString(), text: `M${(mag ?? 0).toFixed(1)} · ${place}` }; - appendLog(ev); + const event = quakeScoreEvent({ + atMs: scoreOffset(), sourceId: f.id, label: `M${(mag ?? 0).toFixed(1)} · ${place}`, + magnitude: mag, depth, lat, lon, + }); + symphonyState.engine.playScoreEvent(event); + recordEvent(event); if (symphonyState.display) { symphonyState.display.pingQuake({ lat, lon, mag }); } @@ -233,65 +464,272 @@ async function pollQuakes() { })); if (symphonyState.display) symphonyState.display.setQuakes(list); } catch (e) { - appendLog({ time: Date.now(), text: `quake feed unreachable: ${e.message || e}` }); + if (symphonyState.running && sessionId === symphonyState.sessionId) { + appendMessage(`quake feed unreachable: ${e.message || e}`, 'usgs', 'feed-error'); + } } } async function pollBeacon() { if (!symphonyState.engine) return; + const sessionId = symphonyState.sessionId; try { const data = await fetchJSON(NIST_LAST); - if (data?.pulse?.pulseIndex) { - symphonyState.engine.playChime(); - appendLog({ time: Date.now(), text: `beacon pulse #${data.pulse.pulseIndex}` }); + if (!symphonyState.running || sessionId !== symphonyState.sessionId) return; + if (data?.pulse?.pulseIndex != null && data.pulse.pulseIndex !== symphonyState.lastPulseIndex) { + symphonyState.lastPulseIndex = data.pulse.pulseIndex; + const event = beaconScoreEvent({ + atMs: scoreOffset(), + sourceId: String(data.pulse.pulseIndex), + label: `beacon pulse #${data.pulse.pulseIndex}`, + }); + symphonyState.engine.playScoreEvent(event); + recordEvent(event); } } catch (e) { - appendLog({ time: Date.now(), text: `beacon unreachable: ${e.message || e}` }); + if (symphonyState.running && sessionId === symphonyState.sessionId) { + appendMessage(`beacon unreachable: ${e.message || e}`, 'nist', 'feed-error'); + } } } async function pollWeather() { if (!symphonyState.engine) return; + const sessionId = symphonyState.sessionId; try { const winds = {}; let totalSpeed = 0; let count = 0; await Promise.all(PLANET_CITIES.map(async (c) => { try { - const data = await fetchJSON(`https://api.open-meteo.com/v1/forecast?latitude=${c.lat}&longitude=${c.lon}¤t=wind_speed_10m`); + const data = await fetchJSON(`https://api.open-meteo.com/v1/forecast?latitude=${c.lat}&longitude=${c.lon}¤t=wind_speed_10m&wind_speed_unit=ms`); const w = data?.current?.wind_speed_10m; if (typeof w === 'number') { winds[c.name] = w; totalSpeed += w; count++; } - } catch {} + } catch { /* one unavailable city should not fail the global average */ } })); + if (!symphonyState.running || sessionId !== symphonyState.sessionId) return; if (count > 0) { const avg = totalSpeed / count; - symphonyState.engine.setWindLevel(avg); + const event = windScoreEvent({ + atMs: scoreOffset(), + sourceId: `city-average-${Date.now()}`, + label: `wind average ${avg.toFixed(1)} m/s across ${count} cities.`, + windMps: avg, + }); + symphonyState.engine.playScoreEvent(event); if (symphonyState.display) symphonyState.display.setCityWind(winds); - appendLog({ time: Date.now(), text: `wind average ${avg.toFixed(1)} m/s across ${count} cities.` }); + recordEvent(event); } } catch (e) { - appendLog({ time: Date.now(), text: `weather unreachable: ${e.message || e}` }); + if (symphonyState.running && sessionId === symphonyState.sessionId) { + appendMessage(`weather unreachable: ${e.message || e}`, 'weather', 'feed-error'); + } } } -function appendLog(event) { - symphonyState.log.push(event); +function recordEvent(scoreEvent) { + symphonyState.scoreEvents.push(scoreEvent); + if (symphonyState.scoreEvents.length > 500) symphonyState.scoreEvents.shift(); + const start = Date.parse(symphonyState.startedAt || new Date().toISOString()); + symphonyState.log.push({ + time: new Date(start + scoreEvent.at_ms).toISOString(), + text: scoreEvent.label, + source: scoreEvent.source, + source_id: scoreEvent.source_id, + score_event_id: scoreEvent.id, + }); if (symphonyState.log.length > 500) symphonyState.log.shift(); - if (symphonyState.domEls.eventLog) { - const el = symphonyState.domEls.eventLog; - const recent = symphonyState.log.slice(-12).reverse(); - el.innerHTML = ''; - for (const ev of recent) { - const li = document.createElement('li'); - li.className = 'sym-log-row'; - li.innerHTML = `${new Date(ev.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}${escape(ev.text)}`; - el.appendChild(li); - } - } + renderTimeline(); if (symphonyState.domEls.sessionLabel && symphonyState.running) { symphonyState.domEls.sessionLabel.textContent = sessionStr(symphonyState.startedAt); } } +function appendMessage(text, source = 'session', sourceId = 'message') { + recordEvent(markerScoreEvent({ + atMs: scoreOffset(), + sourceId: `${sourceId}-${symphonyState.scoreEvents.length + 1}`, + label: text, + source, + })); +} + +function scoreOffset(time = Date.now()) { + if (!symphonyState.startedAt) return 0; + const timestamp = typeof time === 'number' ? time : Date.parse(time); + return Math.max(0, Math.round(timestamp - Date.parse(symphonyState.startedAt))); +} + +function currentScore() { + if (!symphonyState.startedAt) throw new Error('Play a Symphony session first.'); + const end = symphonyState.running ? new Date().toISOString() : symphonyState.stoppedAt; + return buildSymphonyScore(symphonyState.startedAt, symphonyState.scoreEvents, end); +} + +function timelineRows() { + const events = symphonyState.replay.mode === 'idle' + ? symphonyState.log.slice(-12).reverse() + : symphonyState.log; + return events.map((event) => h('li', { + class: `sym-log-row${event.score_event_id === symphonyState.replay.activeEventId ? ' is-current' : ''}`, + 'data-score-event': event.score_event_id, + }, [ + h('span', { class: 'mono small muted' }, [new Date(event.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })]), + h('span', { class: `sym-source sym-source-${event.source || 'legacy'}` }, [symphonySourceLabel(event.source)]), + h('span', null, [event.text]), + ])); +} + +function symphonySourceLabel(source) { + return ({ + session: 'On device', + usgs: 'USGS', + nist: 'NIST', + weather: 'Open-Meteo', + today: 'Today', + legacy: 'Legacy', + })[source] || source || 'Unknown'; +} + +function renderTimeline() { + const list = symphonyState.domEls.eventLog; + if (!list) return; + clear(list); + list.append(...timelineRows()); +} + +function startReplay(rootEl) { + if (!symphonyState.scoreEvents.length) return; + if (symphonyState.running) stop(); + const score = currentScore(); + if (symphonyState.replay.positionMs >= score.duration_ms) symphonyState.replay.positionMs = 0; + beginReplayAudio(score); + render(rootEl); + tickReplay(); +} + +function beginReplayAudio(score) { + releaseReplayAudio(); + try { + symphonyState.replay.engine = new Engine(); + symphonyState.replay.engine.start(); + symphonyState.replay.engine.setVolume(symphonyState.volume); + } catch (error) { + symphonyState.replay.engine = null; + symphonyState.replay.mode = 'idle'; + toast(`Replay failed: ${error.message || error}`, 'danger'); + return; + } + symphonyState.replay.mode = 'playing'; + symphonyState.replay.anchorPositionMs = symphonyState.replay.positionMs; + symphonyState.replay.anchorWallMs = performance.now(); + const index = score.events.findIndex((event) => event.at_ms >= symphonyState.replay.positionMs); + symphonyState.replay.nextEventIndex = index < 0 ? score.events.length : index; + symphonyState.replay.timer = setInterval(tickReplay, 50); +} + +function pauseReplay(rootEl) { + synchronizeReplayClock(); + releaseReplayAudio(); + symphonyState.replay.mode = 'paused'; + render(rootEl); + updateReplayDom(); +} + +function stopReplay(rootEl) { + haltReplay(); + render(rootEl); +} + +function haltReplay() { + releaseReplayAudio(); + symphonyState.replay.mode = 'idle'; + symphonyState.replay.positionMs = 0; + symphonyState.replay.activeEventId = null; +} + +function releaseReplayAudio() { + if (symphonyState.replay.timer) clearInterval(symphonyState.replay.timer); + symphonyState.replay.timer = null; + if (symphonyState.replay.engine) symphonyState.replay.engine.stop(); + symphonyState.replay.engine = null; +} + +function tickReplay() { + if (symphonyState.replay.mode !== 'playing') return; + const score = currentScore(); + synchronizeReplayClock(); + while (symphonyState.replay.nextEventIndex < score.events.length) { + const event = score.events[symphonyState.replay.nextEventIndex]; + if (!event || event.at_ms > symphonyState.replay.positionMs) break; + symphonyState.replay.engine?.playScoreEvent(event); + if (event.location && symphonyState.display) symphonyState.display.pingQuake({ + lat: event.location.lat, + lon: event.location.lon, + mag: event.location.magnitude, + }); + symphonyState.replay.nextEventIndex++; + } + updateReplayDom(score); + if (symphonyState.replay.positionMs >= score.duration_ms) { + releaseReplayAudio(); + symphonyState.replay.mode = 'paused'; + symphonyState.replay.positionMs = score.duration_ms; + if (symphonyState.rootEl) render(symphonyState.rootEl); + } +} + +function synchronizeReplayClock() { + if (symphonyState.replay.mode !== 'playing') return; + const elapsed = (performance.now() - symphonyState.replay.anchorWallMs) * symphonyState.replay.speed; + symphonyState.replay.positionMs = Math.min(currentScore().duration_ms, symphonyState.replay.anchorPositionMs + elapsed); +} + +function setReplaySpeed(speed) { + if (![0.5, 1, 1.5, 2].includes(speed)) return; + synchronizeReplayClock(); + symphonyState.replay.speed = speed; + symphonyState.replay.anchorPositionMs = symphonyState.replay.positionMs; + symphonyState.replay.anchorWallMs = performance.now(); +} + +function previewReplayPosition(positionMs) { + const time = symphonyState.domEls.replayTime; + if (time) time.textContent = `${formatDuration(positionMs)} / ${formatDuration(currentScore().duration_ms)}`; +} + +function seekReplay(rootEl, positionMs) { + const score = currentScore(); + const wasPlaying = symphonyState.replay.mode === 'playing'; + releaseReplayAudio(); + symphonyState.replay.positionMs = Math.max(0, Math.min(score.duration_ms, positionMs)); + if (wasPlaying) beginReplayAudio(score); + else if (symphonyState.replay.mode !== 'idle') symphonyState.replay.mode = 'paused'; + render(rootEl); + updateReplayDom(score); +} + +function updateReplayDom(score = currentScore()) { + const position = Math.min(score.duration_ms, symphonyState.replay.positionMs); + if (symphonyState.domEls.replayRange) symphonyState.domEls.replayRange.value = String(Math.round(position)); + if (symphonyState.domEls.replayTime) { + symphonyState.domEls.replayTime.textContent = `${formatDuration(position)} / ${formatDuration(score.duration_ms)}`; + } + if (symphonyState.domEls.sessionLabel && symphonyState.replay.mode !== 'idle') { + symphonyState.domEls.sessionLabel.textContent = `replay · ${formatDuration(position)}`; + } + const active = [...score.events].reverse().find((event) => event.at_ms <= position); + if (active?.id === symphonyState.replay.activeEventId) return; + symphonyState.replay.activeEventId = active?.id ?? null; + document.querySelectorAll('.sym-log-row[data-score-event]').forEach((row) => { + const selected = row.getAttribute('data-score-event') === symphonyState.replay.activeEventId; + row.classList.toggle('is-current', selected); + if (selected) { + row.setAttribute('aria-current', 'true'); + row.scrollIntoView({ block: 'nearest' }); + } else row.removeAttribute('aria-current'); + }); +} + function refreshDisplay() { if (symphonyState.display) symphonyState.display.render(); } @@ -305,21 +743,65 @@ function sessionStr(startedAt) { async function saveSession() { if (!symphonyState.ctx?.archive || !symphonyState.log.length) return; - const stamp = fileSafeISO(new Date(symphonyState.startedAt || Date.now())); - const id = (symphonyState.startedAt || '').slice(-6).replace(/\D/g, '') || 'session'; - const rel = `archive/symphony/${stamp}__session_${id}.json`; try { - await symphonyState.ctx.archive.writeJSON(rel, { - human_summary: `Symphony session of ${symphonyState.log.length} events, started ${humanDate(symphonyState.startedAt || new Date().toISOString())}`, + const createdAt = symphonyState.startedAt || new Date().toISOString(); + const score = currentScore(); + if (!validateSymphonyScore(score)) throw new TypeError('The Symphony score could not be validated.'); + const saved = await symphonyState.ctx.archive.save({ + chamber: 'symphony', type: 'symphony-session', - started_at: symphonyState.startedAt, - stopped_at: new Date().toISOString(), - events: symphonyState.log, + createdAt, + summary: `Symphony session of ${symphonyState.log.length} events, started ${humanDate(createdAt)}`, + payload: { + started_at: symphonyState.startedAt, + stopped_at: symphonyState.stoppedAt, + snapshot_at: new Date().toISOString(), + events: symphonyState.log, + score, + daily_motifs: symphonyState.sessionDailyMotifs, + }, + relations: symphonyState.sessionRelations, }); - toast(`Saved → ${rel}`, 'success'); + toast(`Saved → ${saved.path}`, 'success'); } catch (e) { toast(`Save failed: ${e.message || e}`, 'danger'); } } -function escape(s) { return String(s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); } +function downloadWav() { + try { + const score = currentScore(); + if (!validateSymphonyScore(score)) throw new TypeError('The Symphony score could not be validated.'); + downloadBytes(encodeSymphonyWav(score), 'audio/wav', 'wav'); + toast('WAV score exported.', 'success'); + } catch (error) { + toast(`WAV export failed: ${error.message || error}`, 'danger'); + } +} + +function downloadMidi() { + try { + const score = currentScore(); + if (!validateSymphonyScore(score)) throw new TypeError('The Symphony score could not be validated.'); + downloadBytes(encodeSymphonyMidi(score), 'audio/midi', 'mid'); + toast('MIDI score exported.', 'success'); + } catch (error) { + toast(`MIDI export failed: ${error.message || error}`, 'danger'); + } +} + +function downloadBytes(bytes, mediaType, extension) { + const blob = new Blob([bytes], { type: mediaType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + const stamp = (symphonyState.startedAt || new Date().toISOString()).replaceAll(':', '-').replaceAll('.', '-'); + anchor.href = url; + anchor.download = `sortilune-symphony-${stamp}.${extension}`; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function formatDuration(milliseconds) { + const seconds = Math.max(0, Math.floor(milliseconds / 1_000)); + return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`; +} diff --git a/src/domain/algorithm-registry.ts b/src/domain/algorithm-registry.ts new file mode 100644 index 0000000..5924eec --- /dev/null +++ b/src/domain/algorithm-registry.ts @@ -0,0 +1,61 @@ +export interface VersionedAlgorithm { + id: string; + version: number; + replay(input: TInput): Promise | TOutput; +} + +const IDENTIFIER = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; + +export class AlgorithmRegistry { + readonly #algorithms = new Map>(); + + register(algorithm: VersionedAlgorithm): this { + if (!IDENTIFIER.test(algorithm.id) || algorithm.id.length > 96) { + throw new TypeError('algorithm ID must be a bounded lowercase identifier'); + } + if (!Number.isSafeInteger(algorithm.version) || algorithm.version < 1 || algorithm.version > 2_147_483_647) { + throw new RangeError('algorithm version must be a positive 32-bit integer'); + } + if (typeof algorithm.replay !== 'function') throw new TypeError('algorithm replay must be a function'); + const key = registryKey(algorithm.id, algorithm.version); + if (this.#algorithms.has(key)) throw new Error(`algorithm already registered: ${key}`); + this.#algorithms.set(key, Object.freeze({ ...algorithm }) as VersionedAlgorithm); + return this; + } + + has(id: string, version: number): boolean { + return this.#algorithms.has(registryKey(id, version)); + } + + get(id: string, version: number): VersionedAlgorithm { + const algorithm = this.#algorithms.get(registryKey(id, version)); + if (!algorithm) throw new UnsupportedAlgorithmError(id, version); + return algorithm as VersionedAlgorithm; + } + + replay(id: string, version: number, input: TInput): Promise { + return Promise.resolve().then(() => this.get(id, version).replay(input)); + } + + list(): ReadonlyArray<{ id: string; version: number }> { + return [...this.#algorithms.values()] + .map(({ id, version }) => ({ id, version })) + .sort((left, right) => left.id.localeCompare(right.id) || left.version - right.version); + } +} + +export class UnsupportedAlgorithmError extends Error { + readonly algorithmId: string; + readonly version: number; + + constructor(id: string, version: number) { + super(`unsupported algorithm: ${id}/v${version}`); + this.name = 'UnsupportedAlgorithmError'; + this.algorithmId = id; + this.version = version; + } +} + +function registryKey(id: string, version: number): string { + return `${id}\0${version}`; +} diff --git a/src/domain/archive-annotations.ts b/src/domain/archive-annotations.ts new file mode 100644 index 0000000..9160d0f --- /dev/null +++ b/src/domain/archive-annotations.ts @@ -0,0 +1,24 @@ +export interface ArchiveAnnotation { + title?: string; + tags: string[]; + favorite: boolean; + hidden: boolean; + collections: string[]; + updated_at: string; +} + +export interface ArchiveCollection { + id: string; + name: string; + created_at: string; + updated_at: string; +} + +export interface ArchiveAnnotationStore { + schema: 'sortilune.archive-annotations'; + schema_version: 1; + updated_at: string; + records: Record; + collections: Record; +} + diff --git a/src/domain/archive-record.ts b/src/domain/archive-record.ts new file mode 100644 index 0000000..f2cb486 --- /dev/null +++ b/src/domain/archive-record.ts @@ -0,0 +1,102 @@ +import type { Rfc3339Timestamp, Sha256Hex, StableId } from './identifiers.js'; + +export type JsonPrimitive = null | boolean | number | string; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type SourceKind = + | 'public-randomness' + | 'quantum' + | 'atmospheric' + | 'seismic' + | 'weather' + | 'system' + | 'fixture' + | 'imported'; + +export interface SourceReference { + id: string; + label: string; + kind: SourceKind; + url?: string; +} + +export interface Provenance { + source: SourceReference; + fetched_at: Rfc3339Timestamp; + raw: string; + signature?: string | null; + details?: JsonValue; +} + +export type RelationKind = + | 'project' + | 'daily-record' + | 'rerolled-from' + | 'rerolled-to' + | 'source-record' + | 'derived-from' + | 'annotation-for' + | 'practice'; + +export interface Relation { + kind: RelationKind; + target_id: StableId; + target_schema?: string; + metadata?: JsonValue; +} + +export type AssetMediaType = + | 'image/svg+xml' + | 'image/png' + | 'application/json' + | 'text/markdown' + | 'audio/wav'; + +export interface AssetReference { + id: StableId; + role: string; + media_type: AssetMediaType; + path: string; + sha256: Sha256Hex; + bytes: number; + width?: number; + height?: number; +} + +export interface LegacyPackReference { + id: StableId; + version: number; + content_hash?: Sha256Hex; +} + +export interface ContentPackReference { + id: string; + version: string; + digest: Sha256Hex; + item_id: string; + content_snapshot: JsonValue; +} + +export type PackReference = LegacyPackReference | ContentPackReference; + +export interface AlgorithmReference { + id: string; + version: number; + parameters?: JsonValue; +} + +export interface ArchiveRecord { + schema: 'sortilune.archive-record'; + schema_version: 1; + id: StableId; + chamber: string; + type: string; + created_at: Rfc3339Timestamp; + summary: string; + payload: TPayload; + provenance: Provenance[]; + relations: Relation[]; + assets: AssetReference[]; + pack?: PackReference; + algorithm?: AlgorithmReference; +} diff --git a/src/domain/contracts.ts b/src/domain/contracts.ts new file mode 100644 index 0000000..92f609c --- /dev/null +++ b/src/domain/contracts.ts @@ -0,0 +1,62 @@ +import type { ArchiveRepository } from '../archive/repository.js'; +import type { AppState } from '../lib/state.js'; +import type { ContentRegistry } from '../packs/content-registry.js'; +import type { PackRepository } from '../packs/repository.js'; +import type { ProjectService } from '../projects/service.js'; +import type { PracticeRepository } from '../practices/repository.js'; +import type { RouteReference } from './settings.js'; + +export type EntropyKind = 'bytes' | 'float' | 'integer' | 'choice' | 'permutation'; + +export interface LegacyEntropyProvenance { + source_id: string; + source_name: string; + flavor: string; + description: string; + fetched_at: string; + raw: string; + signature: string | null; + extra: Record; +} + +export interface EntropyRequest { + kind?: EntropyKind; + range?: [number, number]; + count?: number; + source?: string; + choices?: TChoice[]; + fallback?: boolean; +} + +export interface EntropyResult { + value: TValue; + provenance: LegacyEntropyProvenance; +} + +export interface EntropyService { + request(request?: EntropyRequest): Promise>; + getSourceStatus(): unknown[]; + testAllSources(): Promise; + reset(): void; + setEnabled(map: Readonly> | null): void; + setPreferred(source: string | null): void; +} + +export interface AppContext { + entropy: EntropyService; + archive: ArchiveRepository; + content: ContentRegistry; + packs: PackRepository; + projects: ProjectService; + practices: PracticeRepository; + state: { + get(): AppState; + set(patch: Partial): void; + subscribe(listener: (state: AppState, previous: AppState) => void): () => void; + }; + settings: { + get(): AppState; + set(patch: Partial): void; + }; + navigate(destination: string | RouteReference, params?: Record): Promise; +} diff --git a/src/domain/identifiers.ts b/src/domain/identifiers.ts new file mode 100644 index 0000000..73b99a1 --- /dev/null +++ b/src/domain/identifiers.ts @@ -0,0 +1,80 @@ +declare const stableIdBrand: unique symbol; +declare const rfc3339Brand: unique symbol; +declare const sha256Brand: unique symbol; + +export type StableId = string & { readonly [stableIdBrand]: 'StableId' }; +export type Rfc3339Timestamp = string & { readonly [rfc3339Brand]: 'Rfc3339Timestamp' }; +export type Sha256Hex = string & { readonly [sha256Brand]: 'Sha256Hex' }; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/; +const SHA256_PATTERN = /^[0-9a-f]{64}$/; + +export function isStableId(value: unknown): value is StableId { + return typeof value === 'string' && UUID_PATTERN.test(value); +} + +export function parseStableId(value: unknown): StableId { + if (!isStableId(value)) throw new TypeError('stable ID must be a valid UUID'); + return value; +} + +export function createStableId(): StableId { + return parseStableId(crypto.randomUUID()); +} + +export async function stableIdFromText(value: string): Promise { + if (typeof value !== 'string' || value.length < 1 || value.length > 4096) { + throw new TypeError('stable ID input must contain 1 to 4096 characters'); + } + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); + const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return parseStableId(`${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`); +} + +export function isRfc3339Timestamp(value: unknown): value is Rfc3339Timestamp { + if (typeof value !== 'string' || value.length > 64) return false; + const match = RFC3339_PATTERN.exec(value); + if (!match) return false; + + const [, yearText, monthText, dayText, hourText, minuteText, secondText, offsetHourText, offsetMinuteText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + const offsetHour = offsetHourText === undefined ? 0 : Number(offsetHourText); + const offsetMinute = offsetMinuteText === undefined ? 0 : Number(offsetMinuteText); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0; + + return month >= 1 + && month <= 12 + && day >= 1 + && day <= daysInMonth + && hour <= 23 + && minute <= 59 + && second <= 59 + && offsetHour <= 23 + && offsetMinute <= 59 + && Number.isFinite(Date.parse(value)); +} + +export function parseRfc3339Timestamp(value: unknown): Rfc3339Timestamp { + if (!isRfc3339Timestamp(value)) throw new TypeError('timestamp must be valid RFC 3339'); + return value; +} + +export function nowRfc3339(): Rfc3339Timestamp { + return parseRfc3339Timestamp(new Date().toISOString()); +} + +export function isSha256Hex(value: unknown): value is Sha256Hex { + return typeof value === 'string' && SHA256_PATTERN.test(value); +} + +export function parseSha256Hex(value: unknown): Sha256Hex { + if (!isSha256Hex(value)) throw new TypeError('SHA-256 value must be 64 lowercase hexadecimal characters'); + return value; +} diff --git a/src/domain/journal.ts b/src/domain/journal.ts new file mode 100644 index 0000000..300c130 --- /dev/null +++ b/src/domain/journal.ts @@ -0,0 +1,256 @@ +import type { NormalizedArchiveItem } from '../archive/repository.js'; +import type { Project } from './projects.js'; + +export type JournalTheme = 'paper' | 'midnight'; +export type JournalOrder = 'oldest' | 'newest'; +export type JournalProvenance = 'none' | 'summary' | 'full'; + +export interface JournalOptions { + title: string; + cover: boolean; + theme: JournalTheme; + order: JournalOrder; + provenance: JournalProvenance; + include_private_writing: boolean; +} + +export interface JournalFact { + label: string; + value: string; +} + +export interface JournalSection { + id: string; + kicker: string; + title: string; + date: string | null; + paragraphs: string[]; + facts: JournalFact[]; + provenance: string[]; +} + +export interface JournalManifest { + schema: 'sortilune.journal-export-manifest'; + schema_version: 1; + created_at: string; + renderer: 'sortilune.journal-html/v1'; + selected_record_ids: string[]; + project_id: string | null; + options: JournalOptions; +} + +export interface JournalDocument { + schema: 'sortilune.journal-document'; + schema_version: 1; + title: string; + subtitle: string; + options: JournalOptions; + sections: JournalSection[]; + manifest: JournalManifest; +} + +export function createJournalDocument(input: { + records: readonly NormalizedArchiveItem[]; + options: JournalOptions; + project?: Project | null; + createdAt?: string; +}): JournalDocument { + const options = normalizeJournalOptions(input.options); + const ordered = [...input.records].sort((left, right) => ( + options.order === 'oldest' + ? left.created_at.localeCompare(right.created_at) + : right.created_at.localeCompare(left.created_at) + )); + const sections = [ + ...(input.project ? [projectSection(input.project, options.include_private_writing)] : []), + ...ordered.map((record) => archiveSection(record, options)), + ]; + const createdAt = normalizeTimestamp(input.createdAt ?? new Date().toISOString()); + return { + schema: 'sortilune.journal-document', + schema_version: 1, + title: options.title, + subtitle: subtitleFor(ordered.length, input.project ?? null), + options, + sections, + manifest: { + schema: 'sortilune.journal-export-manifest', + schema_version: 1, + created_at: createdAt, + renderer: 'sortilune.journal-html/v1', + selected_record_ids: ordered.map((record) => record.id), + project_id: input.project?.id ?? null, + options, + }, + }; +} + +export function normalizeJournalOptions(options: JournalOptions): JournalOptions { + const title = String(options.title ?? '').normalize('NFC').trim().replace(/\s+/gu, ' '); + if (!title || title.length > 160) throw new TypeError('Journal title must contain 1 to 160 characters.'); + if (!['paper', 'midnight'].includes(options.theme)) throw new TypeError('Journal theme is invalid.'); + if (!['oldest', 'newest'].includes(options.order)) throw new TypeError('Journal order is invalid.'); + if (!['none', 'summary', 'full'].includes(options.provenance)) throw new TypeError('Journal provenance option is invalid.'); + return { + title, + cover: Boolean(options.cover), + theme: options.theme, + order: options.order, + provenance: options.provenance, + include_private_writing: Boolean(options.include_private_writing), + }; +} + +function archiveSection(item: NormalizedArchiveItem, options: JournalOptions): JournalSection { + const payload = objectValue(item.payload); + const paragraphs = [item.summary]; + const facts: JournalFact[] = [ + { label: 'Chamber', value: titleCase(item.chamber) }, + { label: 'Record type', value: item.type }, + ]; + if (item.chamber === 'today' && item.type === 'daily-record') addToday(payload, paragraphs, facts); + else if (item.chamber === 'diary' || item.type === 'diary-entry') addDiary(payload, paragraphs, facts, options.include_private_writing); + else if (item.type === 'practice-completion' || item.type === 'practice-skip') addPractice(payload, paragraphs, facts, options.include_private_writing); + else if (item.type === 'symphony-session') addSymphony(payload, paragraphs, facts); + else addGeneric(payload, facts, options.include_private_writing); + return { + id: item.id, + kicker: `${titleCase(item.chamber)} · ${item.type}`, + title: sectionTitle(item, payload), + date: item.created_at, + paragraphs: uniqueText(paragraphs), + facts: uniqueFacts(facts).slice(0, 18), + provenance: provenanceLines(item, options.provenance), + }; +} + +function projectSection(project: Project, includePrivate: boolean): JournalSection { + const paragraphs = [`${project.template.name} · ${project.status}`]; + if (includePrivate && project.notes.trim()) paragraphs.push(project.notes.trim()); + else if (project.notes.trim()) paragraphs.push('Project notes excluded by the private-writing option.'); + return { + id: project.id, + kicker: 'Project workspace', + title: project.name, + date: project.created_at, + paragraphs, + facts: project.steps.map((step, index) => ({ + label: `Step ${index + 1} · ${step.title}`, + value: step.record_reference?.summary ?? titleCase(step.status), + })), + provenance: [], + }; +} + +function addToday(payload: Record, paragraphs: string[], facts: JournalFact[]): void { + const outputs = objectValue(payload.outputs); + const oracle = objectValue(outputs.oracle); + const card = objectValue(oracle.card); + const constraint = objectValue(outputs.constraint); + const diary = objectValue(outputs.diary); + const symphony = objectValue(outputs.symphony); + if (stringValue(constraint.text)) paragraphs.push(stringValue(constraint.text)); + pushFact(facts, 'Oracle', stringValue(card.name)); + pushFact(facts, 'Diary prompt', stringValue(diary.question)); + pushFact(facts, 'Word', stringValue(diary.word)); + pushFact(facts, 'Number', primitiveString(diary.number)); + pushFact(facts, 'Direction', stringValue(diary.direction)); + pushFact(facts, 'Symphony', primitiveString(symphony.tempo_bpm) ? `${primitiveString(symphony.tempo_bpm)} BPM` : ''); + pushFact(facts, 'Edition', primitiveString(payload.edition)); +} + +function addDiary(payload: Record, paragraphs: string[], facts: JournalFact[], includePrivate: boolean): void { + const prompt = objectValue(payload.prompt); + pushFact(facts, 'Prompt', stringValue(prompt.question) || stringValue(payload.question)); + pushFact(facts, 'Word', stringValue(prompt.word) || stringValue(payload.word)); + pushFact(facts, 'Number', primitiveString(prompt.number ?? payload.number)); + pushFact(facts, 'Direction', stringValue(prompt.direction) || stringValue(payload.direction)); + const body = stringValue(payload.body) || stringValue(payload.markdown); + if (body && includePrivate) paragraphs.push(body); + else if (body) paragraphs.push('Private diary writing excluded.'); +} + +function addPractice(payload: Record, paragraphs: string[], facts: JournalFact[], includePrivate: boolean): void { + pushFact(facts, 'Practice', stringValue(payload.plan_name)); + pushFact(facts, 'Activity', stringValue(payload.activity)); + pushFact(facts, 'Outcome', stringValue(payload.outcome)); + const reflection = stringValue(payload.reflection); + if (reflection && includePrivate) paragraphs.push(reflection); + else if (reflection) paragraphs.push('Private reflection excluded.'); +} + +function addSymphony(payload: Record, paragraphs: string[], facts: JournalFact[]): void { + const score = objectValue(payload.score); + const events = Array.isArray(score.events) ? score.events : Array.isArray(payload.events) ? payload.events : []; + pushFact(facts, 'Score', score.exact === true ? 'Exact SymphonyScore v1' : 'Approximate legacy session'); + pushFact(facts, 'Events', String(events.length)); + pushFact(facts, 'Duration', typeof score.duration_ms === 'number' ? formatDuration(score.duration_ms) : ''); + if (score.exact === false) paragraphs.push('This older session contains approximate text markers, not an exact replay score.'); +} + +function addGeneric(payload: Record, facts: JournalFact[], includePrivate: boolean): void { + const privateKeys = new Set(['body', 'markdown', 'reflection', 'notes']); + const excludedKeys = new Set(['raw', 'signature', 'signatureValue', 'certificate_pem', 'root_value']); + for (const [key, value] of Object.entries(payload)) { + if (excludedKeys.has(key) || (!includePrivate && privateKeys.has(key))) continue; + const text = printableValue(value); + if (text) facts.push({ label: titleCase(key), value: text }); + if (facts.length >= 18) break; + } +} + +function provenanceLines(item: NormalizedArchiveItem, option: JournalProvenance): string[] { + if (option === 'none') return []; + return item.provenance.map((entry) => { + const base = `${entry.source.label} · ${entry.fetched_at}`; + if (option === 'summary') return base; + const raw = entry.raw.length > 2_000 ? `${entry.raw.slice(0, 2_000)}…` : entry.raw; + return `${base}\nSource ID: ${entry.source.id}${entry.source.url ? `\nSource URL: ${entry.source.url}` : ''}\nRaw: ${raw}`; + }); +} + +function sectionTitle(item: NormalizedArchiveItem, payload: Record): string { + if (item.type === 'daily-record') return `${stringValue(payload.local_date) || item.created_at.slice(0, 10)} constellation`; + if (item.type === 'diary-entry') return stringValue(objectValue(payload.prompt).question) || item.summary; + return item.summary; +} + +function subtitleFor(count: number, project: Project | null): string { + const records = `${count} selected record${count === 1 ? '' : 's'}`; + return project ? `${project.name} · ${records}` : records; +} + +function normalizeTimestamp(value: string): string { + const time = Date.parse(value); + if (!Number.isFinite(time)) throw new TypeError('Journal creation time is invalid.'); + return new Date(time).toISOString(); +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function stringValue(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } +function primitiveString(value: unknown): string { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''; } +function printableValue(value: unknown): string { + if (typeof value === 'string') return value.length <= 500 ? value : `${value.slice(0, 500)}…`; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value) && value.length <= 12 && value.every((item) => ['string', 'number', 'boolean'].includes(typeof item))) return value.join(', '); + return ''; +} +function pushFact(facts: JournalFact[], label: string, value: string): void { if (value) facts.push({ label, value }); } +function uniqueText(values: string[]): string[] { return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; } +function uniqueFacts(values: JournalFact[]): JournalFact[] { + const seen = new Set(); + return values.filter((fact) => { + const key = `${fact.label}\0${fact.value}`; + if (!fact.value.trim() || seen.has(key)) return false; + seen.add(key); + return true; + }); +} +function titleCase(value: string): string { return value.replaceAll(/[-_]/gu, ' ').replace(/\b\w/gu, (letter) => letter.toUpperCase()); } +function formatDuration(milliseconds: number): string { + const seconds = Math.max(0, Math.round(milliseconds / 1_000)); + return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; +} diff --git a/src/domain/packs.ts b/src/domain/packs.ts new file mode 100644 index 0000000..210dc5c --- /dev/null +++ b/src/domain/packs.ts @@ -0,0 +1,120 @@ +import type { JsonValue } from './archive-record.js'; +import type { Sha256Hex } from './identifiers.js'; + +export const PACK_KINDS = [ + 'oracle-deck', + 'constraints', + 'diary-prompts', + 'lottery-presets', + 'canvas-palettes', +] as const; + +export type PackKind = typeof PACK_KINDS[number]; + +export type PackLicense = + | { type: 'spdx'; expression: string } + | { type: 'custom'; name: string; text: string }; + +export interface PackDependency { + pack_id: string; + version: string; +} + +export interface OraclePackCard { + id: string; + name: string; + meaning: string; + symbol?: string; + category?: string; + keywords?: string[]; +} + +export interface ConstraintPackItem { + id: string; + text: string; + category?: string; +} + +export interface DiaryPackItem { + id: string; + text: string; +} + +export type LotteryPackPreset = + | { id: string; name: string; tool: 'wheel' | 'name-picker' | 'shuffle'; items: string[] } + | { id: string; name: string; tool: 'number'; minimum: number; maximum: number; integer: boolean } + | { id: string; name: string; tool: 'dice'; count: number; sides: number } + | { id: string; name: string; tool: 'coin'; heads: string; tails: string }; + +export interface CanvasPackPalette { + id: string; + name: string; + colors: string[]; +} + +export type PackContent = + | { cards: OraclePackCard[] } + | { items: ConstraintPackItem[] } + | { prompts: DiaryPackItem[]; words: DiaryPackItem[] } + | { presets: LotteryPackPreset[] } + | { palettes: CanvasPackPalette[] }; + +export interface SortilunePack { + schema: 'sortilune.pack'; + schema_version: 1; + pack_id: string; + version: string; + kind: PackKind; + name: string; + description?: string; + author: { name: string }; + attribution: string; + license: PackLicense; + dependencies?: PackDependency[]; + content: PackContent; +} + +export interface PackContentReference { + id: string; + version: string; + digest: Sha256Hex; + item_id: string; + content_snapshot: JsonValue; +} + +export interface InstalledPackVersion { + pack: SortilunePack; + digest: string; + storage_key: string; + enabled: boolean; + installed_at: string; + source_name: string; +} + +export interface PackRegistryEntry { + pack_id: string; + version: string; + kind: PackKind; + name: string; + digest: string; + storage_key: string; + enabled: boolean; + installed_at: string; + source_name: string; + dependencies: PackDependency[]; +} + +export interface PackRegistryDocument { + schema: 'sortilune.pack-registry'; + schema_version: 1; + updated_at: string; + entries: PackRegistryEntry[]; +} + +export type PackEffectiveStatus = + | 'installed' + | 'update-available' + | 'disabled' + | 'missing-dependency' + | 'invalid' + | 'historical'; diff --git a/src/domain/practices.ts b/src/domain/practices.ts new file mode 100644 index 0000000..e047408 --- /dev/null +++ b/src/domain/practices.ts @@ -0,0 +1,98 @@ +import { createStableId, nowRfc3339, type Rfc3339Timestamp, type StableId } from './identifiers.js'; + +export type PracticeStatus = 'active' | 'paused' | 'stopped'; + +export interface PracticeActivity { + id: StableId; + title: string; +} + +export interface PracticePlan { + id: StableId; + name: string; + status: PracticeStatus; + activities: PracticeActivity[]; + eligible_weekdays: number[]; + created_at: Rfc3339Timestamp; + updated_at: Rfc3339Timestamp; +} + +export interface PracticeStore { + schema: 'sortilune.practice-store'; + schema_version: 1; + updated_at: Rfc3339Timestamp; + plans: PracticePlan[]; +} + +export interface PracticeAssignment { + plan_id: StableId; + plan_name: string; + activity_id: StableId; + activity: string; + local_date: string; + algorithm: 'sortilune.practice-assignment/v1'; +} + +export interface PracticeDraft { + name: string; + activities: string[]; + eligible_weekdays: number[]; +} + +export function emptyPracticeStore(timestamp = nowRfc3339()): PracticeStore { + return { schema: 'sortilune.practice-store', schema_version: 1, updated_at: timestamp, plans: [] }; +} + +export function createPracticePlan(draft: PracticeDraft, timestamp = nowRfc3339()): PracticePlan { + const normalized = normalizePracticeDraft(draft); + return { + id: createStableId(), + name: normalized.name, + status: 'active', + activities: normalized.activities.map((title) => ({ id: createStableId(), title })), + eligible_weekdays: normalized.eligible_weekdays, + created_at: timestamp, + updated_at: timestamp, + }; +} + +export function normalizePracticeDraft(draft: PracticeDraft): PracticeDraft { + const name = draft.name.normalize('NFC').trim().replace(/\s+/gu, ' '); + if (!name || name.length > 120) throw new TypeError('Practice name must contain 1 to 120 characters.'); + const activities = [...new Set(draft.activities.map((item) => item.normalize('NFC').trim().replace(/\s+/gu, ' ')).filter(Boolean))]; + if (!activities.length || activities.length > 24 || activities.some((item) => item.length > 240)) { + throw new TypeError('Add 1 to 24 distinct activities, each no longer than 240 characters.'); + } + const eligible = [...new Set(draft.eligible_weekdays.map((value) => Math.trunc(value)))].sort((a, b) => a - b); + if (!eligible.length || eligible.some((value) => value < 0 || value > 6)) throw new TypeError('Choose at least one eligible weekday.'); + return { name, activities, eligible_weekdays: eligible }; +} + +export function assignmentForDate(plan: PracticePlan, localDate: string): PracticeAssignment | null { + if (plan.status !== 'active' || !/^\d{4}-\d{2}-\d{2}$/u.test(localDate)) return null; + const weekday = new Date(`${localDate}T12:00:00Z`).getUTCDay(); + if (!plan.eligible_weekdays.includes(weekday) || !plan.activities.length) return null; + const index = hash32(`${plan.id}\0${localDate}\0sortilune.practice-assignment/v1`) % plan.activities.length; + const activity = plan.activities[index]!; + return { + plan_id: plan.id, + plan_name: plan.name, + activity_id: activity.id, + activity: activity.title, + local_date: localDate, + algorithm: 'sortilune.practice-assignment/v1', + }; +} + +export function clonePracticeStore(store: PracticeStore): PracticeStore { + return structuredClone(store); +} + +function hash32(value: string): number { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(value)) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash; +} diff --git a/src/domain/projects.ts b/src/domain/projects.ts new file mode 100644 index 0000000..ba5c4f9 --- /dev/null +++ b/src/domain/projects.ts @@ -0,0 +1,217 @@ +import type { Relation } from './archive-record.js'; +import type { Rfc3339Timestamp, StableId } from './identifiers.js'; +import { createStableId, nowRfc3339, parseStableId } from './identifiers.js'; +import type { RouteReference } from './settings.js'; +import type { SavedArchiveRecord } from '../archive/repository.js'; + +export type ProjectStatus = 'active' | 'completed' | 'closed'; +export type ProjectStepStatus = 'pending' | 'current' | 'completed' | 'skipped'; +export type ProjectDestination = 'today' | 'oracle' | 'constraint' | 'canvas' | 'decider' | 'diary'; + +export interface ProjectDestinationOption { + destination: ProjectDestination; + label: string; + description: string; +} + +export interface ProjectTemplateStep { + id: string; + title: string; + description: string; + destination: ProjectDestination | null; + destination_options: ProjectDestinationOption[]; +} + +export interface ProjectTemplate { + id: string; + version: number; + name: string; + description: string; + steps: ProjectTemplateStep[]; +} + +export interface ProjectRecordReference { + id: StableId; + path: string; + chamber: string; + type: string; + summary: string; + created_at: Rfc3339Timestamp; +} + +export interface ProjectStep extends ProjectTemplateStep { + selected_destination: ProjectDestination | null; + attempt: number; + status: ProjectStepStatus; + note: string; + record_reference: ProjectRecordReference | null; + completed_at: Rfc3339Timestamp | null; + skipped_at: Rfc3339Timestamp | null; +} + +export interface Project { + schema: 'sortilune.project'; + schema_version: 1; + id: StableId; + name: string; + status: ProjectStatus; + created_at: Rfc3339Timestamp; + updated_at: Rfc3339Timestamp; + completed_at: Rfc3339Timestamp | null; + closed_at: Rfc3339Timestamp | null; + current_step_id: string | null; + notes: string; + template: ProjectTemplate; + steps: ProjectStep[]; +} + +export interface ProjectContext { + project_id: StableId; + project_step_id: string; + project_step_attempt: number; +} + +export interface ProjectHandoffModel { + schema: 'sortilune.project-handoff'; + schema_version: 1; + project_id: StableId; + name: string; + status: ProjectStatus; + template: ProjectTemplate; + notes: string; + records: Array; +} + +const PROJECT_STEP_ID_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; + +export function createProject(template: ProjectTemplate, name: string, timestamp = nowRfc3339()): Project { + const normalizedName = normalizeProjectName(name); + const templateSnapshot = cloneTemplate(template); + const steps = templateSnapshot.steps.map((step, index): ProjectStep => ({ + ...clone(step), + selected_destination: step.destination_options.length ? null : step.destination, + attempt: 1, + status: index === 0 ? 'current' : 'pending', + note: '', + record_reference: null, + completed_at: null, + skipped_at: null, + })); + return { + schema: 'sortilune.project', + schema_version: 1, + id: createStableId(), + name: normalizedName, + status: 'active', + created_at: timestamp, + updated_at: timestamp, + completed_at: null, + closed_at: null, + current_step_id: steps[0]!.id, + notes: '', + template: templateSnapshot, + steps, + }; +} + +export function duplicateProject(source: Project, timestamp = nowRfc3339()): Project { + const copy = createProject(source.template, `${source.name} copy`, timestamp); + copy.notes = source.notes; + copy.steps = copy.steps.map((step, index) => ({ + ...step, + note: source.steps[index]?.note ?? '', + selected_destination: source.steps[index]?.selected_destination ?? step.selected_destination, + })); + return copy; +} + +export function normalizeProjectName(name: string): string { + const value = String(name ?? '').trim().replace(/\s+/gu, ' '); + if (!value || value.length > 120) throw new TypeError('project name must contain 1 to 120 characters'); + return value; +} + +export function normalizeProjectNote(note: string, maximum = 20_000): string { + const value = String(note ?? '').replace(/\r\n?/gu, '\n'); + if (value.length > maximum) throw new TypeError(`project note must contain no more than ${maximum.toLocaleString()} characters`); + return value; +} + +export function projectContextFromRoute(route?: RouteReference): ProjectContext | null { + const projectId = route?.params.project_id; + const stepId = route?.params.project_step_id; + const attempt = Number(route?.params.project_step_attempt); + if (!projectId || !stepId || !PROJECT_STEP_ID_PATTERN.test(stepId) + || !Number.isInteger(attempt) || attempt < 1 || attempt > 9999) return null; + try { + return { project_id: parseStableId(projectId), project_step_id: stepId, project_step_attempt: attempt }; + } catch { + return null; + } +} + +export function projectRelation(context: ProjectContext, templateId?: string): Relation { + return { + kind: 'project', + target_id: context.project_id, + target_schema: 'sortilune.project', + metadata: { + step_id: context.project_step_id, + step_attempt: context.project_step_attempt, + ...(templateId ? { template_id: templateId } : {}), + }, + }; +} + +export function relationProjectContext(relation: Relation): ProjectContext | null { + if (relation.kind !== 'project' || !relation.metadata || typeof relation.metadata !== 'object' || Array.isArray(relation.metadata)) return null; + const stepId = relation.metadata.step_id; + const attempt = relation.metadata.step_attempt; + if (typeof stepId !== 'string' || !PROJECT_STEP_ID_PATTERN.test(stepId) + || typeof attempt !== 'number' || !Number.isInteger(attempt) || attempt < 1 || attempt > 9999) return null; + try { + return { project_id: parseStableId(relation.target_id), project_step_id: stepId, project_step_attempt: attempt }; + } catch { + return null; + } +} + +export function projectRecordReference(saved: SavedArchiveRecord): ProjectRecordReference { + return { + id: saved.record.id, + path: saved.path, + chamber: saved.record.chamber, + type: saved.record.type, + summary: saved.record.summary, + created_at: saved.record.created_at, + }; +} + +export function projectHandoff(project: Project): ProjectHandoffModel { + return { + schema: 'sortilune.project-handoff', + schema_version: 1, + project_id: project.id, + name: project.name, + status: project.status, + template: cloneTemplate(project.template), + notes: project.notes, + records: project.steps.flatMap((step) => step.record_reference ? [{ + ...clone(step.record_reference), + step_id: step.id, + step_title: step.title, + }] : []), + }; +} + +export function cloneProject(project: Project): Project { + return clone(project); +} + +export function cloneTemplate(template: ProjectTemplate): ProjectTemplate { + return clone(template); +} + +function clone(value: T): T { + return structuredClone(value); +} diff --git a/src/domain/receipts.ts b/src/domain/receipts.ts new file mode 100644 index 0000000..52b64f9 --- /dev/null +++ b/src/domain/receipts.ts @@ -0,0 +1,29 @@ +import type { JsonValue } from './archive-record.js'; +import type { Rfc3339Timestamp, Sha256Hex } from './identifiers.js'; + +export type ReceiptSourceKind = 'archive-record' | 'project-summary'; + +export interface PortableReceipt { + schema: 'sortilune.receipt'; + schema_version: 1; + kind: 'result'; + created_at: Rfc3339Timestamp; + source: { + kind: ReceiptSourceKind; + id: string; + title: string; + }; + content: JsonValue; + limitations: string[]; + integrity: { + algorithm: 'SHA-256'; + canonicalization: 'sortilune.canonical-json/v1'; + value: Sha256Hex; + }; +} + +export type ReceiptVerification = + | { status: 'valid'; receipt: PortableReceipt; expected: string; actual: string } + | { status: 'changed'; receipt: PortableReceipt; expected: string; actual: string } + | { status: 'unsupported'; reason: string } + | { status: 'invalid'; reason: string }; diff --git a/src/domain/settings.ts b/src/domain/settings.ts new file mode 100644 index 0000000..b4cab80 --- /dev/null +++ b/src/domain/settings.ts @@ -0,0 +1,33 @@ +export type ThemeId = 'cosmic-dark' | 'cosmic-light' | 'high-contrast'; +export type MotionPreference = 'system' | 'reduce' | 'allow'; +export type RailMode = 'auto' | 'expanded' | 'compact'; + +export interface RouteReference { + destination: string; + params: Record; +} + +export interface SettingsV2 { + schema: 'sortilune.settings'; + schema_version: 2; + route: RouteReference; + navigation?: { + rail_mode: RailMode; + }; + theme: ThemeId; + entropy: { + preferred_source: string; + enabled_sources: Record; + }; + visual: { + starfield: boolean; + reduce_motion: MotionPreference; + }; + archive?: { + search_diary_body: boolean; + on_this_day: boolean; + }; + chambers: { + last_used_deck: 'tarot' | 'i-ching' | 'runes' | 'cosmic'; + }; +} diff --git a/src/domain/text.ts b/src/domain/text.ts new file mode 100644 index 0000000..332d4cb --- /dev/null +++ b/src/domain/text.ts @@ -0,0 +1,26 @@ +const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/; + +export interface ImportedTextOptions { + maxCodeUnits?: number; + label?: string; +} + +/** Normalize untrusted imported user-facing text at its documented boundary. */ +export function normalizeImportedText(value: unknown, options: ImportedTextOptions = {}): string { + const label = options.label ?? 'imported text'; + if (typeof value !== 'string') throw new TypeError(`${label} must be a string`); + if (LONE_SURROGATE.test(value)) throw new TypeError(`${label} contains an unpaired Unicode surrogate`); + const normalized = value.normalize('NFC'); + const maximum = options.maxCodeUnits ?? 1_000_000; + if (!Number.isSafeInteger(maximum) || maximum < 0) { + throw new RangeError('maxCodeUnits must be a non-negative safe integer'); + } + if (normalized.length > maximum) throw new RangeError(`${label} exceeds ${maximum} UTF-16 code units`); + return normalized; +} + +/** Existing stored text is returned byte-semantically unchanged after UTF-8 decoding. */ +export function preserveStoredText(value: unknown, label = 'stored text'): string { + if (typeof value !== 'string') throw new TypeError(`${label} must be a string`); + return value; +} diff --git a/src/features/journal/index.ts b/src/features/journal/index.ts new file mode 100644 index 0000000..703fde0 --- /dev/null +++ b/src/features/journal/index.ts @@ -0,0 +1,267 @@ +import type { NormalizedArchiveItem } from '../../archive/repository.js'; +import type { AppContext } from '../../domain/contracts.js'; +import { createJournalDocument, type JournalOptions } from '../../domain/journal.js'; +import type { Project } from '../../domain/projects.js'; +import type { RouteReference } from '../../domain/settings.js'; +import { clear, h } from '../../lib/dom.js'; +import { journalFilename, renderJournalHtml } from '../../journal/html.js'; +import { ActionBar, EmptyState, ErrorState, Skeleton } from '../../ui/primitives.js'; +import { toast } from '../../chambers/lottery/_shared.js'; + +export const id = 'journal'; + +let rootElement: HTMLElement | null = null; +let context: AppContext | null = null; +let records: NormalizedArchiveItem[] = []; +let project: Project | null = null; +let selectedIds = new Set(); +let loading = true; +let error = ''; +let options: JournalOptions = defaultOptions(); +let revealNext = true; + +export async function mount(root: HTMLElement, appContext: AppContext, route?: RouteReference): Promise { + rootElement = root; + context = appContext; + records = []; + project = null; + selectedIds = new Set(); + loading = true; + error = ''; + options = defaultOptions(); + revealNext = true; + render(); + try { + const items = await appContext.archive.list(); + records = items.filter((item): item is NormalizedArchiveItem => item.status === 'ok') + .sort((left, right) => right.created_at.localeCompare(left.created_at)); + const projectId = route?.params.project_id; + project = projectId ? appContext.projects.repository.get(projectId) ?? null : null; + if (project) { + options = { ...options, title: project.name }; + for (const item of records) { + if (item.relations.some((relation) => relation.kind === 'project' && relation.target_id === project?.id)) selectedIds.add(item.id); + } + } + const recordId = route?.params.record_id; + if (recordId && records.some((item) => item.id === recordId)) selectedIds.add(recordId); + applyArchiveHandoff(); + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } + loading = false; + render(); +} + +export function unmount(): void { + rootElement = null; + context = null; + records = []; + project = null; + selectedIds = new Set(); + revealNext = true; +} + +function render(): void { + if (!rootElement) return; + clear(rootElement); + const selected = selectedRecords(); + let document; + try { + document = createJournalDocument({ records: selected, options, project }); + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } + const html = document ? renderJournalHtml(document) : ''; + const frame = h('div', { class: `chamber-frame journal-frame${revealNext ? ' reveal' : ''}` }, [ + h('header', { class: 'journal-header' }, [ + h('div', null, [ + h('div', { class: 'chamber-id' }, ['Memory · printable journal']), + h('h1', { class: 'chamber-title-big', 'data-page-title': 'true', tabindex: -1 }, ['Journal Export']), + h('p', { class: 'chamber-tagline' }, ['A private preview for offline HTML and Windows Print / Save as PDF.']), + ]), + h('span', { class: 'journal-selection-count mono' }, [`${selected.length} selected`]), + ]), + loading ? h('div', { class: 'journal-loading panel' }, [Skeleton(6)]) + : error ? ErrorState('Journal export could not load', error, ActionBar({ + label: 'Journal error actions', + primary: [button('Try again', () => context?.navigate('journal'), 'btn btn-primary')], + })) + : h('div', { class: 'journal-layout' }, [ + buildControls(document, html), + buildPreview(document, html), + ]), + ]); + rootElement.append(frame); + revealNext = false; +} + +function buildControls(document: ReturnType | undefined, html: string): HTMLElement { + const shown = visibleRecords(); + return h('aside', { class: 'journal-controls panel', 'aria-label': 'Journal export options' }, [ + h('section', { class: 'journal-options' }, [ + h('div', { class: 'label' }, ['Document']), + h('label', null, [ + h('span', { class: 'small muted' }, ['Title']), + h('input', { + class: 'input', maxlength: 160, value: options.title, + onchange: (event: Event) => updateOption('title', (event.currentTarget as HTMLInputElement).value), + }), + ]), + optionCheckbox('Include cover page', 'cover'), + optionCheckbox('Include private writing', 'include_private_writing', 'Diary bodies, practice reflections, and project notes are excluded by default.'), + selectOption('Theme', 'theme', [['paper', 'Paper'], ['midnight', 'Midnight']]), + selectOption('Order', 'order', [['oldest', 'Oldest first'], ['newest', 'Newest first']]), + selectOption('Source notes', 'provenance', [['none', 'None'], ['summary', 'Summary'], ['full', 'Full']]), + ]), + h('section', { class: 'journal-record-picker', 'aria-labelledby': 'journal-records-title' }, [ + h('div', { class: 'spread' }, [ + h('div', { class: 'label', id: 'journal-records-title' }, ['Archive records']), + h('span', { class: 'small muted' }, [`${records.length} available`]), + ]), + h('div', { class: 'journal-picker-actions' }, [ + button('Latest 5', selectLatest, 'btn btn-small'), + button('Clear', clearSelection, 'btn btn-small btn-ghost'), + ]), + records.length ? h('ul', { class: 'journal-record-list' }, shown.map(recordChoice)) + : EmptyState('Archive is empty', 'Save a result, daily constellation, practice, or project step first.'), + records.length > shown.length ? h('p', { class: 'small muted' }, [`Showing the newest ${shown.length} records plus any prepared selection.`]) : null, + ]), + ActionBar({ + label: 'Journal export actions', + primary: [button('Download HTML', () => downloadHtml(document, html), 'btn btn-primary', !document?.sections.length)], + secondary: [button('Print / Save as PDF', printPreview, 'btn', !document?.sections.length)], + }), + h('p', { class: 'small muted journal-pdf-note' }, ['The PDF button opens the Windows print dialog; choose Microsoft Print to PDF. No custom PDF engine is bundled.']), + ]); +} + +function buildPreview(document: ReturnType | undefined, html: string): HTMLElement { + return h('section', { class: 'journal-preview', 'aria-labelledby': 'journal-preview-title' }, [ + h('div', { class: 'journal-preview-heading' }, [ + h('div', null, [h('div', { class: 'label' }, ['Preview']), h('h2', { id: 'journal-preview-title' }, [document?.title ?? 'Nothing selected'])]), + document ? h('span', { class: 'badge' }, [`${document.sections.length} section${document.sections.length === 1 ? '' : 's'}`]) : null, + ]), + document?.sections.length + ? h('iframe', { + id: 'journal-preview-frame', + class: 'journal-preview-frame', + title: 'Printable journal preview', + srcdoc: html, + sandbox: 'allow-same-origin allow-modals', + }) + : EmptyState('Choose records to preview', 'Select Archive records on the left, or prepare a selection from Archive, Today, or Projects.'), + ]); +} + +function recordChoice(item: NormalizedArchiveItem): HTMLElement { + return h('li', null, [h('label', { class: 'journal-record-choice' }, [ + h('input', { + type: 'checkbox', checked: selectedIds.has(item.id), + onchange: (event: Event) => { + if ((event.currentTarget as HTMLInputElement).checked) selectedIds.add(item.id); + else selectedIds.delete(item.id); + render(); + }, + }), + h('span', null, [ + h('strong', null, [item.summary]), + h('span', { class: 'small muted' }, [`${titleCase(item.chamber)} · ${formatDate(item.created_at)}`]), + ]), + ])]); +} + +function optionCheckbox(label: string, key: 'cover' | 'include_private_writing', help = ''): HTMLElement { + return h('label', { class: 'journal-check-option' }, [ + h('input', { + type: 'checkbox', checked: options[key], + onchange: (event: Event) => updateOption(key, (event.currentTarget as HTMLInputElement).checked), + }), + h('span', null, [h('strong', null, [label]), help ? h('span', { class: 'small muted' }, [help]) : null]), + ]); +} + +function selectOption(label: string, key: K, values: Array<[JournalOptions[K], string]>): HTMLElement { + return h('label', null, [ + h('span', { class: 'small muted' }, [label]), + h('select', { + class: 'select', + onchange: (event: Event) => updateOption(key, (event.currentTarget as HTMLSelectElement).value as JournalOptions[K]), + }, values.map(([value, copy]) => h('option', { value, selected: value === options[key] }, [copy]))), + ]); +} + +function updateOption(key: K, value: JournalOptions[K]): void { + if (key === 'title' && !String(value).trim()) { + toast('Journal title cannot be empty.', 'danger'); + render(); + return; + } + options = { ...options, [key]: value }; + error = ''; + render(); +} + +function selectedRecords(): NormalizedArchiveItem[] { return records.filter((item) => selectedIds.has(item.id)); } + +function visibleRecords(): NormalizedArchiveItem[] { + const visible = records.slice(0, 80); + const ids = new Set(visible.map((item) => item.id)); + for (const item of records) if (selectedIds.has(item.id) && !ids.has(item.id)) visible.push(item); + return visible; +} + +function selectLatest(): void { + for (const item of records.slice(0, 5)) selectedIds.add(item.id); + render(); +} + +function clearSelection(): void { selectedIds.clear(); render(); } + +function applyArchiveHandoff(): void { + try { + const source = sessionStorage.getItem('sortilune.export-selection.v1'); + if (!source) return; + const handoff = JSON.parse(source) as { schema?: string; records?: Array<{ path?: string; id?: string | null }> }; + if (handoff.schema !== 'sortilune.export-selection-handoff' || !Array.isArray(handoff.records)) return; + for (const reference of handoff.records.slice(0, 500)) { + const match = records.find((item) => item.path === reference.path || item.id === reference.id); + if (match) selectedIds.add(match.id); + } + sessionStorage.removeItem('sortilune.export-selection.v1'); + } catch { + sessionStorage.removeItem('sortilune.export-selection.v1'); + } +} + +function downloadHtml(document: ReturnType | undefined, html: string): void { + if (!document?.sections.length) return; + const url = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' })); + const anchor = window.document.createElement('a'); + anchor.href = url; + anchor.download = journalFilename(document); + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + toast('Journal HTML downloaded.', 'success'); +} + +function printPreview(): void { + const frame = document.getElementById('journal-preview-frame') as HTMLIFrameElement | null; + if (!frame?.contentWindow) return; + frame.contentWindow.focus(); + frame.contentWindow.print(); +} + +function button(label: string, action: (() => void | Promise) | undefined, className = 'btn', disabled = false): HTMLButtonElement { + return h('button', { class: className, type: 'button', disabled, onclick: action }, [label]) as HTMLButtonElement; +} + +function defaultOptions(): JournalOptions { + return { title: 'Sortilune Journal', cover: true, theme: 'paper', order: 'oldest', provenance: 'summary', include_private_writing: false }; +} + +function titleCase(value: string): string { return value.replaceAll(/[-_]/gu, ' ').replace(/\b\w/gu, (letter) => letter.toUpperCase()); } +function formatDate(value: string): string { + const date = new Date(value); + return Number.isFinite(date.getTime()) ? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(date) : value; +} diff --git a/src/features/practices/index.ts b/src/features/practices/index.ts new file mode 100644 index 0000000..a7cee74 --- /dev/null +++ b/src/features/practices/index.ts @@ -0,0 +1,261 @@ +import type { NormalizedArchiveItem } from '../../archive/repository.js'; +import type { AppContext } from '../../domain/contracts.js'; +import { assignmentForDate, type PracticeAssignment, type PracticeDraft, type PracticePlan } from '../../domain/practices.js'; +import { nowRfc3339 } from '../../domain/identifiers.js'; +import { currentLocalDate } from '../today/time.js'; +import { clear, h } from '../../lib/dom.js'; +import { toast } from '../../lib/notify.js'; +import { PRACTICE_STARTERS } from '../../practices/starters.js'; +import { ActionBar, Dialog, EmptyState, ErrorState, Skeleton, type DialogController } from '../../ui/primitives.js'; + +export const id = 'practices'; + +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +let rootElement: HTMLElement | null = null; +let context: AppContext | null = null; +let unsubscribe: (() => void) | null = null; +let activeDialog: DialogController | null = null; +let history: NormalizedArchiveItem[] = []; +let loadingHistory = false; +let busy = false; + +export async function mount(root: HTMLElement, appContext: AppContext): Promise { + rootElement = root; + context = appContext; + unsubscribe?.(); + unsubscribe = appContext.practices.subscribe(() => render()); + render(); + await refreshHistory(); +} + +export function unmount(): void { + unsubscribe?.(); + unsubscribe = null; + activeDialog?.close(); + activeDialog = null; + rootElement = null; + context = null; + history = []; + busy = false; +} + +function render(): void { + if (!rootElement || !context) return; + clear(rootElement); + const snapshot = context.practices.snapshot; + const body: HTMLElement[] = []; + if (!snapshot.available) { + body.push(ErrorState('Practices need the desktop app', 'Plans are stored locally beside the rest of your Sortilune data.')); + } else if (snapshot.loading) body.push(Skeleton(4)); + else if (snapshot.error) body.push(ErrorState('Practice plans could not be read', snapshot.error)); + else { + body.push(starterSection()); + body.push(planSection(snapshot.store.plans)); + } + rootElement.append(h('div', { class: 'chamber-frame practices-frame reveal' }, [ + h('header', { class: 'practices-header' }, [ + h('div', null, [ + h('div', { class: 'chamber-id' }, ['Workspace · Practices']), + h('h1', { class: 'chamber-title-big', 'data-page-title': 'true', tabindex: -1 }, ['Practices']), + h('p', { class: 'chamber-tagline' }, ['Optional creative prompts with rest, skip, pause, and stop built in.']), + ]), + actionButton('New custom plan', () => openPlanDialog(), 'btn btn-primary', busy || !snapshot.available), + ]), + h('div', { class: 'practices-body' }, body), + ])); +} + +function starterSection(): HTMLElement { + return h('section', { class: 'practice-starters', 'aria-labelledby': 'practice-starters-title' }, [ + h('div', { class: 'practice-section-heading' }, [ + h('div', null, [h('span', { class: 'practice-kicker' }, ['Start gently']), h('h2', { id: 'practice-starters-title' }, ['Choose a small rhythm'])]), + h('p', { class: 'muted' }, ['These are creative suggestions, not treatment plans or performance targets.']), + ]), + h('div', { class: 'practice-starter-grid' }, PRACTICE_STARTERS.map((starter) => h('article', { class: 'practice-starter-card' }, [ + h('span', { class: 'practice-starter-mark', 'aria-hidden': 'true' }, ['◌']), + h('h3', null, [starter.name]), + h('p', { class: 'muted' }, [starter.description]), + h('p', { class: 'small' }, [`${starter.activities.length} prompts · ${starter.eligible_weekdays.length === 7 ? 'every day eligible' : 'weekdays'}`]), + actionButton(`Use ${starter.name}`, () => createPlan(starter), 'btn', busy), + ]))), + ]); +} + +function planSection(plans: PracticePlan[]): HTMLElement { + return h('section', { class: 'practice-plans', 'aria-labelledby': 'practice-plans-title' }, [ + h('div', { class: 'practice-section-heading' }, [ + h('div', null, [h('span', { class: 'practice-kicker' }, ['Your plans']), h('h2', { id: 'practice-plans-title' }, ['Today’s invitations'])]), + loadingHistory ? h('span', { class: 'small muted', role: 'status' }, ['Reading history…']) : null, + ]), + plans.length ? h('div', { class: 'practice-plan-grid' }, plans.map(planCard)) + : EmptyState('No practice plans yet', 'Choose a starter or make a custom plan. Nothing counts against you when you skip or stop.'), + ]); +} + +function planCard(plan: PracticePlan): HTMLElement { + const date = currentLocalDate(); + const assignment = assignmentForDate(plan, date); + const outcome = history.find((item) => objectValue(item.payload).plan_id === plan.id && objectValue(item.payload).local_date === date); + return h('article', { class: `practice-plan-card panel is-${plan.status}` }, [ + h('div', { class: 'spread' }, [ + h('div', null, [h('span', { class: 'practice-status' }, [statusLabel(plan.status)]), h('h3', null, [plan.name])]), + h('span', { class: 'small muted mono' }, [plan.eligible_weekdays.map((day) => WEEKDAYS[day]).join(' ')]), + ]), + outcome ? h('div', { class: 'practice-assignment is-recorded' }, [ + h('span', { class: 'practice-kicker' }, [outcome.type === 'practice-completion' ? 'Completed today' : 'Skipped today']), + h('strong', null, [String(objectValue(outcome.payload).activity || outcome.summary)]), + objectValue(outcome.payload).reflection ? h('p', { class: 'muted' }, [String(objectValue(outcome.payload).reflection)]) : null, + ]) : assignment ? h('div', { class: 'practice-assignment' }, [ + h('span', { class: 'practice-kicker' }, ['Today']), + h('strong', null, [assignment.activity]), + h('p', { class: 'small muted' }, ['Do it, adapt it, skip it, or ignore it. There is no streak.']), + ActionBar({ + label: `${plan.name} assignment actions`, + primary: [actionButton('Mark complete', () => openOutcomeDialog(plan, assignment, 'completed'), 'btn btn-primary', busy)], + secondary: [actionButton('Skip today', () => openOutcomeDialog(plan, assignment, 'skipped'), 'btn btn-ghost', busy)], + }), + ]) : h('p', { class: 'practice-rest muted' }, [plan.status === 'active' ? 'Rest day · no prompt scheduled.' : `${statusLabel(plan.status)} · no prompt scheduled.`]), + h('div', { class: 'practice-plan-actions' }, [ + actionButton('Edit', () => openPlanDialog(plan), 'btn btn-small', busy), + plan.status === 'active' + ? actionButton('Pause', () => setStatus(plan, 'paused'), 'btn btn-small', busy) + : actionButton('Resume', () => setStatus(plan, 'active'), 'btn btn-small', busy), + plan.status !== 'stopped' ? actionButton('Stop', () => setStatus(plan, 'stopped'), 'btn btn-small btn-ghost', busy) : null, + plan.status === 'stopped' ? actionButton('Delete plan', () => deletePlan(plan), 'btn btn-small btn-danger', busy) : null, + ]), + ]); +} + +function openPlanDialog(plan?: PracticePlan): void { + if (!context || busy) return; + const name = h('input', { class: 'input', maxlength: 120, value: plan?.name ?? '', placeholder: 'Plan name' }) as HTMLInputElement; + const activities = h('textarea', { + class: 'textarea', rows: 7, maxlength: 6000, + value: plan?.activities.map((activity) => activity.title).join('\n') ?? '', + placeholder: 'One optional activity per line', + }) as HTMLTextAreaElement; + const selected = new Set(plan?.eligible_weekdays ?? [1, 2, 3, 4, 5]); + const checks = WEEKDAYS.map((day, index) => h('label', { class: 'practice-day-check' }, [ + h('input', { type: 'checkbox', value: index, checked: selected.has(index) ? true : null }), h('span', null, [day]), + ])); + const save = actionButton(plan ? 'Save plan' : 'Create plan', async () => { + if (!context || busy) return; + const draft = { + name: name.value, + activities: activities.value.split(/\r?\n/u), + eligible_weekdays: checks.filter((label) => (label.querySelector('input') as HTMLInputElement).checked) + .map((label) => Number((label.querySelector('input') as HTMLInputElement).value)), + }; + busy = true; + save.disabled = true; + try { + if (plan) await context.practices.update(plan.id, draft); + else await context.practices.create(draft); + activeDialog?.close(); + toast(plan ? 'Practice plan updated.' : 'Practice plan created.', 'success'); + } catch (error) { + toast(messageOf(error), 'danger'); + save.disabled = false; + name.focus(); + } finally { busy = false; render(); } + }, 'btn btn-primary'); + activeDialog?.close(); + activeDialog = Dialog({ + title: plan ? `Edit ${plan.name}` : 'New custom practice', + initialFocus: name, + content: [ + h('p', { class: 'muted small' }, ['Use ordinary creative, observation, reading/listening, or screen-break activities. Plans are optional prompts, not health advice.']), + h('label', null, [h('span', { class: 'label' }, ['Plan name']), name]), + h('label', null, [h('span', { class: 'label' }, ['Activities · one per line']), activities]), + h('fieldset', { class: 'practice-days' }, [h('legend', { class: 'label' }, ['Eligible days']), ...checks]), + ActionBar({ label: 'Plan editor actions', primary: [save], secondary: [actionButton('Cancel', () => activeDialog?.close(), 'btn')] }), + ], + }); + activeDialog.open(); +} + +function openOutcomeDialog(plan: PracticePlan, assignment: PracticeAssignment, outcome: 'completed' | 'skipped'): void { + const reflection = h('textarea', { class: 'textarea', rows: 4, maxlength: 2000, placeholder: 'Optional note or reflection' }) as HTMLTextAreaElement; + const save = actionButton(outcome === 'completed' ? 'Save completion' : 'Save skip', async () => { + save.disabled = true; + try { + await recordOutcome(plan, assignment, outcome, reflection.value); + activeDialog?.close(); + } catch (error) { + toast(messageOf(error), 'danger'); + save.disabled = false; + } + }, 'btn btn-primary'); + activeDialog?.close(); + activeDialog = Dialog({ + title: outcome === 'completed' ? 'Mark complete' : 'Skip today', + initialFocus: reflection, + content: [ + h('p', null, [assignment.activity]), + h('p', { class: 'small muted' }, [outcome === 'completed' ? 'A descriptive record will be added to Archive.' : 'Skipping is neutral and does not break anything.']), + h('label', null, [h('span', { class: 'label' }, ['Optional reflection']), reflection]), + ActionBar({ label: 'Practice outcome actions', primary: [save], secondary: [actionButton('Cancel', () => activeDialog?.close(), 'btn')] }), + ], + }); + activeDialog.open(); +} + +async function recordOutcome(plan: PracticePlan, assignment: PracticeAssignment, outcome: 'completed' | 'skipped', reflection: string): Promise { + if (!context) return; + const at = nowRfc3339(); + await context.archive.save({ + chamber: 'practice', + type: outcome === 'completed' ? 'practice-completion' : 'practice-skip', + summary: `${outcome === 'completed' ? 'Completed' : 'Skipped'} ${plan.name}: ${assignment.activity}`, + payload: { ...assignment, plan_id: plan.id, activity: assignment.activity, outcome, reflection: reflection.trim() }, + provenance: { source: { id: 'practice-plan', label: 'Local practice plan', kind: 'system' }, fetched_at: at, raw: `${plan.id}:${assignment.local_date}:v1` }, + relations: [{ kind: 'practice', target_id: plan.id, target_schema: 'sortilune.practice-plan', metadata: { plan_name: plan.name } }], + algorithm: { id: 'sortilune.practice-assignment', version: 1, parameters: { local_date: assignment.local_date } }, + }); + await refreshHistory(); + toast(outcome === 'completed' ? 'Practice completion saved.' : 'Practice skip saved without penalty.', 'success'); +} + +async function createPlan(draft: PracticeDraft): Promise { + if (!context || busy) return; + busy = true; + render(); + try { await context.practices.create(draft); toast(`${draft.name} added.`, 'success'); } + catch (error) { toast(messageOf(error), 'danger'); } + finally { busy = false; render(); } +} + +async function setStatus(plan: PracticePlan, status: PracticePlan['status']): Promise { + if (!context || busy) return; + busy = true; + try { await context.practices.setStatus(plan.id, status); toast(`${plan.name} is ${status}.`, 'success'); } + catch (error) { toast(messageOf(error), 'danger'); } + finally { busy = false; render(); } +} + +async function deletePlan(plan: PracticePlan): Promise { + if (!context || busy) return; + busy = true; + try { await context.practices.delete(plan.id); toast(`${plan.name} removed. Its Archive history remains.`, 'success'); } + catch (error) { toast(messageOf(error), 'danger'); } + finally { busy = false; render(); } +} + +async function refreshHistory(): Promise { + if (!context) return; + loadingHistory = true; + render(); + try { + const items = await context.archive.listChamber('practice'); + history = items.filter((item): item is NormalizedArchiveItem => item.status === 'ok'); + } catch (error) { + toast(`Practice history is unavailable: ${messageOf(error)}`, 'danger'); + } finally { loadingHistory = false; render(); } +} + +function actionButton(label: string, action: (() => void | Promise) | undefined, className = 'btn', disabled = false): HTMLButtonElement { + return h('button', { class: className, type: 'button', disabled, onclick: action }, [label]) as HTMLButtonElement; +} +function objectValue(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } +function statusLabel(status: PracticePlan['status']): string { return status === 'active' ? 'Active' : status === 'paused' ? 'Paused' : 'Stopped'; } +function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/features/projects/index.ts b/src/features/projects/index.ts new file mode 100644 index 0000000..48e2978 --- /dev/null +++ b/src/features/projects/index.ts @@ -0,0 +1,600 @@ +import type { AppContext } from '../../domain/contracts.js'; +import type { JsonValue } from '../../domain/archive-record.js'; +import type { Project, ProjectStep, ProjectTemplate } from '../../domain/projects.js'; +import { projectHandoff } from '../../domain/projects.js'; +import type { RouteReference } from '../../domain/settings.js'; +import { clear, h } from '../../lib/dom.js'; +import { toast } from '../../lib/notify.js'; +import { createReceipt, downloadReceipt } from '../../receipts/receipt.js'; +import { BUILT_IN_PROJECT_TEMPLATES } from '../../projects/templates.js'; +import { ActionBar, Dialog, EmptyState, ErrorState, ResultStage, Skeleton, type DialogController } from '../../ui/primitives.js'; + +export const id = 'projects'; + +let rootElement: HTMLElement | null = null; +let context: AppContext | null = null; +let routeReference: RouteReference | undefined; +let unsubscribe: (() => void) | null = null; +let activeDialog: DialogController | null = null; +let busy = false; +let liveMessage = ''; + +export function mount(root: HTMLElement, appContext: AppContext, route?: RouteReference): void { + rootElement = root; + context = appContext; + routeReference = route; + unsubscribe?.(); + unsubscribe = appContext.projects.repository.subscribe(() => render()); + render(); +} + +export function unmount(): void { + unsubscribe?.(); + unsubscribe = null; + activeDialog?.close(); + activeDialog = null; + rootElement = null; + context = null; + routeReference = undefined; + busy = false; + liveMessage = ''; +} + +function render(): void { + if (!rootElement || !context) return; + clear(rootElement); + const projectId = routeReference?.params.project_id; + const project = projectId ? context.projects.repository.get(projectId) : null; + if (projectId && !project) renderMissingProject(projectId); + else if (project) renderProject(project); + else renderLanding(); +} + +function renderLanding(): void { + if (!rootElement || !context) return; + const snapshot = context.projects.repository.snapshot; + const active = snapshot.projects.filter((project) => project.status === 'active'); + const history = snapshot.projects.filter((project) => project.status !== 'active'); + const body: HTMLElement[] = []; + if (!snapshot.available) { + body.push(ErrorState( + 'Projects need the desktop app', + 'Project files live beside your other Sortilune app data and are unavailable in this browser-only preview.', + )); + } else if (snapshot.loading) { + body.push(Skeleton(5)); + } else if (snapshot.repository_error) { + body.push(ErrorState('Projects could not be read', snapshot.repository_error, actionButton('Try again', () => reload(), 'btn btn-primary'))); + } else { + body.push(h('section', { class: 'project-template-section', 'aria-labelledby': 'project-template-title' }, [ + h('div', { class: 'project-section-heading' }, [ + h('div', null, [ + h('span', { class: 'project-kicker' }, ['Start a guided workspace']), + h('h2', { id: 'project-template-title' }, ['Choose a shape for the work']), + ]), + h('p', { class: 'muted' }, ['Each template is a small path through chambers you already know.']), + ]), + h('div', { class: 'project-template-grid' }, BUILT_IN_PROJECT_TEMPLATES.map(templateCard)), + ])); + + body.push(h('section', { class: 'project-list-section', 'aria-labelledby': 'active-projects-title' }, [ + h('div', { class: 'project-section-heading' }, [ + h('div', null, [h('span', { class: 'project-kicker' }, ['Resume']), h('h2', { id: 'active-projects-title' }, ['Active projects'])]), + active.length ? h('span', { class: 'badge badge-accent' }, [`${active.length} open`]) : null, + ]), + active.length + ? h('div', { class: 'project-card-grid' }, active.map(projectCard)) + : EmptyState('No active projects', 'Choose a template above when you want several chambers to feel like one session.'), + ])); + + if (history.length) { + body.push(h('details', { class: 'project-history panel' }, [ + h('summary', null, [`Completed and closed · ${history.length}`]), + h('div', { class: 'project-card-grid' }, history.map(projectCard)), + ])); + } + if (snapshot.read_errors.length) { + body.push(h('section', { class: 'project-read-errors panel', role: 'status' }, [ + h('h2', null, ['Files needing attention']), + h('p', { class: 'muted' }, ['These project files were left untouched. Other projects remain usable.']), + h('ul', null, snapshot.read_errors.map((error) => h('li', null, [ + h('code', null, [error.filename]), ` — ${error.error}`, + ]))), + ])); + } + } + + rootElement.append(h('div', { class: 'chamber-frame projects-frame reveal' }, [ + projectsHeader('Workspace · Projects', 'Projects', 'Combine chambers into a gentle, resumable path without hiding the individual results.'), + h('div', { class: 'projects-landing' }, body), + liveRegion(), + ])); +} + +function templateCard(template: ProjectTemplate): HTMLElement { + return h('article', { class: `project-template-card project-template-${template.id}` }, [ + h('div', { class: 'project-template-mark', 'aria-hidden': 'true' }, [template.id === 'creative-session' ? '✦' : template.id === 'decision-reflection' ? '◇' : '☼']), + h('div', { class: 'project-template-copy' }, [ + h('h3', null, [template.name]), + h('p', null, [template.description]), + ]), + h('ol', { class: 'project-template-steps', 'aria-label': `${template.name} steps` }, template.steps.map((step) => h('li', null, [step.title]))), + actionButton(`Start ${template.name}`, () => openCreateDialog(template), 'btn btn-primary'), + ]); +} + +function projectCard(project: Project): HTMLElement { + const finished = finishedCount(project); + const current = project.steps.find((step) => step.id === project.current_step_id); + return h('article', { class: `project-card is-${project.status}` }, [ + h('div', { class: 'project-card-topline' }, [ + h('span', { class: `project-status project-status-${project.status}` }, [statusLabel(project.status)]), + h('span', { class: 'small muted' }, [formatDate(project.updated_at)]), + ]), + h('h3', null, [project.name]), + h('p', { class: 'project-card-template' }, [project.template.name, ` · v${project.template.version}`]), + progressView(project, `${project.name} progress`), + h('p', { class: 'project-card-next' }, [ + project.status === 'completed' ? 'All steps finished.' + : project.status === 'closed' ? 'Closed with its progress preserved.' + : current ? `Next: ${current.title}` : `${finished} steps finished`, + ]), + actionButton(project.status === 'active' ? 'Resume project' : 'Open project', () => navigateProject(project.id), 'btn'), + ]); +} + +function renderProject(project: Project): void { + if (!rootElement || !context) return; + const current = project.steps.find((step) => step.id === project.current_step_id) ?? null; + const currentIndex = current ? project.steps.indexOf(current) : -1; + const records = project.steps.filter((step) => step.record_reference); + const detail = h('div', { class: 'project-detail' }, [ + h('div', { class: 'project-detail-nav' }, [ + actionButton('← All projects', () => context?.navigate('projects'), 'btn btn-ghost'), + h('span', { class: `project-status project-status-${project.status}` }, [statusLabel(project.status)]), + ]), + h('header', { class: 'project-detail-header' }, [ + h('div', null, [ + h('span', { class: 'project-kicker' }, [`${project.template.name} · template v${project.template.version}`]), + h('h1', { class: 'chamber-title-big', 'data-page-title': 'true', tabindex: -1 }, [project.name]), + h('p', { class: 'chamber-tagline' }, [project.template.description]), + ]), + h('div', { class: 'project-detail-progress' }, [ + h('strong', null, [`${finishedCount(project)} / ${project.steps.length}`]), + h('span', { class: 'small muted' }, ['steps finished']), + ]), + ]), + progressView(project, `${project.name} progress`), + project.status === 'closed' + ? closedPanel(project) + : project.status === 'completed' + ? completionPanel(project, records) + : current ? currentStepPanel(project, current, currentIndex) : ErrorState('No current step', 'This project is readable but its next step could not be determined.'), + h('div', { class: 'project-detail-grid' }, [ + stepTimeline(project), + projectNotes(project), + ]), + projectManagement(project), + liveRegion(), + ]); + rootElement.append(h('div', { class: 'chamber-frame projects-frame projects-detail-frame reveal' }, [detail])); +} + +function currentStepPanel(project: Project, step: ProjectStep, index: number): HTMLElement { + const destination = step.selected_destination ?? step.destination; + const previous = project.steps[index - 1]; + const next = project.steps[index + 1]; + return ResultStage({ + label: 'Current project step', + status: 'result', + className: 'project-current-stage panel-raised', + children: [ + h('div', { class: 'project-current-heading' }, [ + h('span', { class: 'project-step-number' }, [`${index + 1}`]), + h('div', null, [ + h('span', { class: 'project-kicker' }, ['Current step']), + h('h2', null, [step.title]), + h('p', { class: 'muted' }, [step.description]), + ]), + ]), + step.destination_options.length ? destinationChoice(project, step) : null, + h('label', { class: 'project-step-note' }, [ + h('span', { class: 'label' }, ['Step note']), + h('textarea', { + class: 'textarea', + rows: 3, + maxlength: 5000, + value: step.note, + placeholder: 'A thought to carry into this step…', + onchange: (event: Event) => saveStepNote(project.id, step.id, (event.currentTarget as HTMLTextAreaElement).value), + }), + ]), + ActionBar({ + label: 'Current step actions', + primary: [actionButton(destination ? `Open ${destinationLabel(destination)}` : 'Choose a chamber', () => openStep(project, step), 'btn btn-primary', busy || !destination)], + secondary: [ + actionButton('Previous', () => move(project.id, -1), 'btn', busy || !previous || isFinished(previous)), + actionButton('Next', () => move(project.id, 1), 'btn', busy || !next || isFinished(next)), + actionButton('Skip for now', () => skip(project.id, step.id), 'btn btn-ghost', busy), + ], + }), + ], + }); +} + +function destinationChoice(project: Project, step: ProjectStep): HTMLElement { + const select = h('select', { + class: 'select', + value: step.selected_destination ?? '', + 'aria-label': 'Chamber for this project step', + onchange: (event: Event) => selectDestination(project.id, step.id, (event.currentTarget as HTMLSelectElement).value), + }, [ + h('option', { value: '' }, ['Choose a chamber…']), + ...step.destination_options.map((option) => h('option', { value: option.destination }, [option.label])), + ]); + const selected = step.destination_options.find((option) => option.destination === step.selected_destination); + return h('div', { class: 'project-destination-choice' }, [ + h('label', null, [h('span', { class: 'label' }, ['Follow today into']), select]), + h('p', { class: 'small muted' }, [selected?.description ?? 'The choice is stored with this project and can be changed until the step is completed.']), + ]); +} + +function stepTimeline(project: Project): HTMLElement { + return h('section', { class: 'project-timeline', 'aria-labelledby': 'project-timeline-title' }, [ + h('div', { class: 'project-section-heading' }, [ + h('div', null, [h('span', { class: 'project-kicker' }, ['Path']), h('h2', { id: 'project-timeline-title' }, ['Project timeline'])]), + actionButton('View in Archive', () => openArchive(project.id), 'btn btn-small'), + ]), + h('ol', { class: 'project-step-list' }, project.steps.map((step, index) => h('li', { class: `project-step-item is-${step.status}` }, [ + h('div', { class: 'project-step-rail', 'aria-hidden': 'true' }, [ + h('span', { class: 'project-step-dot' }, [step.status === 'completed' ? '✓' : step.status === 'skipped' ? '–' : String(index + 1)]), + ]), + h('article', { class: 'project-step-card' }, [ + h('div', { class: 'project-step-card-heading' }, [ + h('div', null, [h('h3', null, [step.title]), h('span', { class: 'small muted' }, [stepStatusLabel(step.status)])]), + step.status === 'completed' || step.status === 'skipped' + ? actionButton('Revisit', () => revisit(project.id, step.id), 'btn btn-small btn-ghost', busy || project.status === 'closed') + : null, + ]), + h('p', { class: 'muted' }, [step.description]), + step.note ? h('p', { class: 'project-step-saved-note' }, [step.note]) : null, + step.record_reference ? h('div', { class: 'project-record-reference' }, [ + h('span', { class: 'project-record-mark', 'aria-hidden': 'true' }, ['↳']), + h('span', null, [ + h('strong', null, [step.record_reference.summary]), + h('span', { class: 'small muted' }, [`${destinationLabel(step.record_reference.chamber)} · ${formatDate(step.record_reference.created_at)}`]), + ]), + actionButton('Open record', () => openArchive(project.id, step.record_reference?.id), 'btn btn-small'), + ]) : null, + ]), + ]))), + ]); +} + +function projectNotes(project: Project): HTMLElement { + const handoff = projectHandoff(project); + return h('aside', { class: 'project-notes panel' }, [ + h('span', { class: 'project-kicker' }, ['Workspace notes']), + h('h2', null, ['Keep the thread']), + h('p', { class: 'muted small' }, ['These notes belong to the project. Chamber results remain separate Archive records.']), + h('label', null, [ + h('span', { class: 'label' }, ['Project notes']), + h('textarea', { + class: 'textarea project-notes-field', + maxlength: 20000, + value: project.notes, + placeholder: 'Questions, motifs, or a direction for the next step…', + onchange: (event: Event) => saveNotes(project.id, (event.currentTarget as HTMLTextAreaElement).value), + }), + ]), + h('div', { class: 'project-handoff-summary' }, [ + h('strong', null, ['Journal handoff ready']), + h('span', { class: 'small muted' }, [`${handoff.records.length} ordered record reference${handoff.records.length === 1 ? '' : 's'} · no hidden state`]), + ]), + ]); +} + +function projectManagement(project: Project): HTMLElement { + return h('section', { class: 'project-management panel', 'aria-labelledby': 'project-management-title' }, [ + h('div', null, [ + h('span', { class: 'project-kicker' }, ['Workspace controls']), + h('h2', { id: 'project-management-title' }, ['Manage project']), + ]), + h('div', { class: 'project-management-actions' }, [ + actionButton('Rename', () => openRenameDialog(project), 'btn', busy), + actionButton('Duplicate', () => duplicate(project.id), 'btn', busy), + actionButton('Export receipt', () => exportProjectReceipt(project), 'btn', busy), + actionButton('Print journal', () => context?.navigate('journal', { project_id: project.id }), 'btn', busy), + project.status === 'closed' + ? actionButton('Reopen', () => reopen(project.id), 'btn', busy) + : actionButton('Close project', () => closeProject(project.id), 'btn', busy), + actionButton('Delete project…', () => openDeleteDialog(project), 'btn btn-danger', busy), + ]), + ]); +} + +function completionPanel(project: Project, records: ProjectStep[]): HTMLElement { + return ResultStage({ + label: 'Project completion summary', + status: 'result', + className: 'project-completion panel-raised', + children: [ + h('span', { class: 'project-completion-mark', 'aria-hidden': 'true' }, ['✦']), + h('div', null, [ + h('span', { class: 'project-kicker' }, ['Session complete']), + h('h2', null, ['The path is gathered']), + h('p', { class: 'muted' }, [`${records.length} chamber result${records.length === 1 ? '' : 's'} remain independently inspectable in Archive.`]), + ]), + ActionBar({ + label: 'Completion actions', + primary: [actionButton('View project in Archive', () => openArchive(project.id), 'btn btn-primary')], + secondary: [actionButton('Duplicate as a new session', () => duplicate(project.id), 'btn')], + }), + ], + }); +} + +function closedPanel(project: Project): HTMLElement { + return ResultStage({ + label: 'Closed project', + status: 'idle', + className: 'project-closed panel', + children: [ + h('div', null, [h('h2', null, ['Progress is safely paused']), h('p', { class: 'muted' }, ['Reopen the project to continue. Its Archive records remain available either way.'])]), + actionButton('Reopen project', () => reopen(project.id), 'btn btn-primary', busy), + ], + }); +} + +function renderMissingProject(projectId: string): void { + if (!rootElement || !context) return; + rootElement.append(h('div', { class: 'chamber-frame projects-frame reveal' }, [ + projectsHeader('Workspace · Projects', 'Project unavailable', 'The workspace file may have been deleted or may need attention.'), + ErrorState( + 'This project could not be opened', + `No readable project matches ${projectId}. Related Archive records, if any, remain untouched.`, + ActionBar({ + label: 'Missing project actions', + primary: [actionButton('All projects', () => context?.navigate('projects'), 'btn btn-primary')], + secondary: [actionButton('Search Archive', () => context?.navigate('archive', { project_id: projectId }), 'btn')], + }), + ), + ])); +} + +function projectsHeader(kicker: string, title: string, tagline: string): HTMLElement { + return h('header', { class: 'projects-header' }, [ + h('div', null, [ + h('div', { class: 'chamber-id' }, [kicker]), + h('h1', { class: 'chamber-title-big', 'data-page-title': 'true', tabindex: -1 }, [title]), + h('p', { class: 'chamber-tagline' }, [tagline]), + ]), + h('div', { class: 'projects-orbit', 'aria-hidden': 'true' }, [h('span'), h('span'), h('span')]), + ]); +} + +function progressView(project: Project, label: string): HTMLElement { + return h('div', { class: 'project-progress' }, [ + h('progress', { max: project.steps.length, value: finishedCount(project), 'aria-label': label }), + h('span', { class: 'small muted' }, [`${finishedCount(project)} of ${project.steps.length}`]), + ]); +} + +function liveRegion(): HTMLElement { + return h('p', { class: 'sr-only', role: 'status', 'aria-live': 'polite' }, [liveMessage]); +} + +function openCreateDialog(template: ProjectTemplate): void { + const input = h('input', { class: 'input', maxlength: 120, value: defaultProjectName(template), autocomplete: 'off' }) as HTMLInputElement; + const create = actionButton('Create project', async () => { + if (!context || busy) return; + busy = true; + create.setAttribute('disabled', 'true'); + let project: Project | null = null; + try { + project = await context.projects.repository.create(template.id, input.value); + } catch (error) { + toast(messageOf(error), 'danger'); + input.focus(); + } finally { + busy = false; + create.removeAttribute('disabled'); + } + if (!project || !context) return; + activeDialog?.close(); + try { + await context.navigate('projects', { project_id: project.id }); + toast(`${project.name} created.`, 'success'); + } catch (error) { + toast(`Project created, but could not be opened: ${messageOf(error)}`, 'danger'); + } + }, 'btn btn-primary'); + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + create.click(); + } + }); + activeDialog?.close(); + activeDialog = Dialog({ + title: `Start ${template.name}`, + initialFocus: input, + content: [ + h('p', { class: 'muted' }, [template.description]), + h('label', null, [h('span', { class: 'label' }, ['Project name']), input]), + h('p', { class: 'small muted' }, [`${template.steps.length} steps · template v${template.version} is pinned into this project`]), + ActionBar({ label: 'Create project actions', primary: [create], secondary: [actionButton('Cancel', () => activeDialog?.close(), 'btn')] }), + ], + }); + activeDialog.open(); +} + +function openRenameDialog(project: Project): void { + const input = h('input', { class: 'input', maxlength: 120, value: project.name, autocomplete: 'off' }) as HTMLInputElement; + const save = actionButton('Save name', async () => { + if (!context || busy) return; + busy = true; + try { + await context.projects.repository.rename(project.id, input.value); + activeDialog?.close(); + toast('Project renamed.', 'success'); + } catch (error) { + toast(messageOf(error), 'danger'); + input.focus(); + } finally { busy = false; } + }, 'btn btn-primary'); + activeDialog?.close(); + activeDialog = Dialog({ + title: 'Rename project', + initialFocus: input, + content: [ + h('label', null, [h('span', { class: 'label' }, ['Project name']), input]), + ActionBar({ label: 'Rename actions', primary: [save], secondary: [actionButton('Cancel', () => activeDialog?.close(), 'btn')] }), + ], + }); + activeDialog.open(); +} + +async function openDeleteDialog(project: Project): Promise { + if (!context || busy) return; + busy = true; + liveMessage = 'Counting related Archive records…'; + render(); + try { + const records = await context.projects.relatedRecords(project.id); + const confirm = actionButton('Delete project only', async () => { + if (!context) return; + confirm.setAttribute('disabled', 'true'); + try { + const summary = await context.projects.repository.delete(project.id, records.length); + activeDialog?.close(); + await context.navigate('projects'); + toast(`${summary.project_name} deleted. ${summary.retained_archive_records} Archive record${summary.retained_archive_records === 1 ? '' : 's'} remain.`, 'success'); + } catch (error) { + toast(messageOf(error), 'danger'); + confirm.removeAttribute('disabled'); + } + }, 'btn btn-danger'); + activeDialog?.close(); + activeDialog = Dialog({ + title: `Delete ${project.name}?`, + content: [ + h('p', null, ['This removes the mutable project workspace only.']), + h('p', { class: 'project-delete-retained' }, [ + h('strong', null, [`${records.length} related Archive record${records.length === 1 ? '' : 's'} will remain`]), + h('span', { class: 'muted small' }, ['Oracle draws, decisions, images, Diary entries, and DailyRecords are not deleted.']), + ]), + ActionBar({ label: 'Delete project actions', primary: [confirm], secondary: [actionButton('Cancel', () => activeDialog?.close(), 'btn')] }), + ], + }); + activeDialog.open(); + } catch (error) { + toast(`Could not count Archive records: ${messageOf(error)}`, 'danger'); + } finally { + busy = false; + liveMessage = ''; + render(); + } +} + +async function openStep(project: Project, step: ProjectStep): Promise { + if (!context || busy) return; + busy = true; + liveMessage = `Opening ${step.title}…`; + render(); + try { + const route = await context.projects.routeForStep(project.id, step.id); + await context.navigate(route); + } catch (error) { + toast(messageOf(error), 'danger'); + busy = false; + liveMessage = ''; + render(); + } +} + +async function mutate(operation: () => Promise, success: string): Promise { + if (busy) return; + busy = true; + liveMessage = success.replace(/\.$/u, '…'); + render(); + try { + await operation(); + liveMessage = success; + toast(success, 'success'); + } catch (error) { + liveMessage = messageOf(error); + toast(liveMessage, 'danger'); + } finally { + busy = false; + render(); + } +} + +function reload(): void { void context?.projects.repository.load(); } +function navigateProject(idValue: string): void { void context?.navigate('projects', { project_id: idValue }); } +function move(projectId: string, direction: -1 | 1): void { void mutate(() => context!.projects.repository.moveCurrent(projectId, direction), 'Current step changed.'); } +function skip(projectId: string, stepId: string): void { void mutate(() => context!.projects.repository.skipStep(projectId, stepId), 'Step skipped.'); } +function revisit(projectId: string, stepId: string): void { void mutate(() => context!.projects.repository.revisitStep(projectId, stepId), 'Step reopened for a new result.'); } +function reopen(projectId: string): void { void mutate(() => context!.projects.repository.reopen(projectId), 'Project reopened.'); } +function closeProject(projectId: string): void { void mutate(() => context!.projects.repository.close(projectId), 'Project closed with its progress preserved.'); } +function saveNotes(projectId: string, notes: string): void { void mutate(() => context!.projects.repository.setNotes(projectId, notes), 'Project notes saved.'); } +function saveStepNote(projectId: string, stepId: string, note: string): void { void mutate(() => context!.projects.repository.setStepNote(projectId, stepId, note), 'Step note saved.'); } +function selectDestination(projectId: string, stepId: string, destination: string): void { + if (!destination) return; + void mutate(() => context!.projects.repository.selectDestination(projectId, stepId, destination as never), 'Chamber selected.'); +} +function duplicate(projectId: string): void { + void mutate(async () => { + const copy = await context!.projects.repository.duplicate(projectId); + await context!.navigate('projects', { project_id: copy.id }); + }, 'Project duplicated as a fresh session.'); +} +async function exportProjectReceipt(project: Project): Promise { + if (busy) return; + busy = true; + try { + const receipt = await createReceipt({ + sourceKind: 'project-summary', + sourceId: project.id, + title: project.name, + content: projectHandoff(project) as unknown as JsonValue, + }); + downloadReceipt(receipt); + toast('Project receipt ready.', 'success'); + } catch (error) { + toast(`Could not export project receipt: ${messageOf(error)}`, 'danger'); + } finally { + busy = false; + render(); + } +} +function openArchive(projectId: string, recordId?: string): void { + void context?.navigate('archive', { project_id: projectId, ...(recordId ? { record_id: recordId } : {}) }); +} + +function actionButton( + label: string, + action: (() => void | Promise) | undefined, + className = 'btn', + disabled = false, +): HTMLButtonElement { + return h('button', { class: className, type: 'button', disabled, onclick: action }, [label]) as HTMLButtonElement; +} + +function finishedCount(project: Project): number { return project.steps.filter(isFinished).length; } +function isFinished(step: ProjectStep): boolean { return step.status === 'completed' || step.status === 'skipped'; } +function statusLabel(status: Project['status']): string { return status === 'active' ? 'Active' : status === 'completed' ? 'Complete' : 'Closed'; } +function stepStatusLabel(status: ProjectStep['status']): string { + return status === 'current' ? 'Current step' : status === 'completed' ? 'Completed' : status === 'skipped' ? 'Skipped' : 'Waiting'; +} +function destinationLabel(destination: string): string { return destination.charAt(0).toUpperCase() + destination.slice(1); } +function formatDate(value: string): string { + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : value; +} +function defaultProjectName(template: ProjectTemplate): string { + const date = new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + return `${template.name} · ${date}`; +} +function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/features/today/certificate-cache.ts b/src/features/today/certificate-cache.ts new file mode 100644 index 0000000..cc5f4fe --- /dev/null +++ b/src/features/today/certificate-cache.ts @@ -0,0 +1,93 @@ +import * as storage from '../../lib/fs.js'; +import { decodeCertificate, type NistCertificateCache } from './nist.js'; + +const CERTIFICATE_ID = /^[0-9a-f]{128}$/u; +const STORAGE_PREFIX = 'sortilune.today.certificate.v1.'; + +interface CachedCertificate { + schema: 'sortilune.nist-certificate-cache'; + schema_version: 1; + certificate_id: string; + pem: string; + cached_at: string; +} + +export class PersistentNistCertificateCache implements NistCertificateCache { + readonly #memory = new Map(); + + async get(certificateId: string): Promise { + const id = normalizeId(certificateId); + const memory = this.#memory.get(id); + if (memory) return memory; + + const desktopText = typeof window !== 'undefined' + ? await storage.readText(certificateStoragePath(id)).catch(() => null) + : null; + const localText = desktopText ?? readLocal(`${STORAGE_PREFIX}${id}`); + if (!localText) return null; + try { + const candidate = JSON.parse(localText) as Partial; + if (candidate.schema !== 'sortilune.nist-certificate-cache' || candidate.schema_version !== 1 + || candidate.certificate_id !== id || typeof candidate.pem !== 'string') return null; + decodeCertificate(candidate.pem); + this.#memory.set(id, candidate.pem); + return candidate.pem; + } catch { + return null; + } + } + + async put(certificateId: string, pem: string): Promise { + const id = normalizeId(certificateId); + decodeCertificate(pem); + const value: CachedCertificate = { + schema: 'sortilune.nist-certificate-cache', + schema_version: 1, + certificate_id: id, + pem, + cached_at: new Date().toISOString(), + }; + const text = `${JSON.stringify(value, null, 2)}\n`; + this.#memory.set(id, pem); + if (typeof window !== 'undefined' && await storage.isAvailable().catch(() => false)) { + try { + await storage.writeText(certificateStoragePath(id), text); + return; + } catch { + // A verified certificate remains usable even when the optional disk + // cache cannot be written. Keep the browser fallback for later loads. + } + } + writeLocal(`${STORAGE_PREFIX}${id}`, text); + } +} + +export function certificateStoragePath(certificateId: string): string { + const id = normalizeId(certificateId); + // The full SHA-512 value plus `.json` exceeds the native portable-component + // limit. A 256-bit prefix is ample for the filename; the stored envelope + // still verifies the complete certificate ID before it is accepted. + return `archive/today/certificates/sha512-${id.slice(0, 64)}.json`; +} + +function normalizeId(value: string): string { + const id = value.toLowerCase(); + if (!CERTIFICATE_ID.test(id)) throw new TypeError('certificate cache ID must be a SHA-512 value'); + return id; +} + +function readLocal(key: string): string | null { + try { + return globalThis.localStorage?.getItem(key) ?? null; + } catch { + return null; + } +} + +function writeLocal(key: string, value: string): void { + try { + globalThis.localStorage?.setItem(key, value); + } catch { + // The in-memory copy still supports the current session. + } +} diff --git a/src/features/today/crypto.ts b/src/features/today/crypto.ts new file mode 100644 index 0000000..b9c81a1 --- /dev/null +++ b/src/features/today/crypto.ts @@ -0,0 +1,89 @@ +const encoder = new TextEncoder(); + +export function utf8(value: string): Uint8Array { + return encoder.encode(value); +} + +export function concatBytes(...values: ReadonlyArray): Uint8Array { + const length = values.reduce((sum, value) => sum + value.byteLength, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const value of values) { + output.set(value, offset); + offset += value.byteLength; + } + return output; +} + +export function toArrayBuffer(value: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(value.byteLength); + copy.set(value); + return copy.buffer; +} + +export function hexToBytesStrict(value: string, expectedBytes?: number): Uint8Array { + if (!/^[0-9a-f]*$/iu.test(value) || value.length % 2 !== 0) { + throw new TypeError('hexadecimal input must contain complete byte pairs'); + } + const bytes = value.length / 2; + if (expectedBytes !== undefined && bytes !== expectedBytes) { + throw new RangeError(`hexadecimal input must contain exactly ${expectedBytes} bytes`); + } + const output = new Uint8Array(bytes); + for (let index = 0; index < bytes; index += 1) { + output[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16); + } + return output; +} + +export function bytesToHexLower(value: Uint8Array): string { + return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export async function digest(name: 'SHA-256' | 'SHA-512', value: Uint8Array): Promise { + return new Uint8Array(await crypto.subtle.digest(name, toArrayBuffer(value))); +} + +async function hmacSha256(key: Uint8Array, value: Uint8Array): Promise { + const imported = await crypto.subtle.importKey( + 'raw', + toArrayBuffer(key), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + return new Uint8Array(await crypto.subtle.sign('HMAC', imported, toArrayBuffer(value))); +} + +/** RFC 5869 HKDF using SHA-256. */ +export async function hkdfSha256( + inputKeyMaterial: Uint8Array, + options: { salt?: Uint8Array; info?: Uint8Array; length: number }, +): Promise { + if (!(inputKeyMaterial instanceof Uint8Array)) throw new TypeError('HKDF input must be bytes'); + const { length } = options; + if (!Number.isSafeInteger(length) || length < 1 || length > 255 * 32) { + throw new RangeError('HKDF output length must be an integer from 1 through 8160 bytes'); + } + const salt = options.salt ?? new Uint8Array(32); + const info = options.info ?? new Uint8Array(); + if (!(salt instanceof Uint8Array) || !(info instanceof Uint8Array)) { + throw new TypeError('HKDF salt and info must be bytes'); + } + if (info.byteLength > 65_535) throw new RangeError('HKDF info exceeds 65535 bytes'); + + const pseudorandomKey = await hmacSha256(salt, inputKeyMaterial); + const output = new Uint8Array(length); + let previous: Uint8Array = new Uint8Array(); + let offset = 0; + for (let counter = 1; offset < length; counter += 1) { + previous = await hmacSha256( + pseudorandomKey, + concatBytes(previous, info, Uint8Array.of(counter)), + ); + const take = Math.min(previous.byteLength, length - offset); + output.set(previous.subarray(0, take), offset); + offset += take; + } + return output; +} diff --git a/src/features/today/daily-record.ts b/src/features/today/daily-record.ts new file mode 100644 index 0000000..7fcaef4 --- /dev/null +++ b/src/features/today/daily-record.ts @@ -0,0 +1,428 @@ +import type { JsonValue } from '../../domain/archive-record.js'; +import { AlgorithmRegistry } from '../../domain/algorithm-registry.js'; +import { + parseRfc3339Timestamp, + stableIdFromText, + type Rfc3339Timestamp, + type StableId, +} from '../../domain/identifiers.js'; +import { validateDailyRecord } from '../../schemas/validate.js'; +import { bytesToHexLower, digest, hexToBytesStrict, hkdfSha256, utf8 } from './crypto.js'; +import type { DailyVerification, NistPulse, VerifiedNistDay, VerificationFact } from './nist.js'; +import { localDayBounds, parseLocalDate, validateTimeZone, type LocalDayBounds } from './time.js'; + +export const TODAY_ALGORITHM_ID = 'sortilune.today'; +export const TODAY_ALGORITHM_VERSION = 1; +export const NIST_VERIFIER_PROFILE = 'nist-beacon-v2-api-2019'; + +const DIRECTIONS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] as const; +const CANVAS_GENERATORS = ['constellation', 'spectral', 'particles', 'lissajous', 'voronoi', 'interference'] as const; +const CONSTRAINT_CATEGORIES = ['creative', 'behavioral', 'perceptual', 'linguistic', 'whimsical'] as const; +const D_DORIAN = [0, 2, 3, 5, 7, 9, 10] as const; + +export const TODAY_STREAMS = [ + { id: 'oracle', info: 'sortilune/today/v1/oracle' }, + { id: 'constraint', info: 'sortilune/today/v1/constraint' }, + { id: 'diary-prompt', info: 'sortilune/today/v1/diary/prompt' }, + { id: 'diary-word', info: 'sortilune/today/v1/diary/word' }, + { id: 'diary-number', info: 'sortilune/today/v1/diary/number' }, + { id: 'diary-color', info: 'sortilune/today/v1/diary/color' }, + { id: 'diary-direction', info: 'sortilune/today/v1/diary/direction' }, + { id: 'canvas', info: 'sortilune/today/v1/canvas' }, + { id: 'symphony', info: 'sortilune/today/v1/symphony' }, +] as const; + +type StreamId = typeof TODAY_STREAMS[number]['id']; +type DailySourceKind = 'nist' | 'local'; + +export interface DailyStream { + id: StreamId; + info: string; + bytes: string; +} + +export interface DailyOutputs { + oracle: JsonValue; + constraint: JsonValue; + diary: JsonValue; + canvas: JsonValue; + symphony: JsonValue; +} + +interface DailySourceBase { + kind: DailySourceKind; + root_value: string; + public: true; + day_start: Rfc3339Timestamp; + day_end: Rfc3339Timestamp; +} + +export interface NistDailySource extends DailySourceBase { + kind: 'nist'; + nist: { + request_url: string; + requested_at: Rfc3339Timestamp; + requested_epoch_ms: number; + verifier_profile: typeof NIST_VERIFIER_PROFILE; + pulse: NistPulse; + certificate_pem: string; + }; +} + +export interface LocalDailySource extends DailySourceBase { + kind: 'local'; + local: { + generated_at: Rfc3339Timestamp; + generator: 'webcrypto.getrandomvalues'; + }; +} + +export type DailySource = NistDailySource | LocalDailySource; + +export interface DailyRecord { + schema: 'sortilune.daily-record'; + schema_version: 1; + id: StableId; + local_date: string; + time_zone: string; + utc_offset_minutes: number; + edition: number; + created_at: Rfc3339Timestamp; + source: DailySource; + algorithm: { + id: typeof TODAY_ALGORITHM_ID; + version: typeof TODAY_ALGORITHM_VERSION; + hkdf: 'hkdf-sha-256'; + context: string; + }; + derivation: { + salt: string; + streams: DailyStream[]; + }; + outputs: DailyOutputs; + verification: DailyVerification; +} + +export interface DailyReplayInput { + rootValue: string; + localDate: string; + timeZone: string; + edition: number; + sourceKind: DailySourceKind; +} + +export type DailyRecordSourceInput = + | { kind: 'nist'; value: VerifiedNistDay } + | { kind: 'local'; rootValue?: Uint8Array; generatedAt?: string }; + +export const todayAlgorithms = new AlgorithmRegistry().register({ + id: TODAY_ALGORITHM_ID, + version: TODAY_ALGORITHM_VERSION, + replay: deriveDailyV1, +}); + +export async function createDailyRecord(options: { + localDate: string; + timeZone: string; + edition?: number; + source: DailyRecordSourceInput; +}): Promise { + const localDate = parseLocalDate(options.localDate); + const timeZone = validateTimeZone(options.timeZone); + const edition = options.edition ?? 1; + if (!Number.isSafeInteger(edition) || edition < 1 || edition > 9999) { + throw new RangeError('Today edition must be an integer from 1 through 9999'); + } + const bounds = localDayBounds(localDate, timeZone); + const source = normalizeSource(options.source, bounds); + const logicalKey = dailyArchiveLogicalKey({ localDate, timeZone, edition, sourceKind: source.kind }); + const id = await stableIdFromText(`today\0daily-record\0${logicalKey}`); + const replay = await todayAlgorithms.replay>>( + TODAY_ALGORITHM_ID, + TODAY_ALGORITHM_VERSION, + { rootValue: source.root_value, localDate, timeZone, edition, sourceKind: source.kind }, + ); + const createdAt = source.kind === 'nist' ? source.nist.requested_at : source.local.generated_at; + const record: DailyRecord = { + schema: 'sortilune.daily-record', + schema_version: 1, + id, + local_date: localDate, + time_zone: timeZone, + utc_offset_minutes: bounds.utcOffsetMinutes, + edition, + created_at: createdAt, + source, + algorithm: { + id: TODAY_ALGORITHM_ID, + version: TODAY_ALGORITHM_VERSION, + hkdf: 'hkdf-sha-256', + context: replay.context, + }, + derivation: { salt: replay.salt, streams: replay.streams }, + outputs: replay.outputs, + verification: options.source.kind === 'nist' ? options.source.value.verification : localVerification(), + }; + if (!validateDailyRecord(record)) { + throw new TypeError(`generated DailyRecord is invalid: ${formatValidationErrors(validateDailyRecord.errors)}`); + } + return record; +} + +export async function replayDailyRecord(record: DailyRecord): Promise { + if (!validateDailyRecord(record)) throw new TypeError('DailyRecord does not match its declared schema'); + const bounds = localDayBounds(record.local_date, record.time_zone); + if (record.source.day_start !== bounds.startIso || record.source.day_end !== bounds.endIso + || record.utc_offset_minutes !== bounds.utcOffsetMinutes) { + throw new Error('DailyRecord local-day bounds do not replay'); + } + if (record.source.kind === 'nist') { + const pulseEpoch = Date.parse(record.source.nist.pulse.timeStamp); + if (record.source.root_value !== record.source.nist.pulse.outputValue) { + throw new Error('DailyRecord NIST root does not match its pulse output'); + } + if (pulseEpoch < bounds.startEpochMs || pulseEpoch >= bounds.endEpochMs) { + throw new Error('DailyRecord NIST pulse is outside its local day'); + } + if (record.source.nist.requested_epoch_ms !== bounds.startEpochMs) { + throw new Error('DailyRecord NIST request epoch does not match its local day'); + } + } + const replay = await todayAlgorithms.replay>>( + record.algorithm.id, + record.algorithm.version, + { + rootValue: record.source.root_value, + localDate: record.local_date, + timeZone: record.time_zone, + edition: record.edition, + sourceKind: record.source.kind, + }, + ); + if (replay.context !== record.algorithm.context || replay.salt !== record.derivation.salt) { + throw new Error('DailyRecord derivation context does not replay'); + } + const storedStreams = new Map(record.derivation.streams.map((stream) => [stream.id, stream])); + if (storedStreams.size !== TODAY_STREAMS.length) throw new Error('DailyRecord stream identities do not replay'); + for (const stream of replay.streams) { + const stored = storedStreams.get(stream.id); + if (!stored || stored.info !== stream.info || stored.bytes !== stream.bytes) { + throw new Error(`DailyRecord stream does not replay: ${stream.id}`); + } + } + if (JSON.stringify(record.outputs) !== JSON.stringify(replay.outputs)) { + throw new Error('DailyRecord outputs do not replay'); + } + return replay.outputs; +} + +export function dailyArchiveLogicalKey(value: { + localDate: string; + timeZone: string; + edition: number; + sourceKind: DailySourceKind; +}): string { + return `${TODAY_ALGORITHM_ID}/v${TODAY_ALGORITHM_VERSION}|${value.sourceKind}|${value.localDate}|${value.timeZone}|${value.edition}`; +} + +async function deriveDailyV1(input: DailyReplayInput): Promise<{ + context: string; + salt: string; + streams: DailyStream[]; + outputs: DailyOutputs; +}> { + const root = hexToBytesStrict(input.rootValue, 64); + parseLocalDate(input.localDate); + validateTimeZone(input.timeZone); + if (!Number.isSafeInteger(input.edition) || input.edition < 1 || input.edition > 9999) { + throw new RangeError('replay edition is outside the v1 range'); + } + if (input.sourceKind !== 'nist' && input.sourceKind !== 'local') throw new TypeError('replay source kind is unsupported'); + const context = `${TODAY_ALGORITHM_ID}/v1|date=${input.localDate}|timezone=${input.timeZone}|edition=${input.edition}|source=${input.sourceKind}`; + const saltBytes = await digest('SHA-256', utf8(context)); + const streams = await Promise.all(TODAY_STREAMS.map(async ({ id, info }): Promise => ({ + id, + info, + bytes: bytesToHexLower(await hkdfSha256(root, { salt: saltBytes, info: utf8(info), length: 64 })), + }))); + const bytesById = new Map(streams.map((stream) => [stream.id, hexToBytesStrict(stream.bytes, 64)])); + return { + context, + salt: bytesToHexLower(saltBytes), + streams, + outputs: await composeOutputs(bytesById), + }; +} + +async function composeOutputs(streams: ReadonlyMap): Promise { + const [deckModule, promptModule, wordModule, ...constraintModules] = await Promise.all([ + import('../../chambers/oracle/decks/generated/cosmic.generated.js'), + import('../../chambers/diary/prompts.json'), + import('../../chambers/diary/words.json'), + import('../../chambers/constraint/libraries/creative.json'), + import('../../chambers/constraint/libraries/behavioral.json'), + import('../../chambers/constraint/libraries/perceptual.json'), + import('../../chambers/constraint/libraries/linguistic.json'), + import('../../chambers/constraint/libraries/whimsical.json'), + ]); + const deck = deckModule.default as Array<{ id: number; name: string; category: string; keywords: string[]; description: string }>; + const prompts = promptModule.default as string[]; + const words = wordModule.default as string[]; + const libraries = constraintModules.map((module) => module.default as string[]); + if (!deck.length || !prompts.length || !words.length || libraries.some((library) => !library.length)) { + throw new Error('Today v1 built-in content is incomplete'); + } + + const oracleBytes = requireStream(streams, 'oracle'); + const oracleIndex = uniformIndex(oracleBytes, deck.length); + const card = deck[oracleIndex]!; + const constraintBytes = requireStream(streams, 'constraint'); + const categoryIndex = uniformIndex(constraintBytes, CONSTRAINT_CATEGORIES.length); + const constraintLibrary = libraries[categoryIndex]!; + const constraintIndex = uniformIndex(constraintBytes.subarray(4), constraintLibrary.length); + + const promptIndex = uniformIndex(requireStream(streams, 'diary-prompt'), prompts.length); + const wordIndex = uniformIndex(requireStream(streams, 'diary-word'), words.length); + const number = uniformIndex(requireStream(streams, 'diary-number'), 100) + 1; + const colorBytes = requireStream(streams, 'diary-color'); + const directionIndex = uniformIndex(requireStream(streams, 'diary-direction'), DIRECTIONS.length); + const canvasBytes = requireStream(streams, 'canvas'); + const canvasIndex = uniformIndex(canvasBytes, CANVAS_GENERATORS.length); + const symphonyBytes = requireStream(streams, 'symphony'); + const notes = Array.from({ length: 8 }, (_, index) => D_DORIAN[symphonyBytes[index]! % D_DORIAN.length]! + 50); + const durations = Array.from({ length: 8 }, (_, index) => [1, 2, 3, 4][symphonyBytes[index + 8]! % 4]!); + + return { + oracle: { + stream_id: 'oracle', + deck_id: 'cosmic', + deck_version: 1, + card_index: oracleIndex, + card: { id: card.id, name: card.name, category: card.category, keywords: [...card.keywords], description: card.description }, + illustration_seed: bytesToHexLower(oracleBytes), + }, + constraint: { + stream_id: 'constraint', + category: CONSTRAINT_CATEGORIES[categoryIndex], + item_index: constraintIndex, + text: constraintLibrary[constraintIndex]!, + }, + diary: { + stream_ids: ['diary-prompt', 'diary-word', 'diary-number', 'diary-color', 'diary-direction'], + prompt_index: promptIndex, + question: prompts[promptIndex]!, + word_index: wordIndex, + word: words[wordIndex]!, + number, + color: `#${bytesToHexLower(colorBytes.subarray(0, 3))}`, + direction: DIRECTIONS[directionIndex], + }, + canvas: { + stream_id: 'canvas', + generator: CANVAS_GENERATORS[canvasIndex], + seed: bytesToHexLower(canvasBytes), + width: 640, + height: 400, + }, + symphony: { + stream_id: 'symphony', + scale: 'd-dorian', + tempo_bpm: 54 + uniformIndex(symphonyBytes.subarray(16), 31), + midi_notes: notes, + beat_durations: durations, + }, + } as DailyOutputs; +} + +function normalizeSource(input: DailyRecordSourceInput, bounds: LocalDayBounds): DailySource { + if (input.kind === 'nist') { + const value = input.value; + const pulseEpoch = Date.parse(value.pulse.timeStamp); + if (pulseEpoch < bounds.startEpochMs || pulseEpoch >= bounds.endEpochMs) { + throw new RangeError('NIST pulse is outside the requested local day'); + } + if (value.requestedEpochMs !== bounds.startEpochMs) { + throw new RangeError('NIST request epoch does not match the requested local day'); + } + const expectedRequestUrl = `https://beacon.nist.gov/beacon/2.0/pulse/time/next/${bounds.startEpochMs}`; + if (value.requestUrl !== expectedRequestUrl) throw new TypeError('NIST request URL does not match the requested local day'); + const failedFact = [ + value.verification.schema, + value.verification.output_hash, + value.verification.certificate_digest, + value.verification.signature, + ].find((fact) => fact.status !== 'verified'); + if (failedFact) throw new TypeError(`NIST source was not strictly verified: ${failedFact.code}`); + return { + kind: 'nist', + root_value: value.pulse.outputValue, + public: true, + day_start: parseRfc3339Timestamp(bounds.startIso), + day_end: parseRfc3339Timestamp(bounds.endIso), + nist: { + request_url: value.requestUrl, + requested_at: parseRfc3339Timestamp(value.requestedAt), + requested_epoch_ms: value.requestedEpochMs, + verifier_profile: NIST_VERIFIER_PROFILE, + pulse: value.pulse, + certificate_pem: value.certificatePem, + }, + }; + } + const generatedAt = parseRfc3339Timestamp(input.generatedAt ?? new Date().toISOString()); + const root = input.rootValue ? Uint8Array.from(input.rootValue) : crypto.getRandomValues(new Uint8Array(64)); + if (root.byteLength !== 64) throw new RangeError('local Today root must contain exactly 64 bytes'); + return { + kind: 'local', + root_value: bytesToHexLower(root), + public: true, + day_start: parseRfc3339Timestamp(bounds.startIso), + day_end: parseRfc3339Timestamp(bounds.endIso), + local: { generated_at: generatedAt, generator: 'webcrypto.getrandomvalues' }, + }; +} + +function localVerification(): DailyVerification { + return { + schema: fact('verified', 'schema-valid', 'DailyRecord matches the versioned schema.'), + output_hash: fact('unsupported', 'local-source', 'Local fallback has no NIST pulse output hash.'), + certificate_digest: fact('unsupported', 'local-source', 'Local fallback has no NIST certificate.'), + signature: fact('unsupported', 'local-source', 'Local fallback has no NIST signature.'), + live_refetch: fact('unsupported', 'local-source', 'Local fallback has no live NIST reference.'), + adjacent_links: fact('unsupported', 'local-source', 'Local fallback has no NIST chain links.'), + }; +} + +function fact(status: VerificationFact['status'], code: string, detail: string): VerificationFact { + return { status, code, detail }; +} + +function requireStream(streams: ReadonlyMap, id: StreamId): Uint8Array { + const value = streams.get(id); + if (!value) throw new Error(`missing Today stream: ${id}`); + return value; +} + +function uniformIndex(bytes: Uint8Array, size: number): number { + if (!Number.isSafeInteger(size) || size < 1 || size > 0xffff_ffff) throw new RangeError('selection size is unsupported'); + const maximum = 0x1_0000_0000; + const limit = Math.floor(maximum / size) * size; + for (let offset = 0; offset + 4 <= bytes.byteLength; offset += 4) { + const value = new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, false); + if (value < limit) return value % size; + } + throw new Error('Today stream exhausted during unbiased selection'); +} + +function formatValidationErrors(errors: unknown): string { + if (!Array.isArray(errors)) return 'unknown schema error'; + return errors.slice(0, 4).map((error) => { + const item = error as { instancePath?: string; message?: string }; + return `${item.instancePath || '/'} ${item.message || 'is invalid'}`; + }).join('; '); +} diff --git a/src/features/today/index.ts b/src/features/today/index.ts new file mode 100644 index 0000000..2459e8a --- /dev/null +++ b/src/features/today/index.ts @@ -0,0 +1,639 @@ +import type { AppContext } from '../../domain/contracts.js'; +import type { SavedArchiveRecord } from '../../archive/repository.js'; +import { assignmentForDate } from '../../domain/practices.js'; +import type { RouteReference } from '../../domain/settings.js'; +import { isStableId } from '../../domain/identifiers.js'; +import { clear, h } from '../../lib/dom.js'; +import { toast } from '../../chambers/lottery/_shared.js'; +import { makeIllustration } from '../../chambers/oracle/illustration.js'; +import { ActionBar, Dialog, EmptyState, ErrorState, ResultStage, Skeleton, type DialogController } from '../../ui/primitives.js'; +import { hexToBytesStrict } from './crypto.js'; +import { + createDailyRecord, + type DailyRecord, + type DailyRecordSourceInput, +} from './daily-record.js'; +import { PersistentNistCertificateCache } from './certificate-cache.js'; +import { NistBeaconClient, NistSourceError, type DailyVerification } from './nist.js'; +import { DailyRecordRepository } from './repository.js'; +import { currentLocalDate, localDayBounds, systemTimeZone } from './time.js'; + +export const id = 'today'; + +type TodayState = + | { kind: 'loading'; message: string } + | { kind: 'new' } + | { kind: 'error'; code: string; detail: string } + | { kind: 'corrupt'; errors: Array<{ path: string; detail: string }> } + | { kind: 'daily'; daily: DailyRecord; verification: DailyVerification; reopened: boolean; visuals: DailyVisuals }; + +interface DailyVisuals { + oracleSvg: string; + canvasSvg: string; +} + +const canvasLoaders = { + constellation: () => import('../../chambers/canvas/generators/constellation.js'), + spectral: () => import('../../chambers/canvas/generators/spectral.js'), + particles: () => import('../../chambers/canvas/generators/particles.js'), + lissajous: () => import('../../chambers/canvas/generators/lissajous.js'), + voronoi: () => import('../../chambers/canvas/generators/voronoi.js'), + interference: () => import('../../chambers/canvas/generators/interference.js'), +} as const; + +let rootElement: HTMLElement | null = null; +let context: AppContext | null = null; +let routeReference: RouteReference | undefined; +let repository: DailyRecordRepository | null = null; +let client: NistBeaconClient | null = null; +let state: TodayState = { kind: 'loading', message: 'Reading the local sky…' }; +let lifecycle = 0; +let receiptDialog: DialogController | null = null; +let localDate = ''; +let timeZone = ''; + +export async function mount(root: HTMLElement, appContext: AppContext, route?: RouteReference): Promise { + const token = ++lifecycle; + rootElement = root; + context = appContext; + routeReference = route; + repository = new DailyRecordRepository(appContext.archive); + client = new NistBeaconClient({ cache: new PersistentNistCertificateCache() }); + timeZone = systemTimeZone(); + localDate = currentLocalDate(timeZone); + state = { kind: 'loading', message: 'Looking for today in your Archive…' }; + render(); + + try { + const shared = await repository.find(localDate, timeZone, 1, 'nist'); + if (token !== lifecycle) return; + if (shared.status === 'found') { + await showDaily(shared.daily, true, token, { path: shared.archive.path, record: shared.archive.record }); + return; + } + if (shared.status === 'corrupt') { + state = { kind: 'corrupt', errors: shared.errors }; + render(); + return; + } + const local = await repository.find(localDate, timeZone, 1, 'local'); + if (token !== lifecycle) return; + if (local.status === 'found') { + await showDaily(local.daily, true, token, { path: local.archive.path, record: local.archive.record }); + return; + } + if (local.status === 'corrupt') state = { kind: 'corrupt', errors: local.errors }; + else state = { kind: 'new' }; + render(); + } catch (error) { + if (token !== lifecycle) return; + state = { kind: 'error', code: 'archive-unavailable', detail: messageOf(error) }; + render(); + } +} + +export function unmount(): void { + lifecycle += 1; + receiptDialog?.close(); + receiptDialog = null; + rootElement = null; + context = null; + routeReference = undefined; + repository = null; + client = null; +} + +function render(): void { + if (!rootElement) return; + clear(rootElement); + const frame = h('div', { class: `chamber-frame today-frame reveal today-${state.kind}` }, [ + h('header', { class: 'today-header' }, [ + h('div', null, [ + h('div', { class: 'chamber-id' }, ['Daily root · ', presentationDate()]), + h('h1', { class: 'chamber-title-big', 'data-page-title': 'true', tabindex: -1 }, ['Today']), + h('p', { class: 'chamber-tagline' }, ['One public root, composed into a constellation for this local day.']), + ]), + h('div', { class: 'today-zone mono small', title: 'IANA time zone used for deterministic day bounds' }, [timeZone]), + ]), + renderBody(), + ]); + rootElement.append(frame); +} + +function renderBody(): HTMLElement { + if (state.kind === 'loading') { + return ResultStage({ + label: 'Today loading', + status: 'loading', + className: 'today-state-stage', + children: [Skeleton(4), h('p', { class: 'muted center-x' }, [state.message])], + }); + } + if (state.kind === 'new') { + return ResultStage({ + label: 'New daily constellation', + status: 'idle', + className: 'today-state-stage', + children: [EmptyState( + 'The day is uncomposed', + 'Sortilune will request the first compatible NIST pulse inside your local day, verify it, then derive nine independent streams. Nothing is saved until every strict check passes.', + ActionBar({ + label: 'New day actions', + primary: [button('Compose today', beginNist, 'btn btn-primary')], + secondary: [button('How verification works', openExplanation, 'btn btn-ghost')], + }), + )], + }); + } + if (state.kind === 'error') { + const copy = errorCopy(state.code, state.detail); + return ResultStage({ + label: 'Today unavailable', + status: 'error', + className: 'today-state-stage', + children: [ErrorState(copy.title, copy.detail, ActionBar({ + label: 'Today error actions', + primary: [button('Try NIST again', beginNist, 'btn btn-primary')], + secondary: [ + button('Create clearly local fallback', beginLocal, 'btn'), + button('Verification details', openExplanation, 'btn btn-ghost'), + ], + }))], + }); + } + if (state.kind === 'corrupt') { + return ResultStage({ + label: 'Corrupt daily record', + status: 'error', + className: 'today-state-stage', + children: [ErrorState( + 'Today’s saved record needs attention', + `Sortilune found ${state.errors.length} damaged or inconsistent record${state.errors.length === 1 ? '' : 's'} and will not replace it silently.`, + ActionBar({ + label: 'Corrupt record actions', + primary: [button('Open Archive folder', revealArchive, 'btn btn-primary')], + secondary: [button('Inspect details', openCorruptionDetails, 'btn btn-ghost')], + }), + )], + }); + } + return renderConstellation(state); +} + +function renderConstellation(view: Extract): HTMLElement { + const daily = view.daily; + const oracle = objectValue(daily.outputs.oracle); + const card = objectValue(oracle.card); + const constraint = objectValue(daily.outputs.constraint); + const diary = objectValue(daily.outputs.diary); + const canvas = objectValue(daily.outputs.canvas); + const symphony = objectValue(daily.outputs.symphony); + const local = daily.source.kind === 'local'; + const stage = ResultStage({ + label: `Daily constellation for ${daily.local_date}`, + status: 'result', + className: `today-constellation${local ? ' is-local' : ' is-shared'}`, + children: [ + h('div', { class: 'today-source-line' }, [ + h('div', { class: `today-source-seal${local ? ' is-local' : ''}` }, [ + h('span', { class: 'today-source-mark', 'aria-hidden': 'true' }, [local ? '◇' : '✦']), + h('span', null, [local ? 'Local fallback' : 'NIST shared sky']), + h('span', { class: 'mono' }, [`edition ${daily.edition}`]), + ]), + h('span', { class: 'muted small' }, [view.reopened ? 'Restored from Archive · no entropy consumed' : 'Archived once · immutable edition']), + ]), + h('div', { class: 'today-composition' }, [ + h('section', { class: 'today-oracle', 'aria-labelledby': 'today-oracle-title' }, [ + h('div', { class: 'today-region-label' }, ['Oracle · signal']), + h('div', { class: 'today-oracle-art', html: view.visuals.oracleSvg, 'aria-hidden': 'true' }, []), + h('div', { class: 'today-oracle-copy' }, [ + h('h2', { id: 'today-oracle-title' }, [String(card.name ?? 'Unknown card')]), + h('p', { class: 'today-oracle-category' }, [String(card.category ?? 'cosmic')]), + h('p', null, [String(card.description ?? '')]), + button('Open in Oracle', () => openOracle(daily, oracle), 'btn btn-small'), + ]), + ]), + h('section', { class: 'today-canvas', 'aria-labelledby': 'today-canvas-title' }, [ + h('div', { class: 'today-region-label' }, ['Canvas · field']), + h('div', { class: 'today-canvas-art', html: view.visuals.canvasSvg, 'aria-hidden': 'true' }, []), + h('div', { class: 'today-canvas-caption' }, [ + h('h2', { id: 'today-canvas-title' }, [titleCase(String(canvas.generator ?? 'constellation'))]), + button('Open in Canvas', () => openCanvas(daily, canvas), 'btn btn-small btn-ghost'), + ]), + ]), + h('section', { class: 'today-constraint', 'aria-labelledby': 'today-constraint-title' }, [ + h('div', { class: 'today-region-label' }, [`Constraint · ${String(constraint.category ?? '')}`]), + h('blockquote', { id: 'today-constraint-title' }, [String(constraint.text ?? '')]), + button('Carry into Constraint', () => openConstraint(daily, constraint), 'btn btn-small'), + ]), + h('section', { class: 'today-diary', 'aria-labelledby': 'today-diary-title' }, [ + h('div', { class: 'today-region-label' }, ['Diary · coordinates']), + h('h2', { id: 'today-diary-title' }, [String(diary.question ?? '')]), + h('div', { class: 'today-coordinates', 'aria-label': 'Daily word, number, color, and direction' }, [ + coordinate('word', diary.word), + coordinate('number', diary.number), + h('span', { class: 'today-coordinate' }, [ + h('span', { class: 'today-coordinate-key' }, ['color']), + h('span', { class: 'today-color-swatch', style: { background: String(diary.color ?? '#888888') }, 'aria-hidden': 'true' }, []), + h('span', { class: 'mono' }, [String(diary.color ?? '')]), + ]), + coordinate('direction', diary.direction), + ]), + button('Evening reflection', () => openDiary(daily, diary), 'btn btn-primary'), + ]), + h('section', { class: 'today-symphony', 'aria-labelledby': 'today-symphony-title' }, [ + h('div', { class: 'today-region-label' }, ['Symphony · motif']), + h('div', { class: 'today-motif', 'aria-hidden': 'true' }, motifBars(symphony)), + h('div', { class: 'today-symphony-copy' }, [ + h('h2', { id: 'today-symphony-title' }, [`${String(symphony.tempo_bpm ?? '')} BPM · D Dorian`]), + button('Open in Symphony', () => openSymphony(daily, symphony), 'btn btn-small btn-ghost'), + ]), + ]), + ]), + renderPracticeCallout(daily.local_date), + renderVerification(view), + ActionBar({ + label: 'Daily constellation actions', + primary: [button('Evening reflection', () => openDiary(daily, diary), 'btn btn-primary')], + secondary: [ + button('View receipt', () => openReceipt(daily), 'btn'), + button('Print journal', () => context?.navigate('journal', { record_id: daily.id }), 'btn'), + button('Copy receipt data', () => copyReceipt(daily), 'btn btn-ghost'), + button('Alternate edition', alternateEdition, 'btn btn-ghost'), + ], + }), + ], + }); + return stage; +} + +function renderPracticeCallout(localDate: string): HTMLElement { + const plans = context?.practices.snapshot.store.plans ?? []; + const assignment = plans.map((plan) => assignmentForDate(plan, localDate)).find(Boolean); + return h('section', { class: 'today-practice-callout', 'aria-labelledby': 'today-practice-title' }, [ + h('div', { class: 'today-practice-callout-copy' }, [ + h('span', { class: 'today-region-label' }, ['Practice · optional']), + h('h2', { id: 'today-practice-title' }, [assignment?.activity ?? (plans.length ? 'No practice scheduled today' : 'Add a gentle practice')]), + h('p', { class: 'small muted' }, [assignment ? `${assignment.plan_name} · skipping is always neutral` : 'Creative prompts without streaks, pressure, or health claims.']), + ]), + button(assignment ? 'Open practice' : 'Set up practices', () => context?.navigate('practices'), 'btn'), + ]); +} + +function renderVerification(view: Extract): HTMLElement { + const labels: Array<[keyof DailyVerification, string]> = [ + ['schema', 'Schema'], + ['output_hash', 'Output'], + ['certificate_digest', 'Certificate'], + ['signature', 'Signature'], + ['live_refetch', 'Live refetch'], + ['adjacent_links', 'Previous link'], + ]; + return h('details', { class: 'today-verification' }, [ + h('summary', null, [ + h('span', null, ['Verification facts']), + h('span', { class: 'today-verification-summary' }, [verificationSummary(view.verification)]), + ]), + h('div', { class: 'today-verification-grid' }, labels.map(([key, label]) => { + const fact = view.verification[key]; + return h('div', { class: `today-verification-fact is-${fact.status}` }, [ + h('span', { class: 'today-verification-dot', 'aria-hidden': 'true' }, [fact.status === 'verified' ? '✓' : fact.status === 'failed' ? '×' : '·']), + h('span', null, [h('strong', null, [label]), h('span', { class: 'small muted' }, [fact.detail])]), + ]); + })), + view.daily.source.kind === 'nist' + ? ActionBar({ + label: 'Verification actions', + primary: [button('Verify cached signature + live pulse', () => reverify(view.daily), 'btn btn-small')], + secondary: [button('How to read these facts', openExplanation, 'btn btn-small btn-ghost')], + }) + : h('p', { class: 'small muted' }, ['Local fallback is reproducible from its archived root, but it has no public NIST signature or chain.']), + ]); +} + +async function beginNist(): Promise { + const token = lifecycle; + if (!repository || !client) return; + state = { kind: 'loading', message: 'Requesting and verifying the first NIST pulse inside this local day…' }; + render(); + try { + const source = await client.fetchDay(localDayBounds(localDate, timeZone)); + if (token !== lifecycle) return; + const daily = await createDailyRecord({ localDate, timeZone, source: { kind: 'nist', value: source } }); + const saved = await repository.saveOnce(daily); + const archived = saved.record.payload as unknown as DailyRecord; + await showDaily(archived, JSON.stringify(archived) !== JSON.stringify(daily), token, saved); + } catch (error) { + if (token !== lifecycle) return; + state = { + kind: 'error', + code: error instanceof NistSourceError ? error.code : 'generation-failed', + detail: messageOf(error), + }; + render(); + } +} + +async function beginLocal(): Promise { + const token = lifecycle; + if (!repository) return; + state = { kind: 'loading', message: 'Creating a clearly labeled local fallback…' }; + render(); + try { + const daily = await createDailyRecord({ localDate, timeZone, source: { kind: 'local' } }); + const saved = await repository.saveOnce(daily); + const archived = saved.record.payload as unknown as DailyRecord; + await showDaily(archived, JSON.stringify(archived) !== JSON.stringify(daily), token, saved); + } catch (error) { + if (token !== lifecycle) return; + state = { kind: 'error', code: 'local-generation-failed', detail: messageOf(error) }; + render(); + } +} + +async function alternateEdition(): Promise { + if (state.kind !== 'daily' || !repository) return; + const current = state.daily; + const token = lifecycle; + state = { kind: 'loading', message: `Composing immutable edition ${current.edition + 1}…` }; + render(); + try { + let source: DailyRecordSourceInput; + if (current.source.kind === 'nist') { + source = { + kind: 'nist', + value: { + pulse: current.source.nist.pulse, + certificatePem: current.source.nist.certificate_pem, + requestedAt: current.source.nist.requested_at, + requestedEpochMs: current.source.nist.requested_epoch_ms, + requestUrl: current.source.nist.request_url, + verification: current.verification, + }, + }; + } else { + source = { + kind: 'local', + rootValue: hexToBytesStrict(current.source.root_value, 64), + generatedAt: current.source.local.generated_at, + }; + } + const daily = await createDailyRecord({ + localDate: current.local_date, + timeZone: current.time_zone, + edition: current.edition + 1, + source, + }); + const saved = await repository.saveOnce(daily); + const archived = saved.record.payload as unknown as DailyRecord; + await showDaily(archived, JSON.stringify(archived) !== JSON.stringify(daily), token, saved); + } catch (error) { + if (token !== lifecycle) return; + state = { kind: 'error', code: 'alternate-failed', detail: messageOf(error) }; + render(); + } +} + +async function showDaily( + daily: DailyRecord, + reopened: boolean, + token: number, + saved?: SavedArchiveRecord, +): Promise { + const visuals = await createVisuals(daily); + if (token !== lifecycle) return; + state = { kind: 'daily', daily, verification: daily.verification, reopened, visuals }; + render(); + if (saved && context && routeReference) { + try { + await context.projects.captureToday(routeReference, saved); + } catch (error) { + toast(`Today is saved, but project progress could not be recorded: ${messageOf(error)}`, 'danger'); + } + } +} + +async function createVisuals(daily: DailyRecord): Promise { + const oracle = objectValue(daily.outputs.oracle); + const card = objectValue(oracle.card); + const canvas = objectValue(daily.outputs.canvas); + let oracleSvg = ''; + let canvasSvg = ''; + try { + oracleSvg = makeIllustration({ + rawBytes: hexToBytesStrict(String(oracle.illustration_seed), 64), + deck: 'cosmic', + card, + size: 380, + }); + } catch { + oracleSvg = ''; + } + try { + const generator = String(canvas.generator) as keyof typeof canvasLoaders; + const loader = canvasLoaders[generator]; + if (!loader) throw new Error('unknown Canvas generator'); + const module = await loader(); + canvasSvg = module.generate(hexToBytesStrict(String(canvas.seed), 64), { width: 640, height: 400 }); + } catch { + canvasSvg = ''; + } + return { oracleSvg, canvasSvg }; +} + +async function reverify(daily: DailyRecord): Promise { + if (!client || daily.source.kind !== 'nist' || state.kind !== 'daily') return; + const prior = state; + const token = lifecycle; + toast('Checking cached cryptography and live NIST reference…', 'info'); + try { + const verification = await client.verifyStored( + daily.source.nist.pulse, + daily.source.nist.certificate_pem, + { liveRefetch: true, adjacentLinks: true }, + ); + if (token !== lifecycle || state !== prior) return; + state = { ...prior, verification }; + render(); + toast(verification.live_refetch.status === 'verified' ? 'Live NIST pulse matches.' : 'Verification facts updated.', 'success'); + } catch (error) { + if (token !== lifecycle || state !== prior) return; + toast(`Live verification could not finish: ${messageOf(error)}`, 'danger'); + } +} + +function openOracle(daily: DailyRecord, oracle: Record): void { + navigate('oracle', daily, { + daily_card_index: String(oracle.card_index ?? ''), + daily_seed: String(oracle.illustration_seed ?? ''), + }); +} + +function openConstraint(daily: DailyRecord, constraint: Record): void { + navigate('constraint', daily, { + daily_category: String(constraint.category ?? ''), + daily_item_index: String(constraint.item_index ?? ''), + }); +} + +function openDiary(daily: DailyRecord, diary: Record): void { + navigate('diary', daily, { + daily_date: daily.local_date, + daily_prompt_index: String(diary.prompt_index ?? ''), + daily_word_index: String(diary.word_index ?? ''), + daily_number: String(diary.number ?? ''), + daily_color: String(diary.color ?? ''), + daily_direction: String(diary.direction ?? ''), + }); +} + +function openCanvas(daily: DailyRecord, canvas: Record): void { + navigate('canvas', daily, { + daily_generator: String(canvas.generator ?? ''), + daily_seed: String(canvas.seed ?? ''), + }); +} + +function openSymphony(daily: DailyRecord, symphony: Record): void { + navigate('symphony', daily, { + daily_motif: JSON.stringify({ + tempo: symphony.tempo_bpm, + notes: symphony.midi_notes, + durations: symphony.beat_durations, + }), + }); +} + +function navigate(destination: string, daily: DailyRecord, values: Record): void { + const projectId = routeReference?.params.project_id; + const params = { + daily_record_id: daily.id, + daily_stream: destination, + ...values, + ...(isStableId(projectId) ? { project_id: projectId } : {}), + }; + context?.navigate(destination, params).catch((error) => toast(`Could not open ${destination}: ${messageOf(error)}`, 'danger')); +} + +function openReceipt(daily: DailyRecord): void { + receiptDialog?.close(); + const pre = h('pre', { class: 'today-receipt selectable', tabindex: 0 }, [JSON.stringify(daily, null, 2)]); + receiptDialog = Dialog({ + title: `Today receipt · ${daily.local_date} · edition ${daily.edition}`, + className: 'today-receipt-dialog', + content: [ + h('p', { class: 'muted small' }, ['Public reproducibility data, content snapshots, derivation labels, and separate verification facts. It is not proof of interpretation, authorship, or file creation time.']), + pre, + ActionBar({ label: 'Receipt actions', primary: [button('Copy receipt data', () => copyReceipt(daily), 'btn btn-primary')] }), + ], + }); + receiptDialog.open(); +} + +async function copyReceipt(daily: DailyRecord): Promise { + const text = `${JSON.stringify(daily, null, 2)}\n`; + try { + if (!navigator.clipboard?.writeText) throw new Error('clipboard is unavailable'); + await navigator.clipboard.writeText(text); + toast('Today receipt copied.', 'success'); + } catch { + openReceipt(daily); + toast('Select the receipt text to copy it manually.', 'info'); + } +} + +function openExplanation(): void { + receiptDialog?.close(); + receiptDialog = Dialog({ + title: 'How Today is verified', + content: [ + h('p', null, ['Sortilune asks NIST for a pulse inside the exact local-day interval, validates its fields, recomputes its SHA-512 output, checks the certificate digest, and verifies the RSA signature before creating a shared constellation.']), + h('p', null, ['Schema, output, certificate, signature, live refetch, and adjacent chain links remain separate facts. Offline cryptographic verification can stay valid while live refetch is unknown.']), + h('p', { class: 'muted' }, ['The pulse is public. Today is reproducible, not secret, and it does not prove anyone’s identity or interpretation.']), + ], + }); + receiptDialog.open(); +} + +function openCorruptionDetails(): void { + if (state.kind !== 'corrupt') return; + receiptDialog?.close(); + receiptDialog = Dialog({ + title: 'DailyRecord diagnostics', + content: state.errors.map((error) => h('div', { class: 'today-corrupt-row' }, [ + h('code', null, [error.path]), + h('p', null, [error.detail]), + ])), + }); + receiptDialog.open(); +} + +async function revealArchive(): Promise { + try { + const path = await context?.archive.reveal(); + if (path) toast(`Opened ${path}`, 'success'); + } catch (error) { + toast(`Could not open Archive: ${messageOf(error)}`, 'danger'); + } +} + +function coordinate(label: string, value: unknown): HTMLElement { + return h('span', { class: 'today-coordinate' }, [ + h('span', { class: 'today-coordinate-key' }, [label]), + h('strong', null, [String(value ?? '')]), + ]); +} + +function motifBars(symphony: Record): HTMLElement[] { + const notes = Array.isArray(symphony.midi_notes) ? symphony.midi_notes : []; + const durations = Array.isArray(symphony.beat_durations) ? symphony.beat_durations : []; + return notes.slice(0, 8).map((note, index) => h('span', { + style: { + height: `${24 + (Number(note) - 48) * 4}px`, + width: `${8 + Number(durations[index] ?? 1) * 3}px`, + }, + }, [])); +} + +function button(label: string, action: () => void | Promise, className = 'btn'): HTMLButtonElement { + return h('button', { class: className, type: 'button', onclick: action }, [label]) as HTMLButtonElement; +} + +function presentationDate(): string { + if (!localDate || !timeZone) return 'today'; + const bounds = localDayBounds(localDate, timeZone); + return new Intl.DateTimeFormat(undefined, { timeZone, weekday: 'long', month: 'long', day: 'numeric' }).format(bounds.startEpochMs + 12 * 60 * 60 * 1000); +} + +function errorCopy(code: string, detail: string): { title: string; detail: string } { + const title = ({ + unavailable: 'NIST is quiet right now', + 'no-pulse': 'No NIST pulse belongs to this local day', + 'out-of-day': 'NIST returned a pulse from another day', + 'unsupported-profile': 'NIST changed an unsupported field', + 'output-mismatch': 'The pulse output did not verify', + 'certificate-mismatch': 'The NIST certificate digest did not match', + 'signature-invalid': 'The NIST pulse signature was invalid', + 'archive-unavailable': 'Today could not read the Archive', + } as Record)[code] ?? 'Today could not be composed'; + return { title, detail: `${detail} No local fallback was created automatically.` }; +} + +function verificationSummary(verification: DailyVerification): string { + const values = Object.values(verification); + const verified = values.filter((fact) => fact.status === 'verified').length; + const failedCount = values.filter((fact) => fact.status === 'failed').length; + return failedCount ? `${failedCount} failed · ${verified} verified` : `${verified} verified · ${values.length - verified} separate unknown/not applicable`; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function titleCase(value: string): string { + return value.replaceAll('-', ' ').replace(/\b\w/gu, (letter) => letter.toUpperCase()); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/features/today/nist.ts b/src/features/today/nist.ts new file mode 100644 index 0000000..ad87977 --- /dev/null +++ b/src/features/today/nist.ts @@ -0,0 +1,518 @@ +import { fetchText } from '../../lib/http.js'; +import { parseRfc3339Timestamp } from '../../domain/identifiers.js'; +import { + bytesToHexLower, + concatBytes, + digest, + hexToBytesStrict, + toArrayBuffer, + utf8, +} from './crypto.js'; +import type { LocalDayBounds } from './time.js'; + +const NIST_ORIGIN = 'https://beacon.nist.gov'; +const API_ROOT = `${NIST_ORIGIN}/beacon/2.0`; +const MAX_PULSE_BYTES = 128 * 1024; +const MAX_CERTIFICATE_BYTES = 32 * 1024; +const HASH_HEX = /^[0-9a-f]{128}$/iu; +const SIGNATURE_HEX = /^[0-9a-f]+$/iu; +const PULSE_URI = /^https:\/\/beacon\.nist\.gov\/beacon\/2\.0\/chain\/(\d+)\/pulse\/(\d+)$/u; +const LIST_TYPES = ['previous', 'hour', 'day', 'month', 'year'] as const; + +export type NistListType = typeof LIST_TYPES[number]; +export type VerificationStatus = 'verified' | 'failed' | 'unknown' | 'unsupported'; + +export interface VerificationFact { + status: VerificationStatus; + code: string; + detail: string; +} + +export interface DailyVerification { + schema: VerificationFact; + output_hash: VerificationFact; + certificate_digest: VerificationFact; + signature: VerificationFact; + live_refetch: VerificationFact; + adjacent_links: VerificationFact; +} + +export interface NistListValue { + uri: string; + type: NistListType; + value: string; +} + +export interface NistPulse { + uri: string; + version: '2.0' | 'Version 2.0'; + cipherSuite: 0; + period: number; + certificateId: string; + chainIndex: number; + pulseIndex: number; + timeStamp: string; + localRandomValue: string; + external: { sourceId: string; statusCode: number; value: string }; + listValues: NistListValue[]; + precommitmentValue: string; + statusCode: number; + signatureValue: string; + outputValue: string; +} + +export interface NistCertificateCache { + get(certificateId: string): Promise; + put(certificateId: string, pem: string): Promise; +} + +export interface VerifiedNistDay { + pulse: NistPulse; + certificatePem: string; + requestedAt: string; + requestedEpochMs: number; + requestUrl: string; + verification: DailyVerification; +} + +export class NistSourceError extends Error { + readonly code: string; + + constructor(code: string, message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'NistSourceError'; + this.code = code; + } +} + +export class MemoryNistCertificateCache implements NistCertificateCache { + readonly #values = new Map(); + + async get(certificateId: string): Promise { + return this.#values.get(certificateId.toLowerCase()) ?? null; + } + + async put(certificateId: string, pem: string): Promise { + this.#values.set(certificateId.toLowerCase(), pem); + } +} + +export class NistBeaconClient { + readonly #readText: typeof fetchText; + readonly #cache: NistCertificateCache; + + constructor(options: { readText?: typeof fetchText; cache?: NistCertificateCache } = {}) { + this.#readText = options.readText ?? fetchText; + this.#cache = options.cache ?? new MemoryNistCertificateCache(); + } + + async fetchDay(bounds: LocalDayBounds, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise { + const requestedAt = new Date().toISOString(); + const requestUrl = `${API_ROOT}/pulse/time/next/${bounds.startEpochMs}`; + let sourceText: string; + try { + sourceText = await this.#readText(requestUrl, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? 10_000, + headers: { Accept: 'application/json' }, + }); + } catch (error) { + const status = objectRecord(error).status; + throw new NistSourceError(status === 404 ? 'no-pulse' : 'unavailable', status === 404 + ? `NIST published no pulse after ${bounds.startIso}` + : 'NIST Beacon could not be reached', { cause: error }); + } + const pulse = decodeNistPulseJson(sourceText); + const pulseEpoch = Date.parse(pulse.timeStamp); + if (pulseEpoch < bounds.startEpochMs || pulseEpoch >= bounds.endEpochMs) { + throw new NistSourceError('out-of-day', `NIST returned ${pulse.timeStamp}, outside ${bounds.localDate} in ${bounds.timeZone}`); + } + + let certificatePem = await this.#cache.get(pulse.certificateId); + if (!certificatePem) { + const certificateUrl = `${API_ROOT}/certificate/${pulse.certificateId}`; + try { + const fetchedCertificate = await this.#readText(certificateUrl, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? 10_000, + headers: { Accept: 'text/plain' }, + }); + assertBoundedUtf8(fetchedCertificate, MAX_CERTIFICATE_BYTES, 'certificate'); + certificatePem = fetchedCertificate; + } catch (error) { + throw new NistSourceError('certificate-unavailable', 'NIST pulse certificate could not be retrieved', { cause: error }); + } + } + + if (!certificatePem) throw new NistSourceError('certificate-unavailable', 'NIST pulse certificate was empty'); + const certificate = certificatePem; + const verification = await verifyNistPulse(pulse, certificate); + for (const [name, fact] of Object.entries(verification)) { + if (name === 'live_refetch' || name === 'adjacent_links') continue; + if (fact.status !== 'verified') { + throw new NistSourceError(fact.code, `${name.replaceAll('_', ' ')}: ${fact.detail}`); + } + } + await this.#cache.put(pulse.certificateId, certificate); + return { + pulse, + certificatePem: certificate, + requestedAt, + requestedEpochMs: bounds.startEpochMs, + requestUrl, + verification, + }; + } + + async verifyStored( + pulseValue: unknown, + certificatePem: string, + options: { liveRefetch?: boolean; adjacentLinks?: boolean; signal?: AbortSignal; timeoutMs?: number } = {}, + ): Promise { + let pulse: NistPulse; + try { + pulse = decodeNistPulseJson(JSON.stringify({ pulse: pulseValue })); + } catch (error) { + const verification = unknownVerification(); + verification.schema = failed('schema-invalid', error instanceof Error ? error.message : String(error)); + return verification; + } + const verification = await verifyNistPulse(pulse, certificatePem); + if (options.liveRefetch) { + let text: string; + try { + text = await this.#readText(pulse.uri, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? 10_000, + headers: { Accept: 'application/json' }, + }); + } catch { + verification.live_refetch = unknown('refetch-unavailable', 'Live pulse refetch is currently unavailable.'); + text = ''; + } + if (text) { + try { + const refetched = decodeNistPulseJson(text); + verification.live_refetch = JSON.stringify(refetched) === JSON.stringify(pulse) + ? verified('refetch-match', 'Live NIST pulse matches the stored pulse exactly.') + : failed('refetch-mismatch', 'Live NIST pulse differs from the stored pulse.'); + } catch (error) { + verification.live_refetch = failed('refetch-invalid', error instanceof Error ? error.message : String(error)); + } + } + } + if (options.adjacentLinks) { + const previous = pulse.listValues.find((value) => value.type === 'previous'); + if (!previous || pulse.pulseIndex === 1) { + verification.adjacent_links = unknown('link-not-applicable', 'This pulse has no preceding pulse to check.'); + } else { + let text: string; + try { + text = await this.#readText(previous.uri, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? 10_000, + headers: { Accept: 'application/json' }, + }); + } catch { + verification.adjacent_links = unknown('link-unavailable', 'The preceding pulse is currently unavailable.'); + text = ''; + } + if (text) { + try { + const preceding = decodeNistPulseJson(text); + verification.adjacent_links = preceding.outputValue === previous.value + ? verified('link-match', 'Preceding output matches the stored previous link.') + : failed('link-mismatch', 'Preceding output does not match the stored previous link.'); + } catch (error) { + verification.adjacent_links = failed('link-invalid', error instanceof Error ? error.message : String(error)); + } + } + } + } + return verification; + } +} + +export function decodeNistPulseJson(source: string): NistPulse { + assertBoundedUtf8(source, MAX_PULSE_BYTES, 'pulse response'); + let decoded: unknown; + try { + decoded = JSON.parse(source); + } catch (error) { + throw new NistSourceError('invalid-json', 'NIST pulse response is not valid JSON', { cause: error }); + } + const response = strictRecord(decoded, ['pulse'], 'response'); + const value = strictRecord(response.pulse, [ + 'uri', 'version', 'cipherSuite', 'period', 'certificateId', 'chainIndex', 'pulseIndex', + 'timeStamp', 'localRandomValue', 'external', 'listValues', 'precommitmentValue', + 'statusCode', 'signatureValue', 'outputValue', + ], 'pulse'); + const uri = boundedString(value.uri, 2048, 'pulse.uri'); + const uriMatch = PULSE_URI.exec(uri); + if (!uriMatch) throw unsupported('pulse.uri is not a supported NIST v2 URI'); + const version = boundedString(value.version, 32, 'pulse.version'); + if (version !== '2.0' && version !== 'Version 2.0') throw unsupported(`unsupported NIST version: ${version}`); + if (value.cipherSuite !== 0) throw unsupported(`unsupported NIST cipher suite: ${String(value.cipherSuite)}`); + const period = uint(value.period, 1, 0xffff_ffff, 'pulse.period'); + const chainIndex = uint(value.chainIndex, 1, Number.MAX_SAFE_INTEGER, 'pulse.chainIndex'); + const pulseIndex = uint(value.pulseIndex, 1, Number.MAX_SAFE_INTEGER, 'pulse.pulseIndex'); + if (Number(uriMatch[1]) !== chainIndex || Number(uriMatch[2]) !== pulseIndex) { + throw new NistSourceError('uri-index-mismatch', 'pulse URI indices do not match its fields'); + } + const timeStamp = boundedString(value.timeStamp, 64, 'pulse.timeStamp'); + parseRfc3339Timestamp(timeStamp); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(timeStamp)) { + throw unsupported('NIST timestamp is outside the supported UTC millisecond profile'); + } + const external = strictRecord(value.external, ['sourceId', 'statusCode', 'value'], 'pulse.external'); + if (!Array.isArray(value.listValues) || value.listValues.length !== LIST_TYPES.length) { + throw unsupported('NIST listValues must contain the five deployed chain-link fields'); + } + const listValues = value.listValues.map((item, index): NistListValue => { + const entry = strictRecord(item, ['uri', 'type', 'value'], `pulse.listValues[${index}]`); + const type = boundedString(entry.type, 16, `pulse.listValues[${index}].type`); + if (type !== LIST_TYPES[index]) throw unsupported(`unsupported NIST listValues order at ${index}`); + const listUri = boundedString(entry.uri, 2048, `pulse.listValues[${index}].uri`); + if (!PULSE_URI.test(listUri)) throw unsupported(`unsupported list value URI at ${index}`); + return { uri: listUri, type, value: hashHex(entry.value, `pulse.listValues[${index}].value`) }; + }); + const signatureValue = boundedString(value.signatureValue, 8192, 'pulse.signatureValue'); + if (signatureValue.length < 512 || signatureValue.length % 2 !== 0 || !SIGNATURE_HEX.test(signatureValue)) { + throw new NistSourceError('invalid-signature-field', 'pulse.signatureValue is not a bounded RSA signature'); + } + return { + uri, + version, + cipherSuite: 0, + period, + certificateId: hashHex(value.certificateId, 'pulse.certificateId'), + chainIndex, + pulseIndex, + timeStamp, + localRandomValue: hashHex(value.localRandomValue, 'pulse.localRandomValue'), + external: { + sourceId: hashHex(external.sourceId, 'pulse.external.sourceId'), + statusCode: uint(external.statusCode, 0, 0xffff_ffff, 'pulse.external.statusCode'), + value: hashHex(external.value, 'pulse.external.value'), + }, + listValues, + precommitmentValue: hashHex(value.precommitmentValue, 'pulse.precommitmentValue'), + statusCode: uint(value.statusCode, 0, 0xffff_ffff, 'pulse.statusCode'), + signatureValue: signatureValue.toLowerCase(), + outputValue: hashHex(value.outputValue, 'pulse.outputValue'), + }; +} + +export function serializeNistSigningInput(pulse: NistPulse): Uint8Array { + const fields: Uint8Array[] = [ + lengthPrefixed(utf8(pulse.uri)), + lengthPrefixed(utf8(pulse.version)), + uint32(pulse.cipherSuite), + uint32(pulse.period), + lengthPrefixed(hexToBytesStrict(pulse.certificateId, 64)), + uint64(pulse.chainIndex), + uint64(pulse.pulseIndex), + lengthPrefixed(utf8(pulse.timeStamp)), + lengthPrefixed(hexToBytesStrict(pulse.localRandomValue, 64)), + lengthPrefixed(hexToBytesStrict(pulse.external.sourceId, 64)), + uint32(pulse.external.statusCode), + lengthPrefixed(hexToBytesStrict(pulse.external.value, 64)), + ]; + for (const value of pulse.listValues) fields.push(lengthPrefixed(hexToBytesStrict(value.value, 64))); + fields.push( + lengthPrefixed(hexToBytesStrict(pulse.precommitmentValue, 64)), + uint32(pulse.statusCode), + ); + return concatBytes(...fields); +} + +export async function recomputeNistOutput(pulse: NistPulse): Promise { + return bytesToHexLower(await digest('SHA-512', concatBytes( + serializeNistSigningInput(pulse), + hexToBytesStrict(pulse.signatureValue), + ))); +} + +export async function verifyNistPulse(pulse: NistPulse, certificatePem: string): Promise { + const verification = unknownVerification(); + verification.schema = verified('schema-valid', 'Pulse fields match the supported NIST v2 API profile.'); + const recomputed = await recomputeNistOutput(pulse); + verification.output_hash = recomputed === pulse.outputValue + ? verified('output-match', 'Recomputed SHA-512 output matches the pulse.') + : failed('output-mismatch', 'Recomputed SHA-512 output does not match the pulse.'); + + let certificate: { der: Uint8Array; spki: Uint8Array }; + try { + certificate = decodeCertificate(certificatePem); + } catch (error) { + verification.certificate_digest = failed('certificate-invalid', error instanceof Error ? error.message : String(error)); + return verification; + } + const certificateDigest = bytesToHexLower(await digest('SHA-512', certificate.der)); + if (certificateDigest !== pulse.certificateId) { + verification.certificate_digest = failed('certificate-mismatch', 'Certificate DER digest does not match certificateId.'); + return verification; + } + verification.certificate_digest = verified('certificate-match', 'Certificate DER digest matches certificateId.'); + try { + const key = await crypto.subtle.importKey( + 'spki', + toArrayBuffer(certificate.spki), + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-512' }, + false, + ['verify'], + ); + const valid = await crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + key, + toArrayBuffer(hexToBytesStrict(pulse.signatureValue)), + toArrayBuffer(serializeNistSigningInput(pulse)), + ); + verification.signature = valid + ? verified('signature-valid', 'RSA PKCS#1 v1.5/SHA-512 signature is valid.') + : failed('signature-invalid', 'Pulse signature is invalid.'); + } catch (error) { + verification.signature = failed('signature-error', error instanceof Error ? error.message : String(error)); + } + return verification; +} + +export function decodeCertificate(pem: string): { der: Uint8Array; spki: Uint8Array } { + assertBoundedUtf8(pem, MAX_CERTIFICATE_BYTES, 'certificate'); + const match = /^-----BEGIN CERTIFICATE-----\r?\n([A-Za-z0-9+/=\r\n]+)\r?\n-----END CERTIFICATE-----\r?\n?$/u.exec(pem); + if (!match) throw new TypeError('certificate must contain one bounded PEM certificate'); + const base64 = match[1]!.replace(/[\r\n]/gu, ''); + let binary: string; + try { + binary = atob(base64); + } catch { + throw new TypeError('certificate PEM contains invalid base64'); + } + const der = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + if (der.byteLength < 256 || der.byteLength > 16_384) throw new RangeError('certificate DER size is unsupported'); + return { der, spki: extractSubjectPublicKeyInfo(der) }; +} + +function extractSubjectPublicKeyInfo(der: Uint8Array): Uint8Array { + const certificate = readTlv(der, 0); + if (certificate.tag !== 0x30 || certificate.end !== der.byteLength) throw new TypeError('certificate is not one DER sequence'); + const tbs = readTlv(der, certificate.valueStart); + if (tbs.tag !== 0x30) throw new TypeError('certificate TBSCertificate is malformed'); + let cursor = tbs.valueStart; + let field = readTlv(der, cursor); + if (field.tag === 0xa0) { + cursor = field.end; + field = readTlv(der, cursor); + } + // serialNumber, signature, issuer, validity, subject + for (let index = 0; index < 5; index += 1) { + cursor = field.end; + field = readTlv(der, cursor); + } + if (field.tag !== 0x30 || field.end > tbs.end) throw new TypeError('certificate subjectPublicKeyInfo is malformed'); + return der.slice(field.start, field.end); +} + +function readTlv(bytes: Uint8Array, start: number): { start: number; tag: number; valueStart: number; end: number } { + if (!Number.isSafeInteger(start) || start < 0 || start + 2 > bytes.byteLength) throw new RangeError('truncated DER field'); + const tag = bytes[start]!; + const firstLength = bytes[start + 1]!; + let valueStart = start + 2; + let length = firstLength; + if ((firstLength & 0x80) !== 0) { + const width = firstLength & 0x7f; + if (width < 1 || width > 4 || valueStart + width > bytes.byteLength) throw new RangeError('unsupported DER length'); + length = 0; + for (let index = 0; index < width; index += 1) length = length * 256 + bytes[valueStart + index]!; + if (length < 128) throw new TypeError('DER length is not minimally encoded'); + valueStart += width; + } + const end = valueStart + length; + if (!Number.isSafeInteger(end) || end > bytes.byteLength) throw new RangeError('truncated DER value'); + return { start, tag, valueStart, end }; +} + +function unknownVerification(): DailyVerification { + return { + schema: unknown('schema-not-checked', 'Schema has not been checked.'), + output_hash: unknown('output-not-checked', 'Output hash has not been checked.'), + certificate_digest: unknown('certificate-not-checked', 'Certificate digest has not been checked.'), + signature: unknown('signature-not-checked', 'Signature has not been checked.'), + live_refetch: unknown('refetch-not-run', 'Live refetch has not been run.'), + adjacent_links: unknown('links-not-run', 'Adjacent chain links were not requested.'), + }; +} + +function verified(code: string, detail: string): VerificationFact { + return { status: 'verified', code, detail }; +} + +function failed(code: string, detail: string): VerificationFact { + return { status: 'failed', code, detail }; +} + +function unknown(code: string, detail: string): VerificationFact { + return { status: 'unknown', code, detail }; +} + +function uint32(value: number): Uint8Array { + const output = new Uint8Array(4); + new DataView(output.buffer).setUint32(0, value, false); + return output; +} + +function uint64(value: number): Uint8Array { + const output = new Uint8Array(8); + new DataView(output.buffer).setBigUint64(0, BigInt(value), false); + return output; +} + +function lengthPrefixed(value: Uint8Array): Uint8Array { + return concatBytes(uint32(value.byteLength), value); +} + +function hashHex(value: unknown, label: string): string { + const text = boundedString(value, 128, label); + if (!HASH_HEX.test(text)) throw new NistSourceError('invalid-field', `${label} must be a 64-byte hexadecimal value`); + return text.toLowerCase(); +} + +function uint(value: unknown, minimum: number, maximum: number, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + throw new NistSourceError('invalid-field', `${label} is outside its supported integer range`); + } + return value as number; +} + +function boundedString(value: unknown, maximum: number, label: string): string { + if (typeof value !== 'string' || value.length < 1 || value.length > maximum) { + throw new NistSourceError('invalid-field', `${label} must contain 1 to ${maximum} characters`); + } + return value; +} + +function strictRecord(value: unknown, keys: readonly string[], label: string): Record { + const record = objectRecord(value); + const actual = Object.keys(record); + if (actual.length !== keys.length || keys.some((key) => !Object.hasOwn(record, key)) + || actual.some((key) => !keys.includes(key))) { + throw unsupported(`${label} fields do not match the supported NIST profile`); + } + return record; +} + +function objectRecord(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function assertBoundedUtf8(value: string, maximum: number, label: string): void { + if (typeof value !== 'string' || utf8(value).byteLength > maximum) { + throw new NistSourceError('response-too-large', `${label} exceeds ${maximum} UTF-8 bytes`); + } +} + +function unsupported(message: string): NistSourceError { + return new NistSourceError('unsupported-profile', message); +} diff --git a/src/features/today/repository.ts b/src/features/today/repository.ts new file mode 100644 index 0000000..e7abb8d --- /dev/null +++ b/src/features/today/repository.ts @@ -0,0 +1,158 @@ +import type { ArchiveRepository, NormalizedArchiveItem, SavedArchiveRecord } from '../../archive/repository.js'; +import type { JsonValue, Provenance } from '../../domain/archive-record.js'; +import { stableIdFromText } from '../../domain/identifiers.js'; +import { validateDailyRecord } from '../../schemas/validate.js'; +import { + dailyArchiveLogicalKey, + NIST_VERIFIER_PROFILE, + replayDailyRecord, + TODAY_ALGORITHM_ID, + TODAY_ALGORITHM_VERSION, + type DailyRecord, +} from './daily-record.js'; +import { verifyNistPulse } from './nist.js'; + +export type DailyLookup = + | { status: 'missing' } + | { status: 'found'; daily: DailyRecord; archive: NormalizedArchiveItem } + | { status: 'corrupt'; errors: Array<{ path: string; detail: string }> }; + +export class DailyRecordRepository { + readonly #archive: ArchiveRepository; + readonly #writes = new Map>(); + + constructor(archive: ArchiveRepository) { + this.#archive = archive; + } + + async find(localDate: string, timeZone: string, edition: number, sourceKind: 'nist' | 'local'): Promise { + const items = await this.#archive.listChamber('today'); + const logicalKey = dailyArchiveLogicalKey({ localDate, timeZone, edition, sourceKind }); + const expectedId = await stableIdFromText(`today\0daily-record\0${logicalKey}`); + const corrupt: Array<{ path: string; detail: string }> = []; + let found: { daily: DailyRecord; archive: NormalizedArchiveItem } | null = null; + for (const item of items) { + if (item.chamber !== 'today') continue; + if (item.status === 'error') { + if (item.filename.includes(expectedId) || item.filename.includes(localDate)) { + corrupt.push({ path: item.path, detail: item.error }); + } + continue; + } + if (item.type !== 'daily-record') continue; + const payload = item.payload; + if (!validateDailyRecord(payload)) { + const candidate = payload && typeof payload === 'object' && !Array.isArray(payload) + ? payload as Record + : {}; + const source = candidate.source && typeof candidate.source === 'object' && !Array.isArray(candidate.source) + ? candidate.source as Record + : {}; + if (item.id === expectedId || (candidate.local_date === localDate && candidate.time_zone === timeZone + && candidate.edition === edition && source.kind === sourceKind)) { + corrupt.push({ path: item.path, detail: 'DailyRecord payload failed schema validation' }); + } + continue; + } + const daily = payload as DailyRecord; + if (daily.local_date === localDate && daily.time_zone === timeZone + && daily.edition === edition && daily.source.kind === sourceKind) { + if (daily.id !== item.id || daily.id !== expectedId) { + corrupt.push({ path: item.path, detail: 'DailyRecord ID does not match its deterministic archive identity' }); + } + else { + try { + await replayDailyRecord(daily); + if (daily.source.kind === 'nist') { + const facts = await verifyNistPulse(daily.source.nist.pulse, daily.source.nist.certificate_pem); + const failedFact = [facts.schema, facts.output_hash, facts.certificate_digest, facts.signature] + .find((fact) => fact.status !== 'verified'); + if (failedFact) throw new Error(`${failedFact.code}: ${failedFact.detail}`); + } + if (found) { + corrupt.push({ path: item.path, detail: `duplicate DailyRecord logical identity also appears at ${found.archive.path}` }); + } else found = { daily, archive: item }; + } catch (error) { + corrupt.push({ + path: item.path, + detail: `DailyRecord integrity check failed: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + } + if (corrupt.length) return { status: 'corrupt', errors: corrupt }; + return found ? { status: 'found', ...found } : { status: 'missing' }; + } + + async saveOnce(daily: DailyRecord): Promise { + if (!validateDailyRecord(daily)) throw new TypeError('cannot archive an invalid DailyRecord'); + const key = dailyArchiveLogicalKey({ + localDate: daily.local_date, + timeZone: daily.time_zone, + edition: daily.edition, + sourceKind: daily.source.kind, + }); + const running = this.#writes.get(key); + if (running) return running; + const operation = this.#saveOnce(daily, key).finally(() => this.#writes.delete(key)); + this.#writes.set(key, operation); + return operation; + } + + async #saveOnce(daily: DailyRecord, logicalKey: string): Promise { + const existing = await this.find(daily.local_date, daily.time_zone, daily.edition, daily.source.kind); + if (existing.status === 'found') return { path: existing.archive.path, record: existing.archive.record }; + if (existing.status === 'corrupt') throw new Error('a corrupt DailyRecord occupies this day and must be inspected before creating another'); + try { + const saved = await this.#archive.save({ + chamber: 'today', + type: 'daily-record', + createdAt: daily.created_at, + summary: `${daily.local_date} · ${daily.source.kind === 'nist' ? 'shared sky' : 'local fallback'} · edition ${daily.edition}`, + payload: daily, + provenance: provenanceFor(daily), + logicalKey, + immutableLogicalKey: true, + algorithm: { id: TODAY_ALGORITHM_ID, version: TODAY_ALGORITHM_VERSION }, + }); + if (saved.record.id !== daily.id) throw new Error('DailyRecord ID did not match its archive identity'); + return saved; + } catch (error) { + // A concurrent immutable writer may have won. Return it only if it is the + // exact deterministic record requested; never accept an arbitrary collision. + const concurrent = await this.find(daily.local_date, daily.time_zone, daily.edition, daily.source.kind); + if (concurrent.status === 'found' && JSON.stringify(concurrent.daily) === JSON.stringify(daily)) { + return { path: concurrent.archive.path, record: concurrent.archive.record }; + } + throw error; + } + } +} + +function provenanceFor(daily: DailyRecord): Provenance { + if (daily.source.kind === 'nist') { + return { + source: { + id: 'nist-beacon', + label: 'NIST Randomness Beacon', + kind: 'public-randomness', + url: daily.source.nist.request_url, + }, + fetched_at: daily.source.nist.requested_at, + raw: JSON.stringify({ pulse: daily.source.nist.pulse }), + signature: daily.source.nist.pulse.signatureValue, + details: { + verifier_profile: NIST_VERIFIER_PROFILE, + verification: daily.verification, + } as unknown as JsonValue, + }; + } + return { + source: { id: 'system', label: 'Local Web Crypto', kind: 'system' }, + fetched_at: daily.source.local.generated_at, + raw: daily.source.root_value, + signature: null, + details: { local_fallback: true }, + }; +} diff --git a/src/features/today/route-context.ts b/src/features/today/route-context.ts new file mode 100644 index 0000000..bc78873 --- /dev/null +++ b/src/features/today/route-context.ts @@ -0,0 +1,54 @@ +import type { Relation } from '../../domain/archive-record.js'; +import type { LegacyEntropyProvenance } from '../../domain/contracts.js'; +import type { RouteReference } from '../../domain/settings.js'; +import { isStableId } from '../../domain/identifiers.js'; + +export interface DailyRouteContext { + dailyRecordId: string; + params: Readonly>; +} + +export function readDailyRouteContext(route?: RouteReference): DailyRouteContext | null { + const params = route?.params ?? {}; + const dailyRecordId = params.daily_record_id; + if (!isStableId(dailyRecordId)) return null; + return { + dailyRecordId, + params: Object.freeze({ ...params }), + }; +} + +export function relationsFromDailyRoute(route?: RouteReference): Relation[] { + const context = readDailyRouteContext(route); + if (!context) return []; + const relations: Relation[] = [{ + kind: 'daily-record', + target_id: context.dailyRecordId as Relation['target_id'], + target_schema: 'sortilune.daily-record.v1', + metadata: { origin: 'today' }, + }]; + return relations; +} + +export function dailyDerivedProvenance( + route: RouteReference | undefined, + raw: string, + label: string, +): LegacyEntropyProvenance | null { + const context = readDailyRouteContext(route); + if (!context || !raw) return null; + return { + source_id: 'daily-record', + source_name: 'Today derived stream', + flavor: 'derived', + description: `${label} derived from archived DailyRecord ${context.dailyRecordId}`, + fetched_at: new Date().toISOString(), + raw, + signature: null, + extra: { + daily_record_id: context.dailyRecordId, + daily_stream: context.params.daily_stream ?? 'unknown', + verification_state: 'derived from archived DailyRecord', + }, + }; +} diff --git a/src/features/today/time.ts b/src/features/today/time.ts new file mode 100644 index 0000000..1dae61d --- /dev/null +++ b/src/features/today/time.ts @@ -0,0 +1,144 @@ +const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/u; +const DAY_MS = 86_400_000; +const formatterCache = new Map(); + +export interface LocalDayBounds { + localDate: string; + timeZone: string; + startEpochMs: number; + endEpochMs: number; + startIso: string; + endIso: string; + utcOffsetMinutes: number; +} + +export function systemTimeZone(): string { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return validateTimeZone(zone || 'UTC'); +} + +export function validateTimeZone(value: string): string { + if (typeof value !== 'string' || value.length < 1 || value.length > 128) { + throw new TypeError('time zone must contain 1 to 128 characters'); + } + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }).format(0); + } catch { + throw new RangeError(`unsupported IANA time zone: ${value}`); + } + return value; +} + +export function parseLocalDate(value: string): string { + const match = DATE_PATTERN.exec(value); + if (!match) throw new TypeError('local date must use YYYY-MM-DD'); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const probe = new Date(Date.UTC(year, month - 1, day)); + if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) { + throw new RangeError('local date is not a calendar date'); + } + return value; +} + +export function localDateAt(epochMs: number, timeZone: string): string { + if (!Number.isFinite(epochMs)) throw new TypeError('epoch milliseconds must be finite'); + const parts = zonedParts(epochMs, validateTimeZone(timeZone)); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +export function currentLocalDate(timeZone = systemTimeZone(), now = Date.now()): string { + return localDateAt(now, timeZone); +} + +export function localDayBounds(localDate: string, timeZone: string): LocalDayBounds { + const date = parseLocalDate(localDate); + const zone = validateTimeZone(timeZone); + const nextDate = addCalendarDays(date, 1); + const startEpochMs = firstInstantOfDate(date, zone); + const endEpochMs = firstInstantOfDate(nextDate, zone); + if (endEpochMs <= startEpochMs || endEpochMs - startEpochMs > 48 * 60 * 60 * 1000) { + throw new RangeError('local day has unsupported time-zone bounds'); + } + return { + localDate: date, + timeZone: zone, + startEpochMs, + endEpochMs, + startIso: new Date(startEpochMs).toISOString(), + endIso: new Date(endEpochMs).toISOString(), + utcOffsetMinutes: offsetMinutesAt(startEpochMs, zone), + }; +} + +function firstInstantOfDate(localDate: string, timeZone: string): number { + const match = DATE_PATTERN.exec(localDate)!; + const approximate = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + let low = approximate - 48 * 60 * 60 * 1000; + let high = approximate + 48 * 60 * 60 * 1000; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (localDateAtUnchecked(middle, timeZone) < localDate) low = middle + 1; + else high = middle; + } + if (localDateAtUnchecked(low, timeZone) !== localDate) { + throw new RangeError(`local date does not exist in ${timeZone}`); + } + return low; +} + +function addCalendarDays(localDate: string, days: number): string { + const match = DATE_PATTERN.exec(localDate)!; + const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]) + days)); + return `${date.getUTCFullYear().toString().padStart(4, '0')}-${(date.getUTCMonth() + 1).toString().padStart(2, '0')}-${date.getUTCDate().toString().padStart(2, '0')}`; +} + +function offsetMinutesAt(epochMs: number, timeZone: string): number { + const parts = zonedParts(epochMs, timeZone); + const representedAsUtc = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second), + ); + return Math.round((representedAsUtc - Math.floor(epochMs / 1000) * 1000) / 60_000); +} + +function localDateAtUnchecked(epochMs: number, timeZone: string): string { + const parts = zonedParts(epochMs, timeZone); + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function zonedParts(epochMs: number, timeZone: string): Record<'year' | 'month' | 'day' | 'hour' | 'minute' | 'second', string> { + let formatter = formatterCache.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }); + formatterCache.set(timeZone, formatter); + } + const values: Partial> = {}; + for (const part of formatter.formatToParts(epochMs)) { + if (part.type === 'year' || part.type === 'month' || part.type === 'day' + || part.type === 'hour' || part.type === 'minute' || part.type === 'second') { + values[part.type] = part.value; + } + } + if (!values.year || !values.month || !values.day || values.hour === undefined + || values.minute === undefined || values.second === undefined) { + throw new Error(`could not resolve time-zone parts for ${timeZone}`); + } + return values as Record<'year' | 'month' | 'day' | 'hour' | 'minute' | 'second', string>; +} + +export const NOMINAL_DAY_MS = DAY_MS; diff --git a/src/index.html b/src/index.html index de28772..e1cb658 100644 --- a/src/index.html +++ b/src/index.html @@ -8,7 +8,12 @@ + + + + + @@ -18,10 +23,11 @@ +
- + diff --git a/src/journal/html.ts b/src/journal/html.ts new file mode 100644 index 0000000..47e60a5 --- /dev/null +++ b/src/journal/html.ts @@ -0,0 +1,66 @@ +import type { JournalDocument, JournalFact, JournalSection } from '../domain/journal.js'; + +export function renderJournalHtml(document: JournalDocument): string { + const title = escapeHtml(document.title); + const sections = document.sections.map(renderSection).join('\n'); + const cover = document.options.cover ? `
+

Sortilune journal

+

${title}

+

${escapeHtml(document.subtitle)}

+

Prepared ${escapeHtml(formatDate(document.manifest.created_at))}

+
` : `

${title}

${escapeHtml(document.subtitle)}

`; + const manifest = escapeHtml(JSON.stringify(document.manifest, null, 2)); + return ` + + + + + + ${title} + + + + ${cover} +
${sections || '

No records were selected.

'}
+
Created locally by Sortilune · journal-html/v1
+ + +`; +} + +export function journalFilename(document: JournalDocument): string { + const stem = document.title.normalize('NFKD').replaceAll(/[^a-zA-Z0-9]+/gu, '-').replaceAll(/^-|-$/gu, '').toLowerCase().slice(0, 80); + return `${stem || 'sortilune-journal'}.html`; +} + +function renderSection(section: JournalSection): string { + const date = section.date ? `` : ''; + const paragraphs = section.paragraphs.map((value) => `

${escapeHtml(value).replaceAll('\n', '
')}

`).join(''); + const facts = section.facts.length ? `
${section.facts.map(renderFact).join('')}
` : ''; + const provenance = section.provenance.length ? `
Source notes${section.provenance.map((value) => `
${escapeHtml(value)}
`).join('')}
` : ''; + return `
+

${escapeHtml(section.kicker)}

${escapeHtml(section.title)}

${date}
+
${paragraphs}
${facts}${provenance} +
`; +} + +function renderFact(fact: JournalFact): string { + return `
${escapeHtml(fact.label)}
${escapeHtml(fact.value).replaceAll('\n', '
')}
`; +} + +function escapeHtml(value: string): string { + return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +function escapeAttribute(value: string): string { return escapeHtml(value).replaceAll('`', '`'); } + +function formatDate(value: string): string { + const date = new Date(value); + return Number.isFinite(date.getTime()) + ? new Intl.DateTimeFormat('en', { dateStyle: 'long', timeStyle: value.includes('T') ? 'short' : undefined }).format(date) + : value; +} + +function journalCss(): string { + return `:root{color-scheme:light;--ink:#24211f;--muted:#706b65;--paper:#f8f4ec;--line:#d8d0c4;--accent:#8b6548;font:16px/1.65 Georgia,"Times New Roman",serif}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink)}body,main,.cover,.compact-title,footer{max-width:920px;margin-inline:auto}.cover{min-height:72vh;display:grid;align-content:center;padding:12vh 8%;border-bottom:1px solid var(--line);page-break-after:always}.cover h1,.compact-title h1{font-size:clamp(2.6rem,8vw,5.5rem);line-height:1.02;margin:.2em 0}.cover-date,footer,time,.eyebrow,dt{color:var(--muted)}.eyebrow{font:700 .72rem/1.2 ui-monospace,monospace;letter-spacing:.14em;text-transform:uppercase}.compact-title{padding:4rem 6% 2rem;border-bottom:1px solid var(--line)}main{padding:2rem 6% 5rem}article{padding:2.4rem 0;border-bottom:1px solid var(--line);break-inside:avoid-page}.entry-heading{display:flex;gap:2rem;justify-content:space-between;align-items:start}.entry-heading h2{margin:.2rem 0 1rem;font-size:2rem;line-height:1.2}.entry-heading time{white-space:nowrap;font: .82rem ui-monospace,monospace}.entry-copy{font-size:1.08rem}.entry-copy p{white-space:normal}dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0;border:1px solid var(--line);margin:1.5rem 0}dl div{padding:.7rem 1rem;border-bottom:1px solid var(--line)}dt{font:700 .68rem ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}dd{margin:.15rem 0 0;overflow-wrap:anywhere}details{margin-top:1rem}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#eee8de;padding:1rem;font:.75rem/1.5 ui-monospace,monospace}footer{padding:1.5rem 6%;border-top:1px solid var(--line);font-size:.8rem}.empty{padding:4rem 0}[data-theme=midnight]{color-scheme:dark;--ink:#e8e5df;--muted:#aaa6a0;--paper:#11161e;--line:#343b46;--accent:#8fc9b5}[data-theme=midnight] pre{background:#1a212b}@media(max-width:620px){dl{grid-template-columns:1fr}.entry-heading{display:block}.entry-heading time{display:block;margin-bottom:1rem}}@media print{@page{size:auto;margin:18mm}body{background:#fff;color:#111}.cover{min-height:85vh}.compact-title,main,.cover,footer{max-width:none;padding-inline:0}article{break-inside:avoid-page}details{display:block}details summary{display:none}footer{page-break-before:avoid}[data-theme=midnight]{--ink:#111;--muted:#555;--paper:#fff;--line:#bbb}a{color:#111}}`; +} diff --git a/src/lib/entropy/convert.js b/src/lib/entropy/convert.js index 1c57179..5b26560 100644 --- a/src/lib/entropy/convert.js +++ b/src/lib/entropy/convert.js @@ -61,8 +61,8 @@ export function convert(bytes, { kind, count = 1, range, choices }) { const vals = []; for (let i = 0; i < count; i++) { const u = reader.readUint64(); - // divide by 2^64 to land in [0, 1) - const f = Number(u) / 18446744073709551616; + // Keep the top 53 bits so every representable result remains in [0, 1). + const f = Number(u >> 11n) / 9007199254740992; vals.push(f); } return finalize(vals, count, reader); diff --git a/src/lib/entropy/index.js b/src/lib/entropy/index.js deleted file mode 100644 index c8d85e5..0000000 --- a/src/lib/entropy/index.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * The Entropy Engine. - * - * Public API: - * await entropy.request({ kind, range, count, source, choices }) - * → { value, provenance } - * - * Sources are tried in priority order with per-source timeout and graceful - * fallback. Results are cached per source for 60 s and a sliding byte-offset - * advances on each draw so two requests within a minute legitimately share a - * pulse but yield different bytes. Provenance always reflects the actual - * source that served the request. - */ - -import { withTimeout } from '../http.js'; -import { neededBytes, convert } from './convert.js'; -import { bytesToHex } from '../format.js'; - -import * as nistBeacon from './sources/nist-beacon.js'; -import * as anuQuantum from './sources/anu-quantum.js'; -import * as randomOrg from './sources/random-org.js'; -import * as usgsSeismic from './sources/usgs-seismic.js'; -import * as openMeteo from './sources/open-meteo.js'; -import * as system from './sources/system.js'; - -const ADAPTERS = { - [nistBeacon.id]: nistBeacon, - [anuQuantum.id]: anuQuantum, - [randomOrg.id]: randomOrg, - [usgsSeismic.id]: usgsSeismic, - [openMeteo.id]: openMeteo, - [system.id]: system, -}; - -/** Priority order for `source: 'preferred'`. NIST first; system always last. */ -const PREFERRED_ORDER = [ - nistBeacon.id, - usgsSeismic.id, - openMeteo.id, - randomOrg.id, - anuQuantum.id, - system.id, -]; - -const SOURCE_TIMEOUT_MS = 3000; -const CACHE_TTL_MS = 60_000; -const FETCH_MIN_BYTES = 64; // always fetch at least this much -const RATE_LIMIT_PER_MIN = 10; - -/** Per-source state. */ -const cache = new Map(); -const requestLog = new Map(); // source_id → array of timestamps -const status = new Map(); // source_id → { lastSuccess, lastError, lastErrorMessage } - -for (const id of Object.keys(ADAPTERS)) { - status.set(id, { lastSuccess: null, lastError: null, lastErrorMessage: null }); -} - -/** Main entry point. */ -export async function request(opts = {}) { - const kind = opts.kind || 'integer'; - if (kind === 'integer' && (!opts.range || opts.range.length !== 2)) { - throw new Error("request({kind:'integer'}) requires range: [min, max]"); - } - if (kind === 'choice' && !(opts.choices && opts.choices.length)) { - throw new Error("request({kind:'choice'}) requires a non-empty choices array"); - } - const byteCount = neededBytes(opts); - if (byteCount <= 0) throw new Error('zero-byte request'); - - const order = resolveOrder(opts.source || 'preferred'); - const errors = []; - for (const sourceId of order) { - const adapter = ADAPTERS[sourceId]; - if (!adapter) continue; - try { - const { bytes, extra } = await drawFrom(adapter, byteCount); - const { value } = convert(bytes, opts); - const provenance = { - source_id: adapter.id, - source_name: adapter.displayName, - flavor: adapter.flavor, - description: adapter.describe(extra), - fetched_at: new Date().toISOString(), - raw: bytesToHex(bytes), - signature: extra?.signature || null, - extra: extra || {}, - }; - noteSuccess(adapter.id); - return { value, provenance }; - } catch (err) { - noteError(adapter.id, err); - errors.push({ source: adapter.id, error: String(err?.message || err) }); - // continue to next source - } - } - const detail = errors.map((e) => `${e.source}: ${e.error}`).join(' | '); - throw new Error(`Entropy: all sources failed. ${detail}`); -} - -/** Enable/disable individual sources. */ -let _enabledMap = null; -export function setEnabled(map) { _enabledMap = map ? { ...map } : null; } -function isEnabled(id) { - if (!_enabledMap) return true; - return _enabledMap[id] !== false; -} - -/** Get the current source health status (for the Settings panel). */ -export function getSourceStatus() { - return Array.from(Object.keys(ADAPTERS)).map((id) => ({ - id, - displayName: ADAPTERS[id].displayName, - flavor: ADAPTERS[id].flavor, - ...(status.get(id) || {}), - })); -} - -/** One-shot health check across all sources. */ -export async function testAllSources() { - const out = []; - for (const id of Object.keys(ADAPTERS)) { - const t0 = performance.now(); - try { - await drawFrom(ADAPTERS[id], 16); - out.push({ id, ok: true, ms: Math.round(performance.now() - t0) }); - } catch (err) { - out.push({ id, ok: false, ms: Math.round(performance.now() - t0), error: String(err?.message || err) }); - } - } - return out; -} - -function resolveOrder(source) { - const order = (() => { - if (source === 'preferred' || source === 'any') return PREFERRED_ORDER; - if (source === 'system') return [system.id]; - if (ADAPTERS[source]) { - const rest = PREFERRED_ORDER.filter((id) => id !== source); - return [source, ...rest]; - } - const aliasId = Object.keys(ADAPTERS).find((id) => ADAPTERS[id].flavor === source); - if (aliasId) { - const rest = PREFERRED_ORDER.filter((id) => id !== aliasId); - return [aliasId, ...rest]; - } - return PREFERRED_ORDER; - })(); - // Filter out user-disabled sources, but always keep system as a last resort - const filtered = order.filter((id) => isEnabled(id)); - if (filtered.length === 0) return [system.id]; - return filtered; -} - -async function drawFrom(adapter, byteCount) { - enforceRateLimit(adapter.id); - - let entry = cache.get(adapter.id); - const fresh = entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS; - const enough = fresh && entry.offset + byteCount <= entry.bytes.length; - if (!enough) { - const fetchSize = Math.max(byteCount, FETCH_MIN_BYTES); - const { bytes, extra } = await withTimeout( - adapter.fetchRaw(fetchSize), - SOURCE_TIMEOUT_MS, - adapter.displayName, - ); - if (!(bytes instanceof Uint8Array) || bytes.length < fetchSize) { - throw new Error(`${adapter.id} returned only ${bytes?.length ?? 0} of ${fetchSize} bytes`); - } - entry = { bytes, extra, fetchedAt: Date.now(), offset: 0 }; - cache.set(adapter.id, entry); - noteRequest(adapter.id); - } - const slice = entry.bytes.subarray(entry.offset, entry.offset + byteCount); - entry.offset += byteCount; - return { bytes: new Uint8Array(slice), extra: entry.extra }; -} - -function enforceRateLimit(id) { - const now = Date.now(); - let log = requestLog.get(id); - if (!log) { log = []; requestLog.set(id, log); } - while (log.length && now - log[0] > 60_000) log.shift(); - if (log.length >= RATE_LIMIT_PER_MIN) { - throw new Error(`${id}: local rate limit reached (${RATE_LIMIT_PER_MIN}/min)`); - } -} - -function noteRequest(id) { - const log = requestLog.get(id) || []; - log.push(Date.now()); - requestLog.set(id, log); -} - -function noteSuccess(id) { - const s = status.get(id) || {}; - s.lastSuccess = new Date().toISOString(); - s.lastError = null; - s.lastErrorMessage = null; - status.set(id, s); -} - -function noteError(id, err) { - const s = status.get(id) || {}; - s.lastError = new Date().toISOString(); - s.lastErrorMessage = String(err?.message || err); - status.set(id, s); -} - -/** Clear caches (used by the test panel). */ -export function reset() { - cache.clear(); - requestLog.clear(); -} - -/** Convenience entry point used by chambers. */ -export default { request, getSourceStatus, testAllSources, reset, setEnabled }; diff --git a/src/lib/entropy/index.ts b/src/lib/entropy/index.ts new file mode 100644 index 0000000..4de18d5 --- /dev/null +++ b/src/lib/entropy/index.ts @@ -0,0 +1,296 @@ +/** + * The Entropy Engine. + * + * Public API: + * await entropy.request({ kind, range, count, source, choices }) + * → { value, provenance } + * + * Sources are tried in priority order with per-source timeout and graceful + * fallback. Results are cached per source for 60 s and a sliding byte-offset + * advances on each draw so two requests within a minute legitimately share a + * pulse but yield different bytes. Provenance always reflects the actual + * source that served the request. + */ + +import { withTimeout } from '../http.js'; +import { neededBytes, convert } from './convert.js'; +import { bytesToHex } from '../format.js'; + +import * as nistBeacon from './sources/nist-beacon.js'; +import * as anuQuantum from './sources/anu-quantum.js'; +import * as randomOrg from './sources/random-org.js'; +import * as usgsSeismic from './sources/usgs-seismic.js'; +import * as openMeteo from './sources/open-meteo.js'; +import * as system from './sources/system.js'; +import type { + EntropyRequest, + EntropyResult, + EntropyService, + LegacyEntropyProvenance, +} from '../../domain/contracts.js'; + +interface EntropyAdapter { + id: string; + displayName: string; + flavor: string; + fetchRaw(count: number, options: { signal: AbortSignal }): Promise<{ bytes: Uint8Array; extra?: Record }>; + describe(extra?: Record): string; +} + +interface SourceStatus { + lastSuccess: string | null; + lastError: string | null; + lastErrorMessage: string | null; +} + +const ADAPTERS: Record = { + [nistBeacon.id]: nistBeacon as unknown as EntropyAdapter, + [anuQuantum.id]: anuQuantum as unknown as EntropyAdapter, + [randomOrg.id]: randomOrg as unknown as EntropyAdapter, + [usgsSeismic.id]: usgsSeismic as unknown as EntropyAdapter, + [openMeteo.id]: openMeteo as unknown as EntropyAdapter, + [system.id]: system as unknown as EntropyAdapter, +}; + +/** Priority order for `source: 'preferred'`. NIST first; system always last. */ +const PREFERRED_ORDER = [ + nistBeacon.id, + usgsSeismic.id, + openMeteo.id, + randomOrg.id, + anuQuantum.id, + system.id, +]; + +const SOURCE_TIMEOUT_MS = 3000; +const CACHE_TTL_MS = 60_000; +const FETCH_MIN_BYTES = 64; // always fetch at least this much +const RATE_LIMIT_PER_MIN = 10; +const VALID_KINDS = new Set(['bytes', 'float', 'integer', 'choice', 'permutation']); + +/** Per-source state. */ +const cache = new Map(); +const requestLog = new Map(); // source_id → array of timestamps +const status = new Map(); // source_id → { lastSuccess, lastError, lastErrorMessage } + +for (const id of Object.keys(ADAPTERS)) { + status.set(id, { lastSuccess: null, lastError: null, lastErrorMessage: null }); +} + +/** Main entry point. */ +export async function request(opts: EntropyRequest = {}): Promise> { + const kind = opts.kind || 'integer'; + const count = opts.count ?? 1; + const range = opts.range; + const choices = opts.choices; + if (!VALID_KINDS.has(kind)) throw new Error(`unknown entropy kind: ${kind}`); + const maxCount = kind === 'bytes' ? 65_536 : 10_000; + if (!Number.isSafeInteger(count) || count < 1 || count > maxCount) { + throw new Error(`request count must be a safe integer between 1 and ${maxCount.toLocaleString('en-US')}`); + } + if (kind === 'integer' && (!Array.isArray(range) || range.length !== 2)) { + throw new Error("request({kind:'integer'}) requires range: [min, max]"); + } + if (kind === 'integer' && !range!.every(Number.isSafeInteger)) { + throw new Error('integer ranges must contain safe integers'); + } + if (kind === 'integer' && range![1] < range![0]) { + throw new Error('integer range must have max >= min'); + } + if ((kind === 'choice' || kind === 'permutation') && (!Array.isArray(choices) || choices.length === 0)) { + if (kind === 'choice') { + throw new Error("request({kind:'choice'}) requires a non-empty choices array"); + } + throw new Error("request({kind:'permutation'}) requires a non-empty choices array"); + } + if ((kind === 'choice' || kind === 'permutation') && choices && choices.length > 10_000) { + throw new Error('choice and permutation inputs are limited to 10,000 items'); + } + if (kind === 'permutation' && count !== 1) { + throw new Error('permutation requests do not accept count other than 1'); + } + const byteCount = neededBytes(opts as Parameters[0]); + if (byteCount <= 0) throw new Error('zero-byte request'); + + const order = resolveOrder(opts.source || 'preferred', opts.fallback === true); + if (order.length === 0) throw new Error('Entropy: no requested sources are enabled'); + const errors: Array<{ source: string; error: string }> = []; + for (const sourceId of order) { + const adapter = ADAPTERS[sourceId]; + if (!adapter) continue; + try { + const { bytes, extra } = await drawFrom(adapter, byteCount); + const { value } = convert(bytes, opts as Parameters[1]); + const provenance: LegacyEntropyProvenance = { + source_id: adapter.id, + source_name: adapter.displayName, + flavor: adapter.flavor, + description: adapter.describe(extra), + fetched_at: new Date().toISOString(), + raw: bytesToHex(bytes), + signature: typeof extra?.signature === 'string' ? extra.signature : null, + extra: extra || {}, + }; + noteSuccess(adapter.id); + return { value: value as TValue, provenance }; + } catch (err) { + noteError(adapter.id, err); + errors.push({ source: adapter.id, error: errorMessage(err) }); + // continue to next source + } + } + const detail = errors.map((e) => `${e.source}: ${e.error}`).join(' | '); + throw new Error(`Entropy: all sources failed. ${detail}`); +} + +/** Enable/disable individual sources. */ +let _enabledMap: Readonly> | null = null; +export function setEnabled(map: Readonly> | null): void { _enabledMap = map ? { ...map } : null; } +let _preferredSource: string | null = null; +export function setPreferred(source: string | null): void { + _preferredSource = source && ADAPTERS[source] ? source : null; +} +function isEnabled(id: string): boolean { + if (!_enabledMap) return true; + return _enabledMap[id] !== false; +} + +/** Get the current source health status (for the Settings panel). */ +export function getSourceStatus(): Array { + return Array.from(Object.keys(ADAPTERS)).map((id) => { + const adapter = ADAPTERS[id]!; + return { + id, + displayName: adapter.displayName, + flavor: adapter.flavor, + ...(status.get(id) || { lastSuccess: null, lastError: null, lastErrorMessage: null }), + }; + }); +} + +/** One-shot health check across all sources. */ +export async function testAllSources(): Promise> { + const out: Array<{ id: string; ok: boolean; ms: number; error?: string }> = []; + for (const id of Object.keys(ADAPTERS)) { + const adapter = ADAPTERS[id]!; + const t0 = performance.now(); + try { + await drawFrom(adapter, 16, { forceFresh: true }); + noteSuccess(id); + out.push({ id, ok: true, ms: Math.round(performance.now() - t0) }); + } catch (err) { + noteError(id, err); + out.push({ id, ok: false, ms: Math.round(performance.now() - t0), error: errorMessage(err) }); + } + } + return out; +} + +function resolveOrder(source: string, allowFallback: boolean): string[] { + const order = (() => { + if (source === 'preferred' || source === 'any') { + return _preferredSource + ? [_preferredSource, ...PREFERRED_ORDER.filter((id) => id !== _preferredSource)] + : PREFERRED_ORDER; + } + if (source === 'system') return [system.id]; + if (ADAPTERS[source]) { + if (!allowFallback) return [source]; + return [source, ...PREFERRED_ORDER.filter((id) => id !== source)]; + } + const aliasId = Object.keys(ADAPTERS).find((id) => ADAPTERS[id]!.flavor === source); + if (aliasId) { + if (!allowFallback) return [aliasId]; + return [aliasId, ...PREFERRED_ORDER.filter((id) => id !== aliasId)]; + } + return []; + })(); + return order.filter((id) => isEnabled(id)); +} + +async function drawFrom( + adapter: EntropyAdapter, + byteCount: number, + { forceFresh = false }: { forceFresh?: boolean } = {}, +): Promise<{ bytes: Uint8Array; extra?: Record }> { + if (adapter.id === system.id) { + // crypto.getRandomValues() is local, immediate, and does not consume a + // third-party quota. Caching and a network-style requests-per-minute limit + // only make normal repeated local use fail after a handful of drawings. + const result = await adapter.fetchRaw(byteCount, { signal: new AbortController().signal }); + if (!(result.bytes instanceof Uint8Array) || result.bytes.length < byteCount) { + throw new Error(`${adapter.id} returned only ${result.bytes?.length ?? 0} of ${byteCount} bytes`); + } + return { + bytes: new Uint8Array(result.bytes.subarray(0, byteCount)), + ...(result.extra ? { extra: result.extra } : {}), + }; + } + enforceRateLimit(adapter.id); + + let entry = cache.get(adapter.id); + const fresh = !forceFresh && entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS; + const enough = fresh && entry.offset + byteCount <= entry.bytes.length; + if (!enough) { + const fetchSize = Math.max(byteCount, FETCH_MIN_BYTES); + const { bytes, extra } = await withTimeout( + (signal: AbortSignal) => adapter.fetchRaw(fetchSize, { signal }), + SOURCE_TIMEOUT_MS, + adapter.displayName, + ); + if (!(bytes instanceof Uint8Array) || bytes.length < fetchSize) { + throw new Error(`${adapter.id} returned only ${bytes?.length ?? 0} of ${fetchSize} bytes`); + } + entry = { bytes, extra, fetchedAt: Date.now(), offset: 0 }; + cache.set(adapter.id, entry); + noteRequest(adapter.id); + } + const slice = entry.bytes.subarray(entry.offset, entry.offset + byteCount); + entry.offset += byteCount; + return { bytes: new Uint8Array(slice), extra: entry.extra }; +} + +function enforceRateLimit(id: string): void { + const now = Date.now(); + let log = requestLog.get(id); + if (!log) { log = []; requestLog.set(id, log); } + while (log.length && now - log[0] > 60_000) log.shift(); + if (log.length >= RATE_LIMIT_PER_MIN) { + throw new Error(`local rate limit reached (${RATE_LIMIT_PER_MIN}/min)`); + } +} + +function noteRequest(id: string): void { + const log = requestLog.get(id) || []; + log.push(Date.now()); + requestLog.set(id, log); +} + +function noteSuccess(id: string): void { + const s: SourceStatus = status.get(id) || { lastSuccess: null, lastError: null, lastErrorMessage: null }; + s.lastSuccess = new Date().toISOString(); + s.lastError = null; + s.lastErrorMessage = null; + status.set(id, s); +} + +function noteError(id: string, err: unknown): void { + const s: SourceStatus = status.get(id) || { lastSuccess: null, lastError: null, lastErrorMessage: null }; + s.lastError = new Date().toISOString(); + s.lastErrorMessage = errorMessage(err); + status.set(id, s); +} + +/** Clear caches (used by the test panel). */ +export function reset(): void { + cache.clear(); + requestLog.clear(); +} + +/** Convenience entry point used by chambers. */ +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const entropy: EntropyService = { request, getSourceStatus, testAllSources, reset, setEnabled, setPreferred }; +export default entropy; diff --git a/src/lib/entropy/sources/anu-quantum.js b/src/lib/entropy/sources/anu-quantum.js index d8327b9..6b01b95 100644 --- a/src/lib/entropy/sources/anu-quantum.js +++ b/src/lib/entropy/sources/anu-quantum.js @@ -10,19 +10,32 @@ export const id = 'anu-quantum'; export const displayName = 'ANU Quantum Vacuum'; export const flavor = 'quantum'; -export async function fetchRaw(byteCount) { - const n = Math.max(1, Math.min(1024, byteCount | 0)); - const url = `https://qrng.anu.edu.au/API/jsonI.php?length=${n}&type=uint8`; - const data = await fetchJSON(url); - if (!data?.success || !Array.isArray(data.data) || data.data.length < n) { - throw new Error('ANU Quantum: unsuccessful or short response'); +export async function fetchRaw(byteCount, { signal } = {}) { + const total = Math.max(1, byteCount | 0); + const bytes = new Uint8Array(total); + let offset = 0; + let requestCount = 0; + while (offset < total) { + const n = Math.min(1024, total - offset); + const url = `https://qrng.anu.edu.au/API/jsonI.php?length=${n}&type=uint8`; + const data = await fetchJSON(url, { signal }); + if (!data?.success || !Array.isArray(data.data) || data.data.length < n) { + throw new Error('ANU Quantum: unsuccessful or short response'); + } + const chunk = data.data.slice(0, n); + if (!chunk.every((value) => Number.isInteger(value) && value >= 0 && value <= 255)) { + throw new Error('ANU Quantum: response contained a non-byte value'); + } + bytes.set(chunk, offset); + offset += n; + requestCount += 1; } - const bytes = new Uint8Array(data.data.slice(0, n)); return { bytes, extra: { - source_url: url, - fetched_count: n, + source_url: 'https://qrng.anu.edu.au/API/jsonI.php', + fetched_count: total, + request_count: requestCount, type: 'uint8', }, }; diff --git a/src/lib/entropy/sources/nist-beacon.js b/src/lib/entropy/sources/nist-beacon.js index 174df69..cc08133 100644 --- a/src/lib/entropy/sources/nist-beacon.js +++ b/src/lib/entropy/sources/nist-beacon.js @@ -13,8 +13,8 @@ export const flavor = 'beacon'; const LAST_PULSE_URL = 'https://beacon.nist.gov/beacon/2.0/pulse/last'; -export async function fetchRaw(byteCount) { - const data = await fetchJSON(LAST_PULSE_URL); +export async function fetchRaw(byteCount, { signal } = {}) { + const data = await fetchJSON(LAST_PULSE_URL, { signal }); const pulse = data?.pulse; if (!pulse || !pulse.outputValue) { throw new Error('NIST Beacon: missing pulse.outputValue in response'); diff --git a/src/lib/entropy/sources/open-meteo.js b/src/lib/entropy/sources/open-meteo.js index b80b464..5a73521 100644 --- a/src/lib/entropy/sources/open-meteo.js +++ b/src/lib/entropy/sources/open-meteo.js @@ -20,8 +20,8 @@ const CITIES = [ const FIELDS = 'temperature_2m,wind_speed_10m,pressure_msl,relative_humidity_2m'; -export async function fetchRaw(byteCount) { - const results = await Promise.all(CITIES.map(fetchCity)); +export async function fetchRaw(byteCount, { signal } = {}) { + const results = await Promise.all(CITIES.map((city) => fetchCity(city, signal))); const okCities = results.filter((r) => r); if (okCities.length === 0) throw new Error('Open-Meteo: all city requests failed'); @@ -45,10 +45,10 @@ export async function fetchRaw(byteCount) { }; } -async function fetchCity(city) { +async function fetchCity(city, signal) { try { const url = `https://api.open-meteo.com/v1/forecast?latitude=${city.lat}&longitude=${city.lon}¤t=${FIELDS}`; - const data = await fetchJSON(url); + const data = await fetchJSON(url, { signal }); if (!data?.current) return null; return { name: city.name, current: data.current }; } catch { diff --git a/src/lib/entropy/sources/random-org.js b/src/lib/entropy/sources/random-org.js index ee8fc34..6f9c3b0 100644 --- a/src/lib/entropy/sources/random-org.js +++ b/src/lib/entropy/sources/random-org.js @@ -9,10 +9,10 @@ export const id = 'random-org'; export const displayName = 'random.org Atmospheric Noise'; export const flavor = 'atmospheric'; -export async function fetchRaw(byteCount) { +export async function fetchRaw(byteCount, { signal } = {}) { const n = Math.max(1, byteCount | 0); const url = `https://www.random.org/integers/?num=${n}&min=0&max=255&col=1&base=10&format=plain&rnd=new`; - const text = await fetchText(url); + const text = await fetchText(url, { signal }); if (/Error/i.test(text)) { throw new Error(`random.org returned an error response: ${text.slice(0, 80)}`); } diff --git a/src/lib/entropy/sources/system.js b/src/lib/entropy/sources/system.js index 91a7ca9..d8b4ffa 100644 --- a/src/lib/entropy/sources/system.js +++ b/src/lib/entropy/sources/system.js @@ -1,10 +1,10 @@ /** - * System fallback: crypto.getRandomValues(). + * On-device source: crypto.getRandomValues(). * Honestly labeled. Only used when every external source has failed. */ export const id = 'system'; -export const displayName = 'Local system randomness'; +export const displayName = 'On-device randomness'; export const flavor = 'system'; export async function fetchRaw(byteCount) { @@ -19,5 +19,5 @@ export async function fetchRaw(byteCount) { } export function describe(_extra) { - return 'Local system randomness (no external source reachable). Honest local PRNG; not a public-provenance source.'; + return 'On-device cryptographic randomness from WebView2 (crypto.getRandomValues). No network request; not a public-provenance source.'; } diff --git a/src/lib/entropy/sources/usgs-seismic.js b/src/lib/entropy/sources/usgs-seismic.js index d629b78..06d3400 100644 --- a/src/lib/entropy/sources/usgs-seismic.js +++ b/src/lib/entropy/sources/usgs-seismic.js @@ -12,8 +12,8 @@ export const flavor = 'seismic'; const FEED_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson'; -export async function fetchRaw(byteCount) { - const data = await fetchJSON(FEED_URL); +export async function fetchRaw(byteCount, { signal } = {}) { + const data = await fetchJSON(FEED_URL, { signal }); const features = Array.isArray(data?.features) ? data.features : []; if (features.length === 0) throw new Error('USGS feed returned no earthquakes'); diff --git a/src/lib/fs.js b/src/lib/fs.js deleted file mode 100644 index c4c48e4..0000000 --- a/src/lib/fs.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Archive read/write helpers. Wraps the Tauri fs plugin. - * All paths are relative to the app data directory; the plugin resolves to: - * - Windows: %APPDATA%/Sortilune/ - * - macOS: ~/Library/Application Support/Sortilune/ - * - Linux: ~/.local/share/Sortilune/ - * - * In dev or when running outside Tauri, these methods no-op safely. - */ - -let _fs = null; -let _baseDir = null; - -async function getFS() { - if (_fs !== null) return _fs; - try { - if (window.__TAURI_INTERNALS__) { - const mod = await import('@tauri-apps/plugin-fs'); - _fs = mod; - _baseDir = mod.BaseDirectory.AppData; - } else { - _fs = false; // mark "no fs" - } - } catch { - _fs = false; - } - return _fs; -} - -export async function ensureDir(rel) { - const fs = await getFS(); - if (!fs) return; - try { - await fs.mkdir(rel, { baseDir: _baseDir, recursive: true }); - } catch (e) { - // If it already exists, that's fine. - if (!/exists/i.test(String(e))) throw e; - } -} - -export async function writeText(rel, text) { - const fs = await getFS(); - if (!fs) return null; - const dir = rel.replace(/[^/]+$/, '').replace(/\/$/, ''); - if (dir) await ensureDir(dir); - await fs.writeTextFile(rel, text, { baseDir: _baseDir }); - return rel; -} - -export async function writeJSON(rel, obj) { - return writeText(rel, JSON.stringify(obj, null, 2)); -} - -export async function writeBytes(rel, bytes) { - const fs = await getFS(); - if (!fs) return null; - const dir = rel.replace(/[^/]+$/, '').replace(/\/$/, ''); - if (dir) await ensureDir(dir); - await fs.writeFile(rel, bytes, { baseDir: _baseDir }); - return rel; -} - -export async function readText(rel) { - const fs = await getFS(); - if (!fs) return null; - return await fs.readTextFile(rel, { baseDir: _baseDir }); -} - -export async function readJSON(rel) { - const txt = await readText(rel); - if (txt == null) return null; - return JSON.parse(txt); -} - -export async function listDir(rel) { - const fs = await getFS(); - if (!fs) return []; - try { - return await fs.readDir(rel, { baseDir: _baseDir }); - } catch { - return []; - } -} - -export async function exists(rel) { - const fs = await getFS(); - if (!fs) return false; - try { - return await fs.exists(rel, { baseDir: _baseDir }); - } catch { - return false; - } -} - -export async function archiveRoot() { - return 'archive'; -} - -export async function isAvailable() { - return !!(await getFS()); -} diff --git a/src/lib/fs.ts b/src/lib/fs.ts new file mode 100644 index 0000000..7f39bcb --- /dev/null +++ b/src/lib/fs.ts @@ -0,0 +1,159 @@ +/** + * Archive read/write helpers. Wraps the Tauri fs plugin. + * All paths are relative to the app data directory; the plugin resolves to: + * The exact application-data root is resolved by Tauri from the configured + * com.sortilune.desktop identifier; callers never construct that root. + * + * Read probes degrade safely outside Tauri; writes report an explicit error. + */ + +import { + BaseDirectory, + exists as fsExists, + readDir, + readTextFile, + watch, + type DirEntry, + type WatchEvent, +} from '@tauri-apps/plugin-fs'; +import { invoke } from '@tauri-apps/api/core'; + +type ArchiveBatchFile = { rel: string; bytes: Uint8Array }; + +declare global { + interface Window { + __TAURI_INTERNALS__?: unknown; + } +} + +function fsAvailable(): boolean { + return Boolean(window.__TAURI_INTERNALS__); +} + +export async function writeText(rel: string, text: string): Promise { + return writeBytes(rel, new TextEncoder().encode(text)); +} + +export async function writeJSON(rel: string, obj: unknown): Promise { + return writeText(rel, JSON.stringify(obj, null, 2)); +} + +export async function writeBytes(rel: string, bytes: Uint8Array): Promise { + if (!window.__TAURI_INTERNALS__) { + throw new Error('Archive storage is available only in the desktop build'); + } + return invoke('write_archive', { rel, bytes: Array.from(bytes) }); +} + +export async function writeBatch(files: ArchiveBatchFile[]): Promise { + if (!window.__TAURI_INTERNALS__) { + throw new Error('Archive storage is available only in the desktop build'); + } + if (!Array.isArray(files) || files.length === 0) throw new TypeError('archive batch must contain files'); + return invoke('write_archive_batch', { + files: files.map(({ rel, bytes }) => ({ rel, bytes: Array.from(bytes) })), + }); +} + +export async function writeArchiveAnnotations(bytes: Uint8Array): Promise { + if (!window.__TAURI_INTERNALS__) { + throw new Error('Archive annotations are available only in the desktop build'); + } + return invoke('write_archive_annotations', { bytes: Array.from(bytes) }); +} + +export async function writeArchiveCache(name: string, bytes: Uint8Array): Promise { + if (!window.__TAURI_INTERNALS__) { + throw new Error('Archive cache is available only in the desktop build'); + } + return invoke('write_archive_cache', { name, bytes: Array.from(bytes) }); +} + +export async function clearArchiveCache(): Promise { + if (!window.__TAURI_INTERNALS__) return 0; + return invoke('clear_archive_cache'); +} + +export type ArchiveWatchListener = (paths: string[], event: WatchEvent) => void | Promise; + +export async function watchArchive(listener: ArchiveWatchListener): Promise<() => void> { + if (!fsAvailable()) return () => undefined; + return watch('archive', (event) => { + const paths = event.paths.map(normalizeArchiveWatchPath).filter((path): path is string => Boolean(path)); + if (paths.length > 0) { + void Promise.resolve(listener(paths, event)).catch((error) => { + console.error('Archive watcher refresh failed', error); + }); + } + }, { + baseDir: BaseDirectory.AppData, + recursive: true, + delayMs: 250, + }); +} + +export function normalizeArchiveWatchPath(input: string): string | null { + const normalized = input.replaceAll('\\', '/').replace(/\/+$/u, ''); + const relative = normalized.startsWith('archive/') + ? normalized + : normalized === 'archive' + ? normalized + : (() => { + const marker = '/archive/'; + const offset = normalized.lastIndexOf(marker); + if (offset >= 0) return `archive/${normalized.slice(offset + marker.length)}`; + return normalized.endsWith('/archive') ? 'archive' : null; + })(); + if (!relative || relative.includes('/../') || relative.includes('/./')) return null; + return relative; +} + +export async function readText(rel: string): Promise { + if (!fsAvailable()) return null; + if (!(await exists(rel))) return null; + try { + return await readTextFile(rel, { baseDir: BaseDirectory.AppData }); + } catch (error) { + if (!(await exists(rel))) return null; + throw error; + } +} + +export async function readJSON(rel: string): Promise { + const txt = await readText(rel); + if (txt == null) return null; + return JSON.parse(txt); +} + +export async function listDir(rel: string): Promise { + if (!fsAvailable()) return []; + try { + return await readDir(rel, { baseDir: BaseDirectory.AppData }); + } catch { + return []; + } +} + +export async function exists(rel: string): Promise { + if (!fsAvailable()) return false; + try { + return await fsExists(rel, { baseDir: BaseDirectory.AppData }); + } catch { + return false; + } +} + +export async function archiveRoot(): Promise<'archive'> { + return 'archive'; +} + +export async function isAvailable(): Promise { + return fsAvailable(); +} + +export async function revealArchive(): Promise { + if (!window.__TAURI_INTERNALS__) { + throw new Error('The archive folder can be opened only in the desktop build'); + } + return invoke('reveal_archive'); +} diff --git a/src/lib/http.js b/src/lib/http.js index 9b0b4fe..0f35fea 100644 --- a/src/lib/http.js +++ b/src/lib/http.js @@ -5,20 +5,30 @@ */ let _tauriFetch = null; -let _initTried = false; +let _initPromise = null; async function getFetch() { - if (_initTried) return _tauriFetch || window.fetch.bind(window); - _initTried = true; + if (!_initPromise) { + _initPromise = (async () => { + try { + if (window.__TAURI_INTERNALS__) { + const mod = await import('@tauri-apps/plugin-http'); + _tauriFetch = mod.fetch; + } + } catch { + _tauriFetch = null; + } + return _tauriFetch || window.fetch.bind(window); + })(); + } try { - if (window.__TAURI_INTERNALS__) { - const mod = await import('@tauri-apps/plugin-http'); - _tauriFetch = mod.fetch; - } + return await _initPromise; } catch { - _tauriFetch = null; + // The initializer is deliberately non-throwing, but retain a safe web + // fallback if a future edit changes that contract. + _initPromise = null; + return window.fetch.bind(window); } - return _tauriFetch || window.fetch.bind(window); } const DEFAULT_HEADERS = { @@ -37,9 +47,11 @@ function makeOptions(opts = {}) { export async function fetchText(url, opts = {}) { const f = await getFetch(); - const res = await f(url, makeOptions(opts)); - if (!res.ok) throw httpError(res, url); - return await res.text(); + return timedFetch(opts, async (signal) => { + const res = await f(url, makeOptions({ ...opts, signal })); + if (!res.ok) throw httpError(res, url); + return await res.text(); + }); } export async function fetchJSON(url, opts = {}) { @@ -47,16 +59,44 @@ export async function fetchJSON(url, opts = {}) { try { return JSON.parse(text); } catch (e) { - throw new Error(`Bad JSON from ${url}: ${e.message}`); + throw new Error(`Bad JSON from ${url}: ${e.message}`, { cause: e }); } } export async function fetchBytes(url, opts = {}) { const f = await getFetch(); - const res = await f(url, makeOptions(opts)); - if (!res.ok) throw httpError(res, url); - const buf = await res.arrayBuffer(); - return new Uint8Array(buf); + return timedFetch(opts, async (signal) => { + const res = await f(url, makeOptions({ ...opts, signal })); + if (!res.ok) throw httpError(res, url); + const buf = await res.arrayBuffer(); + return new Uint8Array(buf); + }); +} + +async function timedFetch(opts, operation) { + const controller = new AbortController(); + const timeoutMs = opts.timeoutMs ?? 10_000; + if (!Number.isFinite(timeoutMs) || timeoutMs < 1 || timeoutMs > 120_000) { + throw new RangeError('HTTP timeoutMs must be between 1 and 120000 milliseconds'); + } + const abortFromParent = () => controller.abort(opts.signal?.reason); + if (opts.signal?.aborted) abortFromParent(); + else opts.signal?.addEventListener('abort', abortFromParent, { once: true }); + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(`HTTP request timed out after ${timeoutMs}ms`); + controller.abort(error); + reject(error); + }, timeoutMs); + }); + const running = Promise.resolve().then(() => operation(controller.signal)); + try { + return await Promise.race([running, timeout]); + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener('abort', abortFromParent); + } } function httpError(res, url) { @@ -68,9 +108,18 @@ function httpError(res, url) { /** Race a promise against a timeout. Throws on timeout. */ export function withTimeout(promise, ms, label = 'operation') { + if (!Number.isFinite(ms) || ms < 1 || ms > 120_000) { + return Promise.reject(new RangeError('timeout must be between 1 and 120000 milliseconds')); + } + const operation = promise; + const controller = new AbortController(); + const running = typeof operation === 'function' ? operation(controller.signal) : operation; let to; const t = new Promise((_, rej) => { - to = setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms); + to = setTimeout(() => { + controller.abort(); + rej(new Error(`${label} timed out after ${ms}ms`)); + }, ms); }); - return Promise.race([promise, t]).finally(() => clearTimeout(to)); + return Promise.race([running, t]).finally(() => clearTimeout(to)); } diff --git a/src/lib/nav.js b/src/lib/nav.js deleted file mode 100644 index 195f424..0000000 --- a/src/lib/nav.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Chamber loader & switcher. Lazy-imports each chamber's index.js the first - * time it is shown; unmount() is awaited before the next mount. - */ - -import { CHAMBER_BY_ID } from '../chambers/manifest.js'; -import { getState, setState } from './state.js'; - -const loaders = { - oracle: () => import('../chambers/oracle/index.js'), - decider: () => import('../chambers/decider/index.js'), - diary: () => import('../chambers/diary/index.js'), - constraint: () => import('../chambers/constraint/index.js'), - canvas: () => import('../chambers/canvas/index.js'), - symphony: () => import('../chambers/symphony/index.js'), - beacon: () => import('../chambers/beacon/index.js'), - lottery: () => import('../chambers/lottery/index.js'), -}; - -let _rootEl = null; -let _ctx = null; -let _current = null; // active chamber module instance - -export function attach(rootEl, ctx) { - _rootEl = rootEl; - _ctx = ctx; -} - -export async function go(id) { - if (!CHAMBER_BY_ID[id]) throw new Error(`Unknown chamber: ${id}`); - if (_current && _current.id === id) return; - - if (_current) { - try { await _current.unmount?.(); } catch (e) { console.error('unmount failed', e); } - } - - _rootEl.innerHTML = ''; - const mod = await loaders[id](); - _current = mod; - await mod.mount(_rootEl, _ctx); - setState({ currentChamber: id }); - document.dispatchEvent(new CustomEvent('chamber-changed', { detail: { id } })); -} - -export function currentId() { - return _current?.id ?? getState().currentChamber; -} diff --git a/src/lib/nav.ts b/src/lib/nav.ts new file mode 100644 index 0000000..3caa501 --- /dev/null +++ b/src/lib/nav.ts @@ -0,0 +1,132 @@ +import type { AppContext } from '../domain/contracts.js'; +import type { RouteReference } from '../domain/settings.js'; +import { getState, setState } from './state.js'; +import { RouteRegistry, type RouteModule } from './routes.js'; + +export const routes = new RouteRegistry() + .register({ id: 'today', kind: 'workspace', load: () => import('../features/today/index.js') }) + .register({ id: 'oracle', kind: 'chamber', load: () => import('../chambers/oracle/index.js') }) + .register({ id: 'decider', kind: 'chamber', load: () => import('../chambers/decider/index.js') }) + .register({ id: 'diary', kind: 'chamber', load: () => import('../chambers/diary/index.js') }) + .register({ id: 'constraint', kind: 'chamber', load: () => import('../chambers/constraint/index.js') }) + .register({ id: 'canvas', kind: 'chamber', load: () => import('../chambers/canvas/index.js') }) + .register({ id: 'symphony', kind: 'chamber', load: () => import('../chambers/symphony/index.js') }) + .register({ id: 'beacon', kind: 'chamber', load: () => import('../chambers/beacon/index.js') }) + .register({ id: 'lottery', kind: 'chamber', load: () => import('../chambers/lottery/index.js') }) + .register({ id: 'projects', kind: 'workspace', load: () => import('../features/projects/index.js') }) + .register({ id: 'practices', kind: 'workspace', load: () => import('../features/practices/index.js') }) + .register({ id: 'journal', kind: 'workspace', load: () => import('../features/journal/index.js') }) + .register({ id: 'archive', kind: 'system', load: () => import('../archive/browser.js') }); + +let rootElement: HTMLElement | null = null; +let appContext: AppContext | null = null; +let currentModule: RouteModule | null = null; +let currentRoute: RouteReference | null = null; +let requestedNavigation = 0; +let navigationQueue: Promise = Promise.resolve(); + +export function attach(root: HTMLElement, context: AppContext): void { + rootElement = root; + appContext = context; +} + +export function go(destination: string | RouteReference, params: Record = {}): Promise { + let route: RouteReference; + try { + route = routes.resolve(destination, params); + } catch (error) { + return Promise.reject(error); + } + const requestId = ++requestedNavigation; + if (rootElement) { + rootElement.setAttribute('aria-busy', 'true'); + dispatchNavigationStatus('route-navigation-started', route); + } + navigationQueue = navigationQueue.catch(() => {}).then(() => performGo(route, requestId)); + return navigationQueue; +} + +async function performGo(route: RouteReference, requestId: number): Promise { + if (!rootElement || !appContext) throw new Error('navigation is not attached'); + if (requestId !== requestedNavigation) return; + if (currentRoute && routesEqual(currentRoute, route)) { + rootElement.setAttribute('aria-busy', 'false'); + return; + } + + const previous = currentModule; + currentModule = null; + currentRoute = null; + if (previous) { + try { + await previous.unmount?.(); + } catch (error) { + console.error('unmount failed', error); + } + } + appContext.projects.resetRouteHost(); + if (requestId !== requestedNavigation) return; + + rootElement.innerHTML = ''; + let module: RouteModule; + try { + module = await routes.get(route.destination).load(); + if (requestId !== requestedNavigation) return; + const routeRoot = appContext.projects.createRouteHost(rootElement, route, appContext.navigate); + await module.mount(routeRoot, appContext, route); + } catch (error) { + if (requestId === requestedNavigation) { + renderNavigationError(rootElement, route.destination, error); + rootElement.setAttribute('aria-busy', 'false'); + dispatchNavigationStatus('route-navigation-failed', route); + } + throw error; + } + if (requestId !== requestedNavigation) { + try { + await module.unmount?.(); + } catch { + // Stale module cleanup is best-effort. + } + return; + } + currentModule = module; + currentRoute = route; + const definition = routes.get(route.destination); + const statePatch: Parameters[0] = { route }; + if (definition.kind === 'chamber') statePatch.currentChamber = route.destination; + setState(statePatch); + rootElement.setAttribute('aria-busy', 'false'); + document.dispatchEvent(new CustomEvent('chamber-changed', { detail: { id: route.destination, route } })); + const pageTitle = rootElement.querySelector('[data-page-title], h1'); + if (pageTitle) { + if (!pageTitle.hasAttribute('tabindex')) pageTitle.tabIndex = -1; + pageTitle.focus({ preventScroll: true }); + } +} + +function dispatchNavigationStatus(type: 'route-navigation-started' | 'route-navigation-failed', route: RouteReference): void { + document.dispatchEvent(new CustomEvent(type, { detail: { id: route.destination, route } })); +} + +function renderNavigationError(root: HTMLElement, destination: string, error: unknown): void { + root.innerHTML = ''; + const panel = document.createElement('div'); + panel.className = 'panel'; + const title = document.createElement('h2'); + title.textContent = `Could not open ${destination}`; + const detail = document.createElement('p'); + detail.className = 'muted'; + detail.textContent = error instanceof Error ? error.message : String(error); + panel.append(title, detail); + root.appendChild(panel); +} + +function routesEqual(left: RouteReference, right: RouteReference): boolean { + return left.destination === right.destination + && JSON.stringify(left.params) === JSON.stringify(right.params); +} + +export function currentId(): string { + return currentRoute?.destination ?? getState().route.destination; +} diff --git a/src/lib/notify.js b/src/lib/notify.js new file mode 100644 index 0000000..4bb288b --- /dev/null +++ b/src/lib/notify.js @@ -0,0 +1,18 @@ +import { h } from './dom.js'; +import { ToastRegion } from '../ui/primitives.js'; + +/** Show a short, non-blocking status message. */ +export function toast(message, kind = 'info') { + const el = h('div', { + class: `toast toast-${kind}`, + role: kind === 'danger' ? 'alert' : 'status', + 'aria-live': kind === 'danger' ? 'assertive' : 'polite', + }, [String(message)]); + ToastRegion().appendChild(el); + el.offsetWidth; + el.classList.add('visible'); + setTimeout(() => { + el.classList.remove('visible'); + setTimeout(() => el.remove(), 300); + }, 2800); +} diff --git a/src/lib/routes.ts b/src/lib/routes.ts new file mode 100644 index 0000000..28d4b0d --- /dev/null +++ b/src/lib/routes.ts @@ -0,0 +1,73 @@ +import type { RouteReference } from '../domain/settings.js'; +import type { AppContext } from '../domain/contracts.js'; + +export type RouteKind = 'chamber' | 'workspace' | 'system'; + +export interface RouteModule { + id: string; + mount(root: HTMLElement, context: AppContext, route?: RouteReference): void | Promise; + unmount?(): void | Promise; +} + +export interface RouteDefinition { + id: string; + kind: RouteKind; + parent?: string; + load(): Promise; +} + +const DESTINATION_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; + +export class RouteRegistry { + readonly #routes = new Map(); + + register(definition: RouteDefinition): this { + if (!DESTINATION_PATTERN.test(definition.id)) throw new TypeError(`invalid route destination: ${definition.id}`); + if (this.#routes.has(definition.id)) throw new Error(`duplicate route destination: ${definition.id}`); + if (definition.parent === definition.id) throw new Error('a route cannot parent itself'); + if (definition.parent && !this.#routes.has(definition.parent)) { + throw new Error(`route parent must be registered first: ${definition.parent}`); + } + this.#routes.set(definition.id, Object.freeze({ ...definition })); + return this; + } + + has(destination: string): boolean { + return this.#routes.has(destination); + } + + get(destination: string): RouteDefinition { + const route = this.#routes.get(destination); + if (!route) throw new Error(`Unknown destination: ${destination}`); + return route; + } + + resolve(destination: string | RouteReference, params: Record = {}): RouteReference { + const reference = typeof destination === 'string' ? { destination, params } : destination; + this.get(reference.destination); + const normalized: Record = {}; + const entries = Object.entries(reference.params ?? {}); + if (entries.length > 16) throw new TypeError('route accepts at most 16 parameters'); + for (const [key, value] of entries) { + if (key.length > 64 || typeof value !== 'string' || value.length > 256) { + throw new TypeError(`invalid route parameter: ${key}`); + } + normalized[key] = value; + } + return { destination: reference.destination, params: normalized }; + } + + lineage(destination: string): RouteDefinition[] { + const lineage: RouteDefinition[] = []; + let current: RouteDefinition | undefined = this.get(destination); + while (current) { + lineage.unshift(current); + current = current.parent ? this.get(current.parent) : undefined; + } + return lineage; + } + + list(): RouteDefinition[] { + return [...this.#routes.values()]; + } +} diff --git a/src/lib/state.js b/src/lib/state.js deleted file mode 100644 index 925822e..0000000 --- a/src/lib/state.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Central app state with simple pub/sub. - * Chambers should mutate via setState(patch) — never assign directly to the - * returned getState() result. The store is intentionally tiny; the universe is - * not, but the app's local memory is. - */ - -const KEY = 'sortilune.state.v1'; - -const defaults = { - currentChamber: 'oracle', - theme: 'cosmic-dark', - lastUsedDeck: 'cosmic', - preferredEntropySource: 'preferred', - enabledSources: { - 'nist-beacon': true, - 'anu-quantum': true, - 'random-org': true, - 'usgs-seismic': true, - 'open-meteo': true, - 'system': true, - }, - starfield: true, -}; - -let state = load(); -const listeners = new Set(); - -function load() { - try { - const raw = localStorage.getItem(KEY); - if (!raw) return { ...defaults }; - return { ...defaults, ...JSON.parse(raw) }; - } catch { - return { ...defaults }; - } -} - -function persist() { - try { - localStorage.setItem(KEY, JSON.stringify(state)); - } catch { - /* storage may be unavailable; the app must still run */ - } -} - -export function getState() { - return state; -} - -export function setState(patch) { - const before = state; - state = { ...state, ...patch }; - persist(); - for (const fn of listeners) { - try { fn(state, before); } catch (e) { console.error('state listener failed', e); } - } -} - -export function subscribe(fn) { - listeners.add(fn); - return () => listeners.delete(fn); -} - -export const DEFAULTS = defaults; diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..7751295 --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,225 @@ +import type { MotionPreference, RailMode, RouteReference, SettingsV2, ThemeId } from '../domain/settings.js'; +import { validateSettingsV2 } from '../schemas/validate.js'; + +const SETTINGS_KEY = 'sortilune.settings.v2'; +const LEGACY_KEY = 'sortilune.state.v1'; + +const CHAMBER_IDS = new Set(['oracle', 'decider', 'diary', 'constraint', 'canvas', 'symphony', 'beacon', 'lottery']); +const ROUTE_IDS = new Set([...CHAMBER_IDS, 'today', 'projects', 'practices', 'journal', 'archive']); +const THEME_IDS = new Set(['cosmic-dark', 'cosmic-light', 'high-contrast']); +const DECK_IDS = new Set(['tarot', 'i-ching', 'runes', 'cosmic'] as const); +const DEFAULT_ENABLED_SOURCES: Readonly> = Object.freeze({ + 'nist-beacon': true, + 'anu-quantum': true, + 'random-org': true, + 'usgs-seismic': true, + 'open-meteo': true, + system: true, +}); +const SOURCE_IDS = new Set(['preferred', ...Object.keys(DEFAULT_ENABLED_SOURCES)]); +const RAIL_MODES = new Set(['auto', 'expanded', 'compact']); + +export interface AppState { + route: RouteReference; + railMode: RailMode; + currentChamber: string; + theme: ThemeId; + lastUsedDeck: 'tarot' | 'i-ching' | 'runes' | 'cosmic'; + preferredEntropySource: string; + enabledSources: Readonly>; + starfield: boolean; + reduceMotion: MotionPreference; + searchDiaryBody: boolean; + onThisDay: boolean; +} + +const DEFAULT_STATE: AppState = freezeState({ + route: { destination: 'oracle', params: {} }, + railMode: 'auto', + currentChamber: 'oracle', + theme: 'cosmic-dark', + lastUsedDeck: 'cosmic', + preferredEntropySource: 'preferred', + enabledSources: DEFAULT_ENABLED_SOURCES, + starfield: true, + reduceMotion: 'system', + searchDiaryBody: false, + onThisDay: true, +}); + +const FRESH_STATE: AppState = freezeState({ + ...DEFAULT_STATE, + route: { destination: 'today', params: {} }, +}); + +type StateListener = (state: AppState, previous: AppState) => void; + +let state = loadState(); +const listeners = new Set(); + +export function migrateSettings(candidate: unknown): SettingsV2 { + const input = objectRecord(candidate); + const enabledInput = objectRecord(input.enabledSources); + const enabledSources: Record = {}; + for (const [id, defaultValue] of Object.entries(DEFAULT_ENABLED_SOURCES)) { + enabledSources[id] = typeof enabledInput[id] === 'boolean' ? enabledInput[id] : defaultValue; + } + const currentChamber = typeof input.currentChamber === 'string' && CHAMBER_IDS.has(input.currentChamber) + ? input.currentChamber + : DEFAULT_STATE.currentChamber; + const theme = typeof input.theme === 'string' && THEME_IDS.has(input.theme as ThemeId) + ? input.theme as ThemeId + : DEFAULT_STATE.theme; + const preferredSource = typeof input.preferredEntropySource === 'string' && SOURCE_IDS.has(input.preferredEntropySource) + ? input.preferredEntropySource + : DEFAULT_STATE.preferredEntropySource; + const lastUsedDeck = typeof input.lastUsedDeck === 'string' && DECK_IDS.has(input.lastUsedDeck as AppState['lastUsedDeck']) + ? input.lastUsedDeck as AppState['lastUsedDeck'] + : DEFAULT_STATE.lastUsedDeck; + return { + schema: 'sortilune.settings', + schema_version: 2, + route: { destination: currentChamber, params: {} }, + navigation: { rail_mode: 'auto' }, + theme, + entropy: { preferred_source: preferredSource, enabled_sources: enabledSources }, + visual: { + starfield: typeof input.starfield === 'boolean' ? input.starfield : DEFAULT_STATE.starfield, + reduce_motion: 'system', + }, + archive: { search_diary_body: false, on_this_day: true }, + chambers: { last_used_deck: lastUsedDeck }, + }; +} + +export function getState(): AppState { + return state; +} + +export function setState(patch: Partial): void { + const before = state; + state = normalizeState({ ...state, ...patch }); + persist(state); + for (const listener of listeners) { + try { + listener(state, before); + } catch (error) { + console.error('state listener failed', error); + } + } +} + +export function subscribe(listener: StateListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +function loadState(): AppState { + try { + const storage = globalThis.localStorage; + if (!storage) return FRESH_STATE; + const rawV2 = storage.getItem(SETTINGS_KEY); + if (rawV2) { + const parsed: unknown = JSON.parse(rawV2); + if (validateSettingsV2(parsed)) return settingsToState(parsed as SettingsV2); + } + const legacyRaw = storage.getItem(LEGACY_KEY); + const migrated = legacyRaw ? migrateSettings(JSON.parse(legacyRaw)) : stateToSettings(FRESH_STATE); + storage.setItem(SETTINGS_KEY, JSON.stringify(migrated)); + return settingsToState(migrated); + } catch { + return FRESH_STATE; + } +} + +function persist(value: AppState): void { + try { + globalThis.localStorage?.setItem(SETTINGS_KEY, JSON.stringify(stateToSettings(value))); + } catch { + // Storage can be unavailable; in-memory state must continue to work. + } +} + +function normalizeState(candidate: AppState): AppState { + const currentChamber = CHAMBER_IDS.has(candidate.currentChamber) ? candidate.currentChamber : DEFAULT_STATE.currentChamber; + const route = normalizeRoute(candidate.route, currentChamber); + const enabledSources: Record = {}; + for (const [id, defaultValue] of Object.entries(DEFAULT_ENABLED_SOURCES)) { + enabledSources[id] = typeof candidate.enabledSources?.[id] === 'boolean' ? candidate.enabledSources[id] : defaultValue; + } + return freezeState({ + route, + railMode: RAIL_MODES.has(candidate.railMode) ? candidate.railMode : DEFAULT_STATE.railMode, + currentChamber, + theme: THEME_IDS.has(candidate.theme) ? candidate.theme : DEFAULT_STATE.theme, + lastUsedDeck: DECK_IDS.has(candidate.lastUsedDeck) ? candidate.lastUsedDeck : DEFAULT_STATE.lastUsedDeck, + preferredEntropySource: SOURCE_IDS.has(candidate.preferredEntropySource) + ? candidate.preferredEntropySource + : DEFAULT_STATE.preferredEntropySource, + enabledSources, + starfield: typeof candidate.starfield === 'boolean' ? candidate.starfield : DEFAULT_STATE.starfield, + reduceMotion: ['system', 'reduce', 'allow'].includes(candidate.reduceMotion) ? candidate.reduceMotion : 'system', + searchDiaryBody: typeof candidate.searchDiaryBody === 'boolean' ? candidate.searchDiaryBody : false, + onThisDay: typeof candidate.onThisDay === 'boolean' ? candidate.onThisDay : true, + }); +} + +function normalizeRoute(route: RouteReference | undefined, fallback: string): RouteReference { + const isRegistered = typeof route?.destination === 'string' && ROUTE_IDS.has(route.destination); + const destination = isRegistered ? route.destination : fallback; + const params: Record = {}; + for (const [key, value] of Object.entries(objectRecord(isRegistered ? route.params : null)).slice(0, 16)) { + if (key.length <= 64 && typeof value === 'string' && value.length <= 256) params[key] = value; + } + return { destination, params }; +} + +function settingsToState(settings: SettingsV2): AppState { + const currentChamber = CHAMBER_IDS.has(settings.route.destination) + ? settings.route.destination + : DEFAULT_STATE.currentChamber; + return normalizeState({ + route: settings.route, + railMode: settings.navigation?.rail_mode ?? DEFAULT_STATE.railMode, + currentChamber, + theme: settings.theme, + lastUsedDeck: settings.chambers.last_used_deck, + preferredEntropySource: settings.entropy.preferred_source, + enabledSources: settings.entropy.enabled_sources, + starfield: settings.visual.starfield, + reduceMotion: settings.visual.reduce_motion, + searchDiaryBody: settings.archive?.search_diary_body ?? false, + onThisDay: settings.archive?.on_this_day ?? true, + }); +} + +function stateToSettings(value: AppState): SettingsV2 { + return { + schema: 'sortilune.settings', + schema_version: 2, + route: value.route, + navigation: { rail_mode: value.railMode }, + theme: value.theme, + entropy: { + preferred_source: value.preferredEntropySource, + enabled_sources: { ...value.enabledSources }, + }, + visual: { starfield: value.starfield, reduce_motion: value.reduceMotion }, + archive: { search_diary_body: value.searchDiaryBody, on_this_day: value.onThisDay }, + chambers: { last_used_deck: value.lastUsedDeck }, + }; +} + +function freezeState(value: AppState): AppState { + return Object.freeze({ + ...value, + route: Object.freeze({ ...value.route, params: Object.freeze({ ...value.route.params }) }), + enabledSources: Object.freeze({ ...value.enabledSources }), + }); +} + +function objectRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +export const DEFAULTS = DEFAULT_STATE; diff --git a/src/main.js b/src/main.js deleted file mode 100644 index 9c9b606..0000000 --- a/src/main.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Sortilune entry point. - * Builds the shell, wires the chamber tabs, sets up theme switching and - * the starfield, then mounts the last-used chamber. - */ - -import { h, svg } from './lib/dom.js'; -import { CHAMBERS, BRAND_ICON } from './chambers/manifest.js'; -import { getState, setState, subscribe } from './lib/state.js'; -import * as nav from './lib/nav.js'; -import * as starfield from './lib/starfield.js'; -import entropy from './lib/entropy/index.js'; -import * as archive from './lib/fs.js'; -import * as archiveBrowser from './archive/browser.js'; -import * as settings from './settings/index.js'; - -const THEMES = [ - { id: 'cosmic-dark', label: 'Dark', icon: dotIcon('M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z') }, - { id: 'cosmic-light', label: 'Light', icon: dotIcon('M12 3v1.5M12 19.5V21M4.22 4.22l1.06 1.06M18.72 18.72l1.06 1.06M3 12h1.5M19.5 12H21M4.22 19.78l1.06-1.06M18.72 5.28l1.06-1.06', 'circle') }, - { id: 'high-contrast', label: 'Contrast', icon: dotIcon('M12 3v18M3 12h18') }, -]; - -function dotIcon(path, extra) { - const base = ``; - const tail = extra === 'circle' ? `` : ``; - return base + tail; -} - -function settingsIcon() { - return ` - - - `; -} - -function archiveIcon() { - return ` - - - - `; -} - -function applyTheme(theme) { - document.documentElement.setAttribute('data-theme', theme); -} - -function buildTabs(onSelect) { - const tabs = h('div', { class: 'tabs', role: 'tablist' }, - CHAMBERS.map((c) => h('button', { - class: 'tab', - role: 'tab', - 'data-id': c.id, - title: `${c.displayName} — ${c.tagline}`, - onclick: () => onSelect(c.id), - }, [ - h('span', { class: 'tab-icon' }, [svg(c.icon)]), - h('span', { class: 'tab-label' }, [c.displayName.replace(/^The\s+/, '')]), - ])) - ); - return tabs; -} - -function buildThemeSwitch() { - const wrap = h('div', { class: 'theme-switch', role: 'group', 'aria-label': 'Theme' }, - THEMES.map((t) => h('button', { - 'data-theme-id': t.id, - title: t.label, - onclick: () => { setState({ theme: t.id }); applyTheme(t.id); refreshThemeSwitch(); }, - }, [svg(t.icon)])) - ); - return wrap; -} - -function refreshThemeSwitch() { - const current = getState().theme; - document.querySelectorAll('[data-theme-id]').forEach((btn) => { - btn.setAttribute('aria-pressed', btn.getAttribute('data-theme-id') === current ? 'true' : 'false'); - }); -} - -function refreshTabs() { - const current = getState().currentChamber; - document.querySelectorAll('.tab[data-id]').forEach((btn) => { - btn.setAttribute('aria-current', btn.getAttribute('data-id') === current ? 'true' : 'false'); - }); -} - -let _archiveActive = false; - -async function go(id) { - try { - _archiveActive = false; - await nav.go(id); - refreshTabs(); - } catch (e) { - console.error('Failed to navigate to chamber', id, e); - } -} - -async function showArchive() { - const root = document.getElementById('chamber-root'); - if (!root) return; - _archiveActive = true; - document.querySelectorAll('.tab[data-id]').forEach((b) => b.setAttribute('aria-current', 'false')); - await archiveBrowser.mount(root); -} - -function buildShell(app) { - const tabs = buildTabs(go); - const themeSwitch = buildThemeSwitch(); - - const topbar = h('header', { class: 'topbar' }, [ - h('div', { class: 'row' }, [ - h('span', { class: 'brand' }, [ - h('span', { class: 'brand-mark' }, [svg(BRAND_ICON)]), - h('span', null, ['Sortilune']), - ]), - ]), - tabs, - h('div', { class: 'topbar-right' }, [ - themeSwitch, - h('button', { class: 'btn btn-ghost btn-icon', title: 'Archive', onclick: () => showArchive() }, [svg(archiveIcon())]), - h('button', { class: 'btn btn-ghost btn-icon', title: 'Settings', onclick: () => settings.open() }, [svg(settingsIcon())]), - ]), - ]); - - const container = h('main', { class: 'chamber-container', id: 'chamber-root' }, []); - - const shell = h('div', { class: 'shell' }, [topbar, container]); - app.appendChild(shell); - return container; -} - -async function init() { - const app = document.getElementById('app'); - const initial = getState(); - applyTheme(initial.theme); - - const chamberRoot = buildShell(app); - - // Chamber context: the surface every chamber receives. - const ctx = { - entropy, - archive, - state: { get: getState, set: setState, subscribe }, - settings: { get: getState, set: setState }, - navigate: go, - }; - nav.attach(chamberRoot, ctx); - - refreshThemeSwitch(); - // Wire enabled-sources to the entropy engine - entropy.setEnabled?.(initial.enabledSources || {}); - await go(initial.currentChamber || 'oracle'); - refreshTabs(); - - // Starfield (best-effort) - if (initial.starfield) { - try { starfield.start(document.getElementById('starfield')); } catch (e) { console.warn('starfield disabled', e); } - } - - // Live-update starfield colors when theme switches - subscribe((s, prev) => { - if (s.theme !== prev.theme) applyTheme(s.theme); - }); -} - -init().catch((e) => { - console.error('Sortilune failed to start', e); - const app = document.getElementById('app'); - app.innerHTML = ''; - app.appendChild(h('div', { class: 'panel', style: { margin: '64px auto', maxWidth: '560px' } }, [ - h('h2', null, ['Sortilune could not start']), - h('p', { class: 'muted' }, [String(e?.message || e)]), - ])); -}); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f5c52b4 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,337 @@ +/** Sortilune adaptive shell and application composition root. */ + +import { h, svg } from './lib/dom.js'; +import { APP_MARK, NAVIGATION_BY_ID, NAVIGATION_GROUPS, groupForDestination } from './app/navigation.js'; +import { getState, setState, subscribe } from './lib/state.js'; +import * as nav from './lib/nav.js'; +import * as starfield from './lib/starfield.js'; +import entropy from './lib/entropy/index.js'; +import { archiveRepository } from './archive/repository.js'; +import * as settings from './settings/index.js'; +import type { AppContext } from './domain/contracts.js'; +import type { RailMode, RouteReference, ThemeId } from './domain/settings.js'; +import { createCommandPalette, type CommandPaletteController } from './ui/command-palette.js'; +import { packRepository } from './packs/repository.js'; +import { contentRegistry } from './packs/content-registry.js'; +import { projectRepository } from './projects/repository.js'; +import { ProjectService } from './projects/service.js'; +import { practiceRepository } from './practices/repository.js'; + +if (import.meta.env.MODE === 'wdio') await import('@wdio/tauri-plugin'); + +const THEMES: ReadonlyArray<{ id: ThemeId; label: string; icon: string }> = [ + { id: 'cosmic-dark', label: 'Dark', icon: dotIcon('M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z') }, + { id: 'cosmic-light', label: 'Light', icon: dotIcon('M12 3v1.5M12 19.5V21M4.22 4.22l1.06 1.06M18.72 18.72l1.06 1.06M3 12h1.5M19.5 12H21M4.22 19.78l1.06-1.06M18.72 5.28l1.06-1.06', 'circle') }, + { id: 'high-contrast', label: 'Contrast', icon: contrastIcon() }, +]; + +let shellElement: HTMLElement | null = null; +let pageContext: HTMLElement | null = null; +let railToggle: HTMLButtonElement | null = null; +let commandPalette: CommandPaletteController | null = null; +let starfieldRunning = false; +const compactRailQuery = window.matchMedia('(max-width: 1007px), (max-height: 800px) and (max-width: 1199px)'); +const forcedCompactRailQuery = window.matchMedia('(max-width: 640px)'); +const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + +function dotIcon(path: string, extra?: string): string { + const base = ``; + return extra === 'circle' ? `${base}` : `${base}`; +} + +function contrastIcon(): string { + return ` + + + `; +} + +function settingsIcon(): string { + return ` + + `; +} + +function commandIcon(): string { + return ` + + `; +} + +function applyTheme(theme: ThemeId): void { + document.documentElement.dataset.theme = theme; +} + +function setTheme(theme: ThemeId): void { + setState({ theme }); + applyTheme(theme); + refreshThemeSwitch(); +} + +function buildRail(onSelect: (id: string) => void | Promise): HTMLElement { + const rail = h('aside', { class: 'app-rail', 'aria-label': 'Primary navigation' }, [ + h('div', { class: 'rail-brand-row' }, [ + h('div', { class: 'rail-brand', 'aria-label': 'Sortilune' }, [ + h('span', { class: 'rail-brand-mark', 'aria-hidden': 'true' }, [svg(APP_MARK)]), + h('span', { class: 'rail-label rail-brand-label' }, ['Sortilune']), + ]), + h('button', { + class: 'rail-toggle', + type: 'button', + onclick: toggleRail, + title: 'Toggle navigation width', + }, [h('span', { 'aria-hidden': 'true' }, ['‹'])]), + ]), + h('nav', { class: 'rail-navigation' }, NAVIGATION_GROUPS.map((group) => h('section', { + class: `rail-group rail-group-${group.id}`, + 'aria-labelledby': `rail-group-${group.id}`, + }, [ + h('h2', { class: 'rail-group-label rail-label', id: `rail-group-${group.id}` }, [group.label]), + ...group.items.map((item) => h('button', { + class: 'rail-item', + type: 'button', + 'data-destination': item.id, + 'aria-label': `${item.label}. ${item.description}`, + title: `${item.label} — ${item.description}`, + onclick: () => onSelect(item.id), + onkeydown: handleRailKey, + }, [ + h('span', { class: 'rail-item-icon', 'aria-hidden': 'true' }, [svg(item.icon)]), + h('span', { class: 'rail-label rail-item-copy' }, [ + h('span', { class: 'rail-item-name' }, [item.label]), + h('span', { class: 'rail-item-description' }, [item.description]), + ]), + ])), + ]))), + h('div', { class: 'rail-footer' }, [ + h('button', { + class: 'rail-item rail-settings', + type: 'button', + 'aria-label': 'Open Settings', + title: 'Settings', + onclick: () => settings.open(), + }, [ + h('span', { class: 'rail-item-icon', 'aria-hidden': 'true' }, [svg(settingsIcon())]), + h('span', { class: 'rail-label rail-item-name' }, ['Settings']), + ]), + ]), + ]); + railToggle = rail.querySelector('.rail-toggle') as HTMLButtonElement | null; + return rail; +} + +function handleRailKey(event: KeyboardEvent): void { + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; + const items = Array.from(document.querySelectorAll('.rail-item:not([disabled])')); + const current = items.indexOf(event.currentTarget as HTMLButtonElement); + if (current < 0) return; + const next = event.key === 'Home' ? 0 + : event.key === 'End' ? items.length - 1 + : event.key === 'ArrowDown' ? (current + 1) % items.length + : (current - 1 + items.length) % items.length; + event.preventDefault(); + items[next]?.focus(); +} + +function toggleRail(): void { + if (isLargeText() || forcedCompactRailQuery.matches) return; + const next: RailMode = isRailCompact() ? 'expanded' : 'compact'; + setState({ railMode: next }); + refreshRailMode(); +} + +function refreshRailMode(): void { + if (!shellElement) return; + refreshTextScaleMode(); + const mode = getState().railMode; + const forcedCompact = forcedCompactRailQuery.matches; + shellElement.dataset.railMode = forcedCompact ? 'compact' : mode; + if (railToggle) { + const compact = isRailCompact(); + const largeText = isLargeText(); + railToggle.disabled = largeText || forcedCompact; + railToggle.setAttribute('aria-label', largeText + ? 'Navigation remains compact at large text sizes' + : forcedCompact ? 'Navigation remains compact in this narrow window' + : compact ? 'Expand navigation rail' : 'Compact navigation rail'); + railToggle.setAttribute('aria-expanded', String(!compact)); + railToggle.querySelector('span')!.textContent = compact ? '›' : '‹'; + } +} + +function refreshTextScaleMode(): void { + const rootSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); + document.documentElement.dataset.largeText = String(Number.isFinite(rootSize) && rootSize >= 24); +} + +function isRailCompact(): boolean { + const mode = getState().railMode; + return isLargeText() || forcedCompactRailQuery.matches || mode === 'compact' + || (mode === 'auto' && compactRailQuery.matches); +} + +function isLargeText(): boolean { + return document.documentElement.dataset.largeText === 'true'; +} + +function applyAccessibility(): void { + const state = getState(); + document.documentElement.dataset.motion = state.reduceMotion; + const reduced = state.reduceMotion === 'reduce' || (state.reduceMotion === 'system' && reducedMotionQuery.matches); + const canvas = document.getElementById('starfield'); + if (canvas) canvas.hidden = !state.starfield || reduced; + if (state.starfield && !reduced && !starfieldRunning) { + try { + starfield.start(canvas); + starfieldRunning = true; + } catch (error) { + console.warn('starfield disabled', error); + } + } else if ((!state.starfield || reduced) && starfieldRunning) { + starfield.stop(); + starfieldRunning = false; + } +} + +function buildThemeSwitch(): HTMLElement { + return h('div', { class: 'theme-switch', role: 'group', 'aria-label': 'Theme' }, THEMES.map((theme) => h('button', { + 'data-theme-id': theme.id, + type: 'button', + title: theme.label, + 'aria-label': `${theme.label} theme`, + onclick: () => setTheme(theme.id), + }, [svg(theme.icon)]))); +} + +function refreshThemeSwitch(): void { + const current = getState().theme; + document.querySelectorAll('[data-theme-id]').forEach((button) => { + button.setAttribute('aria-pressed', button.getAttribute('data-theme-id') === current ? 'true' : 'false'); + }); +} + +function applyNavigationContext(current: string, recordRecent = false): void { + document.querySelectorAll('.rail-item[data-destination]').forEach((button) => { + const selected = button.dataset.destination === current; + if (selected) button.setAttribute('aria-current', 'page'); + else button.removeAttribute('aria-current'); + }); + const item = NAVIGATION_BY_ID.get(current); + const group = groupForDestination(current); + if (pageContext) pageContext.textContent = item ? `${group?.label ?? 'Sortilune'} / ${item.label}` : 'Sortilune'; + if (shellElement) shellElement.dataset.chamber = current; + if (recordRecent) commandPalette?.recordRecent(current); +} + +function refreshNavigation(): void { + applyNavigationContext(getState().route.destination, true); +} + +function previewNavigation(event: Event): void { + const destination = (event as CustomEvent<{ id?: string }>).detail?.id; + if (typeof destination === 'string' && NAVIGATION_BY_ID.has(destination)) applyNavigationContext(destination); +} + +async function go(destination: string | RouteReference, params: Record = {}): Promise { + try { + await nav.go(destination, params); + } catch (error) { + console.error('Failed to navigate to destination', destination, error); + } +} + +function buildShell(app: HTMLElement): HTMLElement { + const rail = buildRail(go); + const contextLabel = h('div', { class: 'page-context', 'aria-live': 'polite' }, ['Sortilune']); + pageContext = contextLabel; + const header = h('header', { class: 'page-header' }, [ + contextLabel, + h('div', { class: 'page-header-actions' }, [ + h('button', { + class: 'command-trigger', + type: 'button', + 'aria-label': 'Open command palette', + onclick: () => commandPalette?.open(), + }, [ + h('span', { class: 'command-trigger-icon', 'aria-hidden': 'true' }, [svg(commandIcon())]), + h('span', { class: 'command-trigger-label' }, ['Go to or run…']), + h('kbd', null, [navigator.platform.includes('Mac') ? '⌘K' : 'Ctrl K']), + ]), + buildThemeSwitch(), + ]), + ]); + const chamberRoot = h('main', { + class: 'chamber-container', + id: 'chamber-root', + tabindex: 0, + 'aria-label': 'Current page', + }, []); + const workspace = h('div', { class: 'app-workspace' }, [header, chamberRoot]); + const shell = h('div', { class: 'shell', 'data-rail-mode': getState().railMode }, [rail, workspace]); + shellElement = shell; + app.append(shell); + refreshRailMode(); + return chamberRoot; +} + +async function init(): Promise { + const app = document.getElementById('app'); + if (!app) throw new Error('application root is missing'); + const initial = getState(); + applyTheme(initial.theme); + applyAccessibility(); + const chamberRoot = buildShell(app); + refreshTextScaleMode(); + + await packRepository.load(); + const projectService = new ProjectService(projectRepository, archiveRepository); + await projectService.start(); + await practiceRepository.load(); + + const context: AppContext = { + entropy, + archive: archiveRepository, + content: contentRegistry, + packs: packRepository, + projects: projectService, + practices: practiceRepository, + state: { get: getState, set: setState, subscribe }, + settings: { get: getState, set: setState }, + navigate: go, + }; + nav.attach(chamberRoot, context); + commandPalette = createCommandPalette({ navigate: go, openSettings: settings.open, setTheme }); + document.addEventListener('chamber-changed', refreshNavigation); + document.addEventListener('route-navigation-started', previewNavigation); + document.addEventListener('route-navigation-failed', previewNavigation); + + refreshThemeSwitch(); + entropy.setEnabled?.(initial.enabledSources || {}); + entropy.setPreferred?.(initial.preferredEntropySource); + await nav.go(initial.route); + refreshNavigation(); + + subscribe((state, previous) => { + if (state.theme !== previous.theme) { + applyTheme(state.theme); + refreshThemeSwitch(); + } + if (state.railMode !== previous.railMode) refreshRailMode(); + if (state.starfield !== previous.starfield || state.reduceMotion !== previous.reduceMotion) applyAccessibility(); + }); + compactRailQuery.addEventListener('change', refreshRailMode); + forcedCompactRailQuery.addEventListener('change', refreshRailMode); + reducedMotionQuery.addEventListener('change', applyAccessibility); + window.addEventListener('resize', refreshRailMode); +} + +init().catch((error) => { + console.error('Sortilune failed to start', error); + const app = document.getElementById('app'); + if (!app) return; + app.innerHTML = ''; + app.append(h('div', { class: 'panel', style: { margin: '64px auto', maxWidth: '560px' } }, [ + h('h2', null, ['Sortilune could not start']), + h('p', { class: 'muted' }, [String(error instanceof Error ? error.message : error)]), + ])); +}); diff --git a/src/packs/compact-validator.ts b/src/packs/compact-validator.ts new file mode 100644 index 0000000..a84af2a --- /dev/null +++ b/src/packs/compact-validator.ts @@ -0,0 +1,242 @@ +import type { PackKind } from '../domain/packs.js'; + +export interface CompactPackIssue { + path: string; + code: string; + message: string; +} + +const IDENTIFIER = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u; +const COLOR = /^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$/u; +const KINDS = new Set(['oracle-deck', 'constraints', 'diary-prompts', 'lottery-presets', 'canvas-palettes']); + +export function compactPackIssues(input: unknown): CompactPackIssue[] { + const issues: CompactPackIssue[] = []; + const pack = record(input, '$', issues); + if (!pack) return issues; + exactKeys(pack, '$', [ + 'schema', 'schema_version', 'pack_id', 'version', 'kind', 'name', 'description', + 'author', 'attribution', 'license', 'dependencies', 'content', + ], issues); + constant(pack.schema, 'sortilune.pack', '/schema', issues); + constant(pack.schema_version, 1, '/schema_version', issues); + text(pack.pack_id, '/pack_id', 1, 96, issues, IDENTIFIER); + text(pack.version, '/version', 5, 64, issues, SEMVER); + text(pack.name, '/name', 1, 120, issues); + optionalText(pack.description, '/description', 1000, issues); + text(pack.attribution, '/attribution', 1, 1000, issues); + + const kind = typeof pack.kind === 'string' && KINDS.has(pack.kind as PackKind) + ? pack.kind as PackKind + : null; + if (!kind) add(issues, '/kind', 'schema.enum', 'Pack kind is not supported.'); + validateAuthor(pack.author, issues); + validateLicense(pack.license, issues); + validateDependencies(pack.dependencies, issues); + if (kind) validateContent(kind, pack.content, issues); + return issues.slice(0, 12); +} + +function validateAuthor(input: unknown, issues: CompactPackIssue[]): void { + const value = record(input, '/author', issues); + if (!value) return; + exactKeys(value, '/author', ['name'], issues); + text(value.name, '/author/name', 1, 120, issues); +} + +function validateLicense(input: unknown, issues: CompactPackIssue[]): void { + const value = record(input, '/license', issues); + if (!value) return; + if (value.type === 'spdx') { + exactKeys(value, '/license', ['type', 'expression'], issues); + text(value.expression, '/license/expression', 1, 240, issues); + return; + } + if (value.type === 'custom') { + exactKeys(value, '/license', ['type', 'name', 'text'], issues); + text(value.name, '/license/name', 1, 120, issues); + text(value.text, '/license/text', 1, 10_000, issues); + return; + } + add(issues, '/license/type', 'schema.enum', 'License type must be spdx or custom.'); +} + +function validateDependencies(input: unknown, issues: CompactPackIssue[]): void { + if (input === undefined) return; + const values = array(input, '/dependencies', 0, 32, issues); + if (!values) return; + values.forEach((inputValue, index) => { + const path = `/dependencies/${index}`; + const value = record(inputValue, path, issues); + if (!value) return; + exactKeys(value, path, ['pack_id', 'version'], issues); + text(value.pack_id, `${path}/pack_id`, 1, 96, issues, IDENTIFIER); + text(value.version, `${path}/version`, 5, 64, issues, SEMVER); + }); +} + +function validateContent(kind: PackKind, input: unknown, issues: CompactPackIssue[]): void { + const content = record(input, '/content', issues); + if (!content) return; + if (kind === 'oracle-deck') { + exactKeys(content, '/content', ['cards'], issues); + const cards = array(content.cards, '/content/cards', 1, 500, issues); + cards?.forEach((item, index) => validateOracleCard(item, `/content/cards/${index}`, issues)); + } else if (kind === 'constraints') { + exactKeys(content, '/content', ['items'], issues); + const items = array(content.items, '/content/items', 1, 2_000, issues); + items?.forEach((item, index) => validateConstraint(item, `/content/items/${index}`, issues)); + } else if (kind === 'diary-prompts') { + exactKeys(content, '/content', ['prompts', 'words'], issues); + const prompts = array(content.prompts, '/content/prompts', 0, 2_000, issues); + const words = array(content.words, '/content/words', 0, 5_000, issues); + if (prompts && words && prompts.length + words.length === 0) { + add(issues, '/content', 'schema.minItems', 'Diary packs need at least one prompt or word.'); + } + prompts?.forEach((item, index) => validateDiaryItem(item, `/content/prompts/${index}`, 1_000, issues)); + words?.forEach((item, index) => validateDiaryItem(item, `/content/words/${index}`, 80, issues)); + } else if (kind === 'lottery-presets') { + exactKeys(content, '/content', ['presets'], issues); + const presets = array(content.presets, '/content/presets', 1, 500, issues); + presets?.forEach((item, index) => validateLotteryPreset(item, `/content/presets/${index}`, issues)); + } else { + exactKeys(content, '/content', ['palettes'], issues); + const palettes = array(content.palettes, '/content/palettes', 1, 100, issues); + palettes?.forEach((item, index) => validatePalette(item, `/content/palettes/${index}`, issues)); + } +} + +function validateOracleCard(input: unknown, path: string, issues: CompactPackIssue[]): void { + const value = record(input, path, issues); + if (!value) return; + exactKeys(value, path, ['id', 'name', 'meaning', 'symbol', 'category', 'keywords'], issues); + itemId(value.id, `${path}/id`, issues); + text(value.name, `${path}/name`, 1, 120, issues); + text(value.meaning, `${path}/meaning`, 1, 2_000, issues); + optionalText(value.symbol, `${path}/symbol`, 16, issues); + optionalText(value.category, `${path}/category`, 64, issues); + if (value.keywords !== undefined) { + const keywords = array(value.keywords, `${path}/keywords`, 0, 16, issues); + keywords?.forEach((keyword, index) => text(keyword, `${path}/keywords/${index}`, 1, 64, issues)); + } +} + +function validateConstraint(input: unknown, path: string, issues: CompactPackIssue[]): void { + const value = record(input, path, issues); + if (!value) return; + exactKeys(value, path, ['id', 'text', 'category'], issues); + itemId(value.id, `${path}/id`, issues); + text(value.text, `${path}/text`, 1, 500, issues); + optionalText(value.category, `${path}/category`, 64, issues); +} + +function validateDiaryItem(input: unknown, path: string, maximum: number, issues: CompactPackIssue[]): void { + const value = record(input, path, issues); + if (!value) return; + exactKeys(value, path, ['id', 'text'], issues); + itemId(value.id, `${path}/id`, issues); + text(value.text, `${path}/text`, 1, maximum, issues); +} + +function validateLotteryPreset(input: unknown, path: string, issues: CompactPackIssue[]): void { + const value = record(input, path, issues); + if (!value) return; + itemId(value.id, `${path}/id`, issues); + text(value.name, `${path}/name`, 1, 120, issues); + if (value.tool === 'wheel' || value.tool === 'name-picker' || value.tool === 'shuffle') { + exactKeys(value, path, ['id', 'name', 'tool', 'items'], issues); + const items = array(value.items, `${path}/items`, 1, 1_000, issues); + items?.forEach((item, index) => text(item, `${path}/items/${index}`, 1, 200, issues)); + } else if (value.tool === 'number') { + exactKeys(value, path, ['id', 'name', 'tool', 'minimum', 'maximum', 'integer'], issues); + boundedNumber(value.minimum, `${path}/minimum`, -1_000_000_000, 1_000_000_000, false, issues); + boundedNumber(value.maximum, `${path}/maximum`, -1_000_000_000, 1_000_000_000, false, issues); + boolean(value.integer, `${path}/integer`, issues); + } else if (value.tool === 'dice') { + exactKeys(value, path, ['id', 'name', 'tool', 'count', 'sides'], issues); + boundedNumber(value.count, `${path}/count`, 1, 20, true, issues); + boundedNumber(value.sides, `${path}/sides`, 2, 1_000, true, issues); + } else if (value.tool === 'coin') { + exactKeys(value, path, ['id', 'name', 'tool', 'heads', 'tails'], issues); + text(value.heads, `${path}/heads`, 1, 80, issues); + text(value.tails, `${path}/tails`, 1, 80, issues); + } else { + add(issues, `${path}/tool`, 'schema.enum', 'Lottery preset tool is not supported.'); + } +} + +function validatePalette(input: unknown, path: string, issues: CompactPackIssue[]): void { + const value = record(input, path, issues); + if (!value) return; + exactKeys(value, path, ['id', 'name', 'colors'], issues); + itemId(value.id, `${path}/id`, issues); + text(value.name, `${path}/name`, 1, 120, issues); + const colors = array(value.colors, `${path}/colors`, 3, 12, issues); + colors?.forEach((color, index) => text(color, `${path}/colors/${index}`, 7, 9, issues, COLOR)); +} + +function record(value: unknown, path: string, issues: CompactPackIssue[]): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + add(issues, path, 'schema.type', `${path} must be an object.`); + return null; + } + return value as Record; +} + +function array(value: unknown, path: string, minimum: number, maximum: number, issues: CompactPackIssue[]): unknown[] | null { + if (!Array.isArray(value)) { + add(issues, path, 'schema.type', `${path} must be an array.`); + return null; + } + if (value.length < minimum) add(issues, path, 'schema.minItems', `${path} has too few items.`); + if (value.length > maximum) add(issues, path, 'schema.maxItems', `${path} has too many items.`); + return value; +} + +function exactKeys(value: Record, path: string, allowed: string[], issues: CompactPackIssue[]): void { + const accepted = new Set(allowed); + for (const key of Object.keys(value)) { + if (!accepted.has(key)) add(issues, `${path === '$' ? '' : path}/${key}`, 'schema.additionalProperties', `${path} contains unsupported field ${key}.`); + } +} + +function itemId(value: unknown, path: string, issues: CompactPackIssue[]): void { + text(value, path, 1, 96, issues, IDENTIFIER); +} + +function optionalText(value: unknown, path: string, maximum: number, issues: CompactPackIssue[]): void { + if (value !== undefined) text(value, path, 0, maximum, issues); +} + +function text(value: unknown, path: string, minimum: number, maximum: number, issues: CompactPackIssue[], pattern?: RegExp): void { + if (typeof value !== 'string') { + add(issues, path, 'schema.type', `${path} must be text.`); + } else if (value.length < minimum) { + add(issues, path, 'schema.minLength', `${path} is empty or too short.`); + } else if (value.length > maximum) { + add(issues, path, 'schema.maxLength', `${path} is too long.`); + } else if (pattern && !pattern.test(value)) { + add(issues, path, 'schema.pattern', `${path} has an invalid format.`); + } +} + +function boundedNumber(value: unknown, path: string, minimum: number, maximum: number, integer: boolean, issues: CompactPackIssue[]): void { + if (typeof value !== 'number' || !Number.isFinite(value) || (integer && !Number.isInteger(value))) { + add(issues, path, 'schema.type', `${path} must be ${integer ? 'an integer' : 'a number'}.`); + } else if (value < minimum || value > maximum) { + add(issues, path, 'schema.range', `${path} is outside the supported range.`); + } +} + +function boolean(value: unknown, path: string, issues: CompactPackIssue[]): void { + if (typeof value !== 'boolean') add(issues, path, 'schema.type', `${path} must be true or false.`); +} + +function constant(value: unknown, expected: unknown, path: string, issues: CompactPackIssue[]): void { + if (value !== expected) add(issues, path, 'schema.const', `${path} has an unsupported value.`); +} + +function add(issues: CompactPackIssue[], path: string, code: string, message: string): void { + if (issues.length < 12) issues.push({ path: path || '$', code, message }); +} diff --git a/src/packs/content-registry.ts b/src/packs/content-registry.ts new file mode 100644 index 0000000..21b6306 --- /dev/null +++ b/src/packs/content-registry.ts @@ -0,0 +1,254 @@ +import type { + CanvasPackPalette, + ConstraintPackItem, + DiaryPackItem, + LotteryPackPreset, + OraclePackCard, + PackContentReference, +} from '../domain/packs.js'; +import type { JsonValue } from '../domain/archive-record.js'; +import { parseSha256Hex } from '../domain/identifiers.js'; +import { packRepository, type PackRepository, type PackVersionView } from './repository.js'; + +export interface ContentSource { + type: 'builtin' | 'pack'; + id: string; + label: string; + version: string; + digest?: string; + pack_id?: string; +} + +export interface OracleDeckDefinition { + id: string; + label: string; + count: number; + hint: string; + origin: string; + blurb: string; + source: ContentSource; + load(): Promise; +} + +export interface ConstraintLibraryDefinition { + id: string; + label: string; + description: string; + source: ContentSource; + load(): Promise; +} + +export interface DiaryLibraryDefinition { + id: string; + label: string; + source: ContentSource; + load(): Promise<{ prompts: DiaryPackItem[]; words: DiaryPackItem[] }>; +} + +export type LotteryPresetDefinition = LotteryPackPreset & { source: ContentSource }; + +export interface CanvasPaletteDefinition extends CanvasPackPalette { + source: ContentSource; +} + +const BUILTIN_SOURCE: ContentSource = Object.freeze({ + type: 'builtin', id: 'builtin.sortilune', label: 'Built into Sortilune', version: '1.0.0', +}); + +const BUILTIN_ORACLE_DECKS: OracleDeckDefinition[] = [ + oracleDeck('tarot', 'Tarot', 78, '78 cards · Major + Minor Arcana', 'Rider–Waite tradition', + 'The classic 78-card divination deck. 22 Major Arcana for life’s larger movements, plus four suits for daily currents, choices, and pressures.', + () => import('../chambers/oracle/decks/generated/tarot.generated.js')), + oracleDeck('i-ching', 'I-Ching', 64, '64 hexagrams', 'Wilhelm / Baynes tradition', + 'The Chinese Book of Changes. Sixty-four hexagrams whose patterns describe the character of a moment.', + () => import('../chambers/oracle/decks/generated/i-ching.generated.js')), + oracleDeck('runes', 'Runes', 24, '24 runes · Elder Futhark', 'Germanic Iron Age tradition', + 'The Elder Futhark: 24 carved symbols naming material and communal forces.', + () => import('../chambers/oracle/decks/generated/runes.generated.js')), + oracleDeck('cosmic', 'Cosmic', 36, '36 original cards', 'Original to Sortilune', + 'An original deck of 36 cards drawn from astronomy and philosophy across six categories of mind and matter.', + () => import('../chambers/oracle/decks/generated/cosmic.generated.js')), +]; + +const BUILTIN_CONSTRAINTS: ConstraintLibraryDefinition[] = ([ + ['creative', 'Creative', 'Apply a discipline to your making.'], + ['behavioral', 'Behavioral', 'A small move different from usual.'], + ['perceptual', 'Perceptual', 'Something to notice, count, or watch.'], + ['linguistic', 'Linguistic', 'A constraint on language and speech.'], + ['whimsical', 'Whimsical', 'Slower, stranger, quieter.'], +] as const).map(([id, label, description]) => ({ + id, label, description, source: BUILTIN_SOURCE, + async load(): Promise { + const mod = await import(`../chambers/constraint/libraries/${id}.json`); + const values = (mod.default || mod) as string[]; + return values.map((text, index) => ({ id: `${id}-${index + 1}`, text, category: id })); + }, +})); + +export class ContentRegistry { + #versions: PackVersionView[] = []; + #listeners = new Set<() => void>(); + #unsubscribe: (() => void) | null = null; + + constructor(repository: PackRepository = packRepository) { + this.#versions = repository.snapshot.versions.filter((version) => version.effective); + this.#unsubscribe = repository.subscribe((snapshot) => { + this.#versions = snapshot.versions.filter((version) => version.effective); + for (const listener of this.#listeners) listener(); + }); + } + + subscribe(listener: () => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + dispose(): void { + this.#unsubscribe?.(); + this.#unsubscribe = null; + this.#listeners.clear(); + } + + oracleDecks(): OracleDeckDefinition[] { + const custom = this.#kind('oracle-deck').map((version): OracleDeckDefinition => { + const content = version.pack.content as { cards: OraclePackCard[] }; + const source = sourceFor(version); + return { + id: contentId(version), + label: source.label, + count: content.cards.length, + hint: `${content.cards.length} custom cards`, + origin: `${version.pack.author.name} · ${licenseLabel(version)}`, + blurb: version.pack.description || version.pack.attribution, + source, + async load() { return structuredClone(content.cards); }, + }; + }); + return [...BUILTIN_ORACLE_DECKS, ...custom]; + } + + constraintLibraries(): ConstraintLibraryDefinition[] { + const custom = this.#kind('constraints').map((version): ConstraintLibraryDefinition => { + const content = version.pack.content as { items: ConstraintPackItem[] }; + const source = sourceFor(version); + return { + id: contentId(version), + label: source.label, + description: version.pack.description || version.pack.attribution, + source, + async load() { return structuredClone(content.items); }, + }; + }); + return [...BUILTIN_CONSTRAINTS, ...custom]; + } + + diaryLibraries(): DiaryLibraryDefinition[] { + const builtIn: DiaryLibraryDefinition = { + id: 'builtin.diary', label: 'Sortilune prompts', source: BUILTIN_SOURCE, + async load() { + const [promptsModule, wordsModule] = await Promise.all([ + import('../chambers/diary/prompts.json'), import('../chambers/diary/words.json'), + ]); + const prompts = (promptsModule.default || promptsModule) as string[]; + const words = (wordsModule.default || wordsModule) as string[]; + return { + prompts: prompts.map((text, index) => ({ id: `prompt-${index + 1}`, text })), + words: words.map((text, index) => ({ id: `word-${index + 1}`, text })), + }; + }, + }; + const custom = this.#kind('diary-prompts').map((version): DiaryLibraryDefinition => { + const content = version.pack.content as { prompts: DiaryPackItem[]; words: DiaryPackItem[] }; + const source = sourceFor(version); + return { + id: contentId(version), label: source.label, source, + async load() { return structuredClone(content); }, + }; + }); + return [builtIn, ...custom]; + } + + lotteryPresets(tool?: LotteryPackPreset['tool']): LotteryPresetDefinition[] { + return this.#kind('lottery-presets').flatMap((version) => { + const content = version.pack.content as { presets: LotteryPackPreset[] }; + return content.presets + .filter((preset) => tool === undefined || preset.tool === tool) + .map((preset) => ({ ...structuredClone(preset), source: sourceFor(version) })); + }); + } + + canvasPalettes(): CanvasPaletteDefinition[] { + const builtIn: CanvasPaletteDefinition = { + id: 'sortilune', + name: 'Sortilune', + colors: ['#D4A574', '#7FB3D5', '#E08552', '#5FB3A5', '#C98A4A', '#9D8FC0', '#8DB58A', '#C97171'], + source: BUILTIN_SOURCE, + }; + const custom = this.#kind('canvas-palettes').flatMap((version) => { + const content = version.pack.content as { palettes: CanvasPackPalette[] }; + return content.palettes.map((palette) => ({ ...structuredClone(palette), source: sourceFor(version) })); + }); + return [builtIn, ...custom]; + } + + reference(source: ContentSource, itemId: string, snapshot: unknown): PackContentReference | undefined { + if (source.type !== 'pack' || !source.pack_id || !source.digest) return undefined; + return { + id: source.pack_id, + version: source.version, + digest: parseSha256Hex(source.digest), + item_id: itemId, + content_snapshot: toJsonValue(snapshot), + }; + } + + #kind(kind: PackVersionView['pack']['kind']): PackVersionView[] { + return this.#versions.filter((version) => version.pack.kind === kind); + } +} + +function oracleDeck( + id: string, + label: string, + count: number, + hint: string, + origin: string, + blurb: string, + loader: () => Promise<{ default?: unknown[] } | unknown[]>, +): OracleDeckDefinition { + return { + id, label, count, hint, origin, blurb, source: BUILTIN_SOURCE, + async load() { + const mod = await loader(); + const loaded = (mod as { default?: unknown[] }).default ?? mod; + return Array.isArray(loaded) ? structuredClone(loaded) : []; + }, + }; +} + +function sourceFor(version: PackVersionView): ContentSource { + return { + type: 'pack', + id: contentId(version), + label: `${version.pack.name} ${version.pack.version}`, + version: version.pack.version, + digest: version.digest, + pack_id: version.pack.pack_id, + }; +} + +function contentId(version: PackVersionView): string { + return `pack:${version.pack.pack_id}:${version.pack.version}`; +} + +function licenseLabel(version: PackVersionView): string { + return version.pack.license.type === 'spdx' ? version.pack.license.expression : version.pack.license.name; +} + +function toJsonValue(value: unknown): JsonValue { + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new TypeError('Pack content snapshot must be JSON-serializable.'); + return JSON.parse(serialized) as JsonValue; +} + +export const contentRegistry = new ContentRegistry(); diff --git a/src/packs/repository.ts b/src/packs/repository.ts new file mode 100644 index 0000000..3590906 --- /dev/null +++ b/src/packs/repository.ts @@ -0,0 +1,412 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { + InstalledPackVersion, + PackEffectiveStatus, + PackKind, + PackRegistryDocument, + PackRegistryEntry, + SortilunePack, +} from '../domain/packs.js'; +import { canonicalPackJson, normalizeAndValidatePack, sha256Text } from './validation.js'; + +const REGISTRY_SCHEMA = 'sortilune.pack-registry'; +const IDENTIFIER = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; +const DIGEST = /^[0-9a-f]{64}$/u; + +export interface SelectedPackFile { + source_name: string; + json_text: string; + bytes: number; +} + +export interface PackRepositoryTransport { + available(): boolean; + selectPackFile(): Promise; + readRegistry(): Promise; + writeRegistry(json: string): Promise; + readContent(storageKey: string): Promise; + writeContent(storageKey: string, json: string): Promise; + deleteContent(storageKey: string): Promise; + exportPackFile(suggestedName: string, json: string): Promise; +} + +export interface PackVersionView extends InstalledPackVersion { + status: PackEffectiveStatus; + effective: boolean; + errors: string[]; + missing_dependencies: string[]; +} + +export interface PackRepositorySnapshot { + available: boolean; + loading: boolean; + registry_error: string | null; + versions: PackVersionView[]; +} + +export interface StarterPackDraft { + kind: Extract; + name: string; + content: SortilunePack['content']; +} + +export class PackRepository { + readonly #transport: PackRepositoryTransport; + #registry: PackRegistryDocument = emptyRegistry(); + #snapshot: PackRepositorySnapshot; + #listeners = new Set<(snapshot: PackRepositorySnapshot) => void>(); + + constructor(transport: PackRepositoryTransport = nativeTransport) { + this.#transport = transport; + this.#snapshot = { available: transport.available(), loading: false, registry_error: null, versions: [] }; + } + + get snapshot(): PackRepositorySnapshot { + return cloneSnapshot(this.#snapshot); + } + + subscribe(listener: (snapshot: PackRepositorySnapshot) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + async load(): Promise { + if (!this.#transport.available()) { + this.#setSnapshot({ available: false, loading: false, registry_error: null, versions: [] }); + return this.snapshot; + } + this.#setSnapshot({ ...this.#snapshot, available: true, loading: true }); + try { + const source = await this.#transport.readRegistry(); + this.#registry = source == null ? emptyRegistry() : parseRegistry(source); + const loaded = await Promise.all(this.#registry.entries.map((entry) => this.#loadEntry(entry))); + const versions = calculateStatuses(loaded); + this.#setSnapshot({ available: true, loading: false, registry_error: null, versions }); + } catch (error) { + this.#setSnapshot({ + available: true, + loading: false, + registry_error: friendlyError(error), + versions: [], + }); + } + return this.snapshot; + } + + async importFromDialog(): Promise { + this.#assertAvailable(); + if (this.#snapshot.registry_error) throw new Error('Repair or remove the unreadable pack registry before importing.'); + const selected = await this.#transport.selectPackFile(); + if (!selected) return null; + let input: unknown; + try { + input = JSON.parse(selected.json_text); + } catch (error) { + throw new TypeError(`Pack JSON could not be parsed: ${error instanceof Error ? error.message : String(error)}`); + } + const pack = normalizeAndValidatePack(input); + const canonical = canonicalPackJson(pack); + const digest = await sha256Text(canonical); + const storageKey = await sha256Text(`${pack.pack_id}\0${pack.version}`); + const existing = this.#registry.entries.find((entry) => entry.pack_id === pack.pack_id && entry.version === pack.version); + if (existing) { + if (existing.digest !== digest) throw new Error(`${pack.pack_id} ${pack.version} is already installed with different content. Use a new version number.`); + await this.load(); + return this.#snapshot.versions.find((item) => item.pack.pack_id === pack.pack_id && item.pack.version === pack.version) ?? null; + } + + const entry: PackRegistryEntry = { + pack_id: pack.pack_id, + version: pack.version, + kind: pack.kind, + name: pack.name, + digest, + storage_key: storageKey, + enabled: true, + installed_at: new Date().toISOString(), + source_name: selected.source_name.slice(0, 260), + dependencies: pack.dependencies?.map((dependency) => ({ ...dependency })) ?? [], + }; + let contentWritten = false; + try { + await this.#transport.writeContent(storageKey, canonical); + contentWritten = true; + await this.#persist({ ...this.#registry, entries: [...this.#registry.entries, entry] }); + } catch (error) { + if (contentWritten) await this.#transport.deleteContent(storageKey).catch(() => false); + throw error; + } + await this.load(); + return this.#snapshot.versions.find((item) => item.pack.pack_id === pack.pack_id && item.pack.version === pack.version) ?? null; + } + + async setEnabled(packId: string, version: string, enabled: boolean): Promise { + this.#assertHealthy(); + let found = false; + const entries = this.#registry.entries.map((entry) => { + if (entry.pack_id !== packId || entry.version !== version) return entry; + found = true; + return { ...entry, enabled }; + }); + if (!found) throw new Error(`Pack ${packId} ${version} is not installed.`); + await this.#persist({ ...this.#registry, entries }); + await this.load(); + } + + async uninstall(packId: string, version: string): Promise { + this.#assertHealthy(); + const entry = this.#registry.entries.find((candidate) => candidate.pack_id === packId && candidate.version === version); + if (!entry) return; + await this.#persist({ + ...this.#registry, + entries: this.#registry.entries.filter((candidate) => candidate !== entry), + }); + let cleanupError: unknown = null; + try { + await this.#transport.deleteContent(entry.storage_key); + } catch (error) { + cleanupError = error; + } + await this.load(); + if (cleanupError) { + throw new Error(`Pack was uninstalled, but its unused content file could not be removed: ${friendlyError(cleanupError)}`); + } + } + + async recheck(): Promise { + return this.load(); + } + + async exportStarter(draft: StarterPackDraft): Promise { + this.#assertAvailable(); + const id = `local.${crypto.randomUUID().toLowerCase()}`; + const pack = normalizeAndValidatePack({ + schema: 'sortilune.pack', + schema_version: 1, + pack_id: id, + version: '1.0.0', + kind: draft.kind, + name: draft.name.trim().slice(0, 120) || 'Sortilune starter pack', + description: 'Starter pack exported locally from Sortilune.', + author: { name: 'Local Sortilune user' }, + attribution: 'Created locally in Sortilune.', + license: { + type: 'custom', + name: 'Private use', + text: 'Personal starter content. Edit the license before sharing this pack.', + }, + content: draft.content, + }); + const pretty = `${JSON.stringify(JSON.parse(canonicalPackJson(pack)), null, 2)}\n`; + return this.#transport.exportPackFile(slug(draft.name), pretty); + } + + async #loadEntry(entry: PackRegistryEntry): Promise { + const errors: string[] = []; + let pack: SortilunePack | null = null; + try { + const source = await this.#transport.readContent(entry.storage_key); + if (source == null) throw new Error('Stored content file is missing.'); + const digest = await sha256Text(source); + if (digest !== entry.digest) throw new Error('Stored content digest no longer matches the registry.'); + pack = normalizeAndValidatePack(JSON.parse(source)); + if (pack.pack_id !== entry.pack_id || pack.version !== entry.version || pack.kind !== entry.kind || pack.name !== entry.name) { + throw new Error('Stored pack identity no longer matches the registry.'); + } + } catch (error) { + errors.push(friendlyError(error)); + } + const fallback: SortilunePack = pack ?? { + schema: 'sortilune.pack', schema_version: 1, pack_id: entry.pack_id, version: entry.version, + kind: entry.kind, name: entry.name, author: { name: 'Unavailable' }, attribution: 'Unavailable', + license: { type: 'custom', name: 'Unavailable', text: 'Stored content could not be read.' }, + content: fallbackContent(entry.kind), + }; + return { + pack: fallback, + digest: entry.digest, + storage_key: entry.storage_key, + enabled: entry.enabled, + installed_at: entry.installed_at, + source_name: entry.source_name, + status: errors.length > 0 ? 'invalid' : entry.enabled ? 'installed' : 'disabled', + effective: errors.length === 0 && entry.enabled, + errors, + missing_dependencies: [], + }; + } + + async #persist(registry: PackRegistryDocument): Promise { + const next = { ...registry, updated_at: new Date().toISOString() }; + const text = `${JSON.stringify(next, null, 2)}\n`; + await this.#transport.writeRegistry(text); + this.#registry = next; + } + + #assertAvailable(): void { + if (!this.#transport.available()) throw new Error('Pack storage is available only in the desktop build.'); + } + + #assertHealthy(): void { + this.#assertAvailable(); + if (this.#snapshot.registry_error) throw new Error('The pack registry is unreadable and was not changed.'); + } + + #setSnapshot(snapshot: PackRepositorySnapshot): void { + this.#snapshot = snapshot; + for (const listener of this.#listeners) listener(this.snapshot); + } +} + +function calculateStatuses(values: PackVersionView[]): PackVersionView[] { + const effectiveKeys = new Set(values + .filter((value) => value.errors.length === 0 && value.enabled) + .map((value) => `${value.pack.pack_id}\0${value.pack.version}`)); + let changed = true; + while (changed) { + changed = false; + for (const value of values) { + const key = `${value.pack.pack_id}\0${value.pack.version}`; + if (!effectiveKeys.has(key)) continue; + const dependencyMissing = (value.pack.dependencies ?? []) + .some((dependency) => !effectiveKeys.has(`${dependency.pack_id}\0${dependency.version}`)); + if (dependencyMissing) { + effectiveKeys.delete(key); + changed = true; + } + } + } + const newestById = new Map(); + for (const value of values) { + const current = newestById.get(value.pack.pack_id); + if (!current || compareSemver(value.pack.version, current) > 0) newestById.set(value.pack.pack_id, value.pack.version); + } + return values.map((value) => { + const missing = (value.pack.dependencies ?? []) + .filter((dependency) => !effectiveKeys.has(`${dependency.pack_id}\0${dependency.version}`)) + .map((dependency) => `${dependency.pack_id} ${dependency.version}`); + let status: PackEffectiveStatus; + if (value.errors.length > 0) status = 'invalid'; + else if (missing.length > 0) status = 'missing-dependency'; + else if (!value.enabled) status = 'disabled'; + else if (newestById.get(value.pack.pack_id) !== value.pack.version) status = 'update-available'; + else status = 'installed'; + return { ...value, status, missing_dependencies: missing, effective: status === 'installed' || status === 'update-available' }; + }).sort((left, right) => left.pack.name.localeCompare(right.pack.name) || compareSemver(right.pack.version, left.pack.version)); +} + +function parseRegistry(source: string): PackRegistryDocument { + let input: unknown; + try { input = JSON.parse(source); } catch (error) { throw new Error(`Pack registry JSON is unreadable: ${friendlyError(error)}`); } + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Pack registry must be an object.'); + const value = input as Record; + if (value.schema !== REGISTRY_SCHEMA || value.schema_version !== 1 || typeof value.updated_at !== 'string' || !Array.isArray(value.entries)) { + throw new Error('Pack registry has an unsupported format.'); + } + if (value.entries.length > 1000) throw new Error('Pack registry contains too many entries.'); + const seen = new Set(); + const entries = value.entries.map((candidate, index) => parseEntry(candidate, index)); + for (const entry of entries) { + const identity = `${entry.pack_id}\0${entry.version}`; + if (seen.has(identity)) throw new Error(`Pack registry repeats ${entry.pack_id} ${entry.version}.`); + seen.add(identity); + } + return { schema: REGISTRY_SCHEMA, schema_version: 1, updated_at: value.updated_at, entries }; +} + +function parseEntry(candidate: unknown, index: number): PackRegistryEntry { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) throw new Error(`Pack registry entry ${index} must be an object.`); + const value = candidate as Record; + const allowed = ['pack_id', 'version', 'kind', 'name', 'digest', 'storage_key', 'enabled', 'installed_at', 'source_name', 'dependencies']; + if (Object.keys(value).some((key) => !allowed.includes(key))) throw new Error(`Pack registry entry ${index} contains an unsupported field.`); + if (typeof value.pack_id !== 'string' || !IDENTIFIER.test(value.pack_id) || value.pack_id.length > 96) throw new Error(`Pack registry entry ${index} has an invalid ID.`); + if (typeof value.version !== 'string' || compareSemver(value.version, value.version) !== 0) throw new Error(`Pack registry entry ${index} has an invalid version.`); + if (!['oracle-deck', 'constraints', 'diary-prompts', 'lottery-presets', 'canvas-palettes'].includes(String(value.kind))) throw new Error(`Pack registry entry ${index} has an invalid kind.`); + if (typeof value.name !== 'string' || !value.name || value.name.length > 120) throw new Error(`Pack registry entry ${index} has an invalid name.`); + if (typeof value.digest !== 'string' || !DIGEST.test(value.digest) || typeof value.storage_key !== 'string' || !DIGEST.test(value.storage_key)) throw new Error(`Pack registry entry ${index} has an invalid digest.`); + if (typeof value.enabled !== 'boolean' || typeof value.installed_at !== 'string' || typeof value.source_name !== 'string') throw new Error(`Pack registry entry ${index} has invalid metadata.`); + if (!Array.isArray(value.dependencies) || value.dependencies.length > 32) throw new Error(`Pack registry entry ${index} has invalid dependencies.`); + const dependencies = value.dependencies.map((dependency) => { + if (!dependency || typeof dependency !== 'object' || Array.isArray(dependency)) throw new Error(`Pack registry entry ${index} has invalid dependencies.`); + const item = dependency as Record; + if (typeof item.pack_id !== 'string' || !IDENTIFIER.test(item.pack_id) || typeof item.version !== 'string') throw new Error(`Pack registry entry ${index} has invalid dependencies.`); + compareSemver(item.version, item.version); + return { pack_id: item.pack_id, version: item.version }; + }); + return { + pack_id: value.pack_id, + version: value.version, + kind: value.kind as PackKind, + name: value.name, + digest: value.digest, + storage_key: value.storage_key, + enabled: value.enabled, + installed_at: value.installed_at, + source_name: value.source_name.slice(0, 260), + dependencies, + }; +} + +function compareSemver(left: string, right: string): number { + const pattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u; + const a = pattern.exec(left); + const b = pattern.exec(right); + if (!a || !b) throw new TypeError('Version must use Semantic Versioning, such as 1.0.0.'); + for (let index = 1; index <= 3; index += 1) { + const difference = Number(a[index]) - Number(b[index]); + if (difference !== 0) return difference < 0 ? -1 : 1; + } + const ap = a[4]?.split('.') ?? []; + const bp = b[4]?.split('.') ?? []; + if (ap.length === 0 || bp.length === 0) return ap.length === bp.length ? 0 : ap.length === 0 ? 1 : -1; + for (let index = 0; index < Math.max(ap.length, bp.length); index += 1) { + if (ap[index] === undefined || bp[index] === undefined) return ap[index] === undefined ? -1 : 1; + if (ap[index] === bp[index]) continue; + const an = /^\d+$/u.test(ap[index]!); + const bn = /^\d+$/u.test(bp[index]!); + if (an && bn) return Number(ap[index]) < Number(bp[index]) ? -1 : 1; + if (an !== bn) return an ? -1 : 1; + return ap[index]! < bp[index]! ? -1 : 1; + } + return 0; +} + +function fallbackContent(kind: PackKind): SortilunePack['content'] { + if (kind === 'oracle-deck') return { cards: [] }; + if (kind === 'constraints') return { items: [] }; + if (kind === 'diary-prompts') return { prompts: [], words: [] }; + if (kind === 'lottery-presets') return { presets: [] }; + return { palettes: [] }; +} + +function emptyRegistry(): PackRegistryDocument { + return { schema: REGISTRY_SCHEMA, schema_version: 1, updated_at: new Date(0).toISOString(), entries: [] }; +} + +function friendlyError(error: unknown): string { + if (error instanceof Error) return error.message; + if (error && typeof error === 'object' && 'message' in error) return String((error as { message: unknown }).message); + return String(error); +} + +function cloneSnapshot(snapshot: PackRepositorySnapshot): PackRepositorySnapshot { + return structuredClone(snapshot); +} + +function slug(value: string): string { + return value.normalize('NFKD').replace(/[\u0300-\u036f]/gu, '').toLowerCase() + .replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 60) || 'sortilune-starter'; +} + +const nativeTransport: PackRepositoryTransport = { + available: () => typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__), + selectPackFile: () => invoke('select_pack_file'), + readRegistry: () => invoke('read_pack_registry'), + writeRegistry: (json) => invoke('write_pack_registry', { jsonText: json }), + readContent: (storageKey) => invoke('read_pack_content', { storageKey }), + writeContent: (storageKey, json) => invoke('write_pack_content', { storageKey, jsonText: json }), + deleteContent: (storageKey) => invoke('delete_pack_content', { storageKey }), + exportPackFile: (suggestedName, json) => invoke('export_pack_file', { suggestedName, jsonText: json }), +}; + +export const packRepository = new PackRepository(); diff --git a/src/packs/validation.ts b/src/packs/validation.ts new file mode 100644 index 0000000..9d428f5 --- /dev/null +++ b/src/packs/validation.ts @@ -0,0 +1,147 @@ +import parseSpdxExpression from 'spdx-expression-parse'; +import type { SortilunePack } from '../domain/packs.js'; +import { compactPackIssues } from './compact-validator.js'; + +export interface PackValidationIssue { + path: string; + code: string; + message: string; +} + +export class PackValidationError extends TypeError { + readonly issues: PackValidationIssue[]; + + constructor(issues: PackValidationIssue[]) { + super(issues[0]?.message ?? 'Pack validation failed'); + this.name = 'PackValidationError'; + this.issues = issues; + } +} + +interface NormalizeCounter { nodes: number } + +export function normalizeAndValidatePack(input: unknown): SortilunePack { + const normalized = normalizeValue(input, '$', 0, { nodes: 0 }); + const shapeIssues = compactPackIssues(normalized); + if (shapeIssues.length > 0) throw new PackValidationError(shapeIssues); + + const pack = normalized as SortilunePack; + const issues: PackValidationIssue[] = []; + if (pack.license.type === 'spdx') { + try { + parseSpdxExpression(pack.license.expression); + } catch { + issues.push({ path: '/license/expression', code: 'license.spdx', message: 'License expression is not valid SPDX syntax.' }); + } + } + + const dependencies = new Set(); + for (const [index, dependency] of (pack.dependencies ?? []).entries()) { + const key = dependency.pack_id; + if (dependencies.has(key)) { + issues.push({ path: `/dependencies/${index}/pack_id`, code: 'dependency.duplicate', message: `Dependency ${key} is listed more than once.` }); + } + if (key === pack.pack_id) { + issues.push({ path: `/dependencies/${index}/pack_id`, code: 'dependency.self', message: 'A pack cannot depend on itself.' }); + } + dependencies.add(key); + } + + const ids = new Set(); + for (const [path, id] of itemIds(pack)) { + if (ids.has(id)) issues.push({ path, code: 'item.duplicate', message: `Item ID ${id} is used more than once.` }); + ids.add(id); + } + + if (pack.kind === 'lottery-presets' && 'presets' in pack.content) { + pack.content.presets.forEach((preset, index) => { + if (preset.tool === 'number' && preset.minimum > preset.maximum) { + issues.push({ path: `/content/presets/${index}`, code: 'preset.range', message: 'A number preset minimum cannot exceed its maximum.' }); + } + if ((preset.tool === 'wheel' || preset.tool === 'name-picker') && preset.items.length < 2) { + issues.push({ path: `/content/presets/${index}/items`, code: 'preset.items', message: `${preset.tool} presets need at least two items.` }); + } + }); + } + + if (ids.size > 10_000) { + issues.push({ path: '/content', code: 'content.limit', message: 'A pack can contain at most 10,000 identified items.' }); + } + if (issues.length > 0) throw new PackValidationError(issues); + return pack; +} + +export function canonicalPackJson(pack: SortilunePack): string { + return JSON.stringify(sortJson(pack)); +} + +export async function sha256Text(text: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function normalizeValue(value: unknown, path: string, depth: number, counter: NormalizeCounter): unknown { + if (depth > 32) throw issue(path, 'json.depth', 'Pack JSON exceeds 32 levels of nesting.'); + counter.nodes += 1; + if (counter.nodes > 50_000) throw issue(path, 'json.nodes', 'Pack JSON contains too many values.'); + if (typeof value === 'string') { + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u.test(value)) { + throw issue(path, 'text.control', 'Pack text contains unsupported control characters.'); + } + return value.normalize('NFC'); + } + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw issue(path, 'number.finite', 'Pack numbers must be finite.'); + return value; + } + if (Array.isArray(value)) return value.map((item, index) => normalizeValue(item, `${path}/${index}`, depth + 1, counter)); + if (!value || typeof value !== 'object') throw issue(path, 'json.type', 'Pack content must contain only JSON values.'); + const output: Record = {}; + for (const [rawKey, child] of Object.entries(value)) { + const key = rawKey.normalize('NFC'); + if (Object.hasOwn(output, key)) throw issue(`${path}/${key}`, 'key.normalized-duplicate', `Object key ${key} is duplicated after Unicode normalization.`); + output[key] = normalizeValue(child, `${path}/${escapePointer(key)}`, depth + 1, counter); + } + return output; +} + +function itemIds(pack: SortilunePack): Array<[string, string]> { + if (pack.kind === 'oracle-deck' && 'cards' in pack.content) { + return pack.content.cards.map((item, index) => [`/content/cards/${index}/id`, item.id]); + } + if (pack.kind === 'constraints' && 'items' in pack.content) { + return pack.content.items.map((item, index) => [`/content/items/${index}/id`, item.id]); + } + if (pack.kind === 'diary-prompts' && 'prompts' in pack.content && 'words' in pack.content) { + return [ + ...pack.content.prompts.map((item, index): [string, string] => [`/content/prompts/${index}/id`, item.id]), + ...pack.content.words.map((item, index): [string, string] => [`/content/words/${index}/id`, item.id]), + ]; + } + if (pack.kind === 'lottery-presets' && 'presets' in pack.content) { + return pack.content.presets.map((item, index) => [`/content/presets/${index}/id`, item.id]); + } + if (pack.kind === 'canvas-palettes' && 'palettes' in pack.content) { + return pack.content.palettes.map((item, index) => [`/content/palettes/${index}/id`, item.id]); + } + return []; +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) + .map(([key, child]) => [key, sortJson(child)])); + } + return value; +} + +function issue(path: string, code: string, message: string): PackValidationError { + return new PackValidationError([{ path, code, message }]); +} + +function escapePointer(value: string): string { + return value.replaceAll('~', '~0').replaceAll('/', '~1'); +} diff --git a/src/practices/repository.ts b/src/practices/repository.ts new file mode 100644 index 0000000..c227542 --- /dev/null +++ b/src/practices/repository.ts @@ -0,0 +1,168 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + clonePracticeStore, + createPracticePlan, + emptyPracticeStore, + normalizePracticeDraft, + type PracticeDraft, + type PracticePlan, + type PracticeStatus, + type PracticeStore, +} from '../domain/practices.js'; +import { createStableId, nowRfc3339 } from '../domain/identifiers.js'; +import { validatePracticeStore } from '../schemas/validate.js'; + +export interface PracticeTransport { + available(): boolean; + read(): Promise; + write(json: string): Promise; +} + +export interface PracticeSnapshot { + available: boolean; + loading: boolean; + error: string | null; + store: PracticeStore; +} + +export class PracticeRepository { + readonly #transport: PracticeTransport; + #snapshot: PracticeSnapshot; + #listeners = new Set<(snapshot: PracticeSnapshot) => void>(); + #queue: Promise = Promise.resolve(); + + constructor(transport: PracticeTransport = nativePracticeTransport) { + this.#transport = transport; + this.#snapshot = { available: transport.available(), loading: false, error: null, store: emptyPracticeStore() }; + } + + get snapshot(): PracticeSnapshot { return structuredClone(this.#snapshot); } + + subscribe(listener: (snapshot: PracticeSnapshot) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + load(): Promise { + return this.#serialize(async () => { + if (!this.#transport.available()) { + this.#publish({ available: false, loading: false, error: null, store: emptyPracticeStore() }); + return this.snapshot; + } + this.#publish({ ...this.#snapshot, available: true, loading: true, error: null }); + try { + const source = await this.#transport.read(); + const store = source == null ? emptyPracticeStore() : parsePracticeStore(source); + this.#publish({ available: true, loading: false, error: null, store }); + } catch (error) { + this.#publish({ ...this.#snapshot, available: true, loading: false, error: messageOf(error) }); + } + return this.snapshot; + }); + } + + create(draft: PracticeDraft): Promise { + return this.#mutate((store) => { + const plan = createPracticePlan(draft); + store.plans.push(plan); + return plan; + }); + } + + update(planId: string, draft: PracticeDraft): Promise { + const normalized = normalizePracticeDraft(draft); + return this.#mutate((store, now) => { + const plan = requirePlan(store, planId); + plan.name = normalized.name; + plan.activities = normalized.activities.map((title) => ({ id: createStableId(), title })); + plan.eligible_weekdays = normalized.eligible_weekdays; + plan.updated_at = now; + return plan; + }); + } + + setStatus(planId: string, status: PracticeStatus): Promise { + return this.#mutate((store, now) => { + const plan = requirePlan(store, planId); + plan.status = status; + plan.updated_at = now; + return plan; + }); + } + + delete(planId: string): Promise { + return this.#mutate((store) => { + const index = store.plans.findIndex((plan) => plan.id === planId); + if (index < 0) throw new Error('Practice plan no longer exists.'); + store.plans.splice(index, 1); + }); + } + + #mutate(operation: (store: PracticeStore, now: ReturnType) => T): Promise { + return this.#serialize(async () => { + this.#assertWritable(); + const store = clonePracticeStore(this.#snapshot.store); + const now = nowRfc3339(); + const result = operation(store, now); + store.updated_at = now; + assertPracticeStore(store); + await this.#transport.write(`${JSON.stringify(store, null, 2)}\n`); + this.#publish({ available: true, loading: false, error: null, store }); + return structuredClone(result); + }); + } + + #assertWritable(): void { + if (!this.#transport.available()) throw new Error('Practice plans are available only in the desktop app.'); + if (this.#snapshot.error) throw new Error('Practice storage could not be read and was not changed.'); + } + + #publish(snapshot: PracticeSnapshot): void { + this.#snapshot = structuredClone(snapshot); + for (const listener of this.#listeners) listener(this.snapshot); + } + + #serialize(operation: () => Promise): Promise { + const result = this.#queue.then(operation, operation); + this.#queue = result.then(() => undefined, () => undefined); + return result; + } +} + +export function parsePracticeStore(source: string): PracticeStore { + const value: unknown = JSON.parse(source); + assertPracticeStore(value); + const store = structuredClone(value); + store.plans.sort((left, right) => right.updated_at.localeCompare(left.updated_at)); + return store; +} + +function assertPracticeStore(value: unknown): asserts value is PracticeStore { + if (!validatePracticeStore(value)) { + const error = validatePracticeStore.errors?.[0]; + throw new TypeError(`Practice store is invalid: ${error?.instancePath || '/'} ${error?.keyword || 'unknown'}`); + } + const ids = new Set(); + for (const plan of value.plans) { + if (ids.has(plan.id)) throw new TypeError('Practice store contains duplicate plan IDs.'); + ids.add(plan.id); + const activityIds = new Set(plan.activities.map((activity) => activity.id)); + if (activityIds.size !== plan.activities.length) throw new TypeError(`Practice plan ${plan.name} contains duplicate activity IDs.`); + } +} + +function requirePlan(store: PracticeStore, planId: string): PracticePlan { + const plan = store.plans.find((candidate) => candidate.id === planId); + if (!plan) throw new Error('Practice plan no longer exists.'); + return plan; +} + +function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +export const nativePracticeTransport: PracticeTransport = { + available: () => typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__), + read: () => invoke('read_practice_store'), + write: (json) => invoke('write_practice_store', { jsonText: json }), +}; + +export const practiceRepository = new PracticeRepository(); diff --git a/src/practices/starters.ts b/src/practices/starters.ts new file mode 100644 index 0000000..8185dfc --- /dev/null +++ b/src/practices/starters.ts @@ -0,0 +1,32 @@ +import type { PracticeDraft } from '../domain/practices.js'; + +export interface PracticeStarter extends PracticeDraft { + id: string; + description: string; +} + +const EVERY_DAY = [0, 1, 2, 3, 4, 5, 6]; + +export const PRACTICE_STARTERS: readonly PracticeStarter[] = Object.freeze([ + { + id: 'creative-spark', + name: 'Creative spark', + description: 'A small invitation to make something without turning it into a performance target.', + activities: ['Sketch one shape you noticed today.', 'Write six lines without editing.', 'Photograph an ordinary texture.', 'Make a tiny color palette from the room.'], + eligible_weekdays: EVERY_DAY, + }, + { + id: 'notice-the-world', + name: 'Notice the world', + description: 'Brief observation prompts for attention and variety.', + activities: ['Notice three different sounds.', 'Take a different familiar route.', 'Find a shadow with an interesting edge.', 'Spend five minutes looking closely at one object.'], + eligible_weekdays: EVERY_DAY, + }, + { + id: 'screen-breath', + name: 'Screen breath', + description: 'Optional low-pressure breaks from the current screen.', + activities: ['Look away from the screen and notice the farthest visible point.', 'Stand up and refill a drink.', 'Read one page on paper.', 'Listen to one song without multitasking.'], + eligible_weekdays: [1, 2, 3, 4, 5], + }, +]); diff --git a/src/projects/repository.ts b/src/projects/repository.ts new file mode 100644 index 0000000..e88909b --- /dev/null +++ b/src/projects/repository.ts @@ -0,0 +1,387 @@ +import { invoke } from '@tauri-apps/api/core'; +import type { Project, ProjectDestination, ProjectRecordReference } from '../domain/projects.js'; +import { + cloneProject, + createProject, + duplicateProject, + normalizeProjectName, + normalizeProjectNote, +} from '../domain/projects.js'; +import { isStableId, nowRfc3339, parseStableId, type Rfc3339Timestamp, type StableId } from '../domain/identifiers.js'; +import { getProjectTemplate } from './templates.js'; +import { normalizeProject, parseProjectJson, projectJson } from './validation.js'; + +export interface ProjectTransport { + available(): boolean; + listProjects(): Promise; + readProject(projectId: string): Promise; + writeProject(projectId: string, json: string): Promise; + deleteProject(projectId: string): Promise; +} + +export interface ProjectReadError { + project_id: string; + filename: string; + error: string; +} + +export interface ProjectRepositorySnapshot { + available: boolean; + loading: boolean; + repository_error: string | null; + projects: Project[]; + read_errors: ProjectReadError[]; +} + +export interface ProjectDeletionSummary { + project_id: StableId; + project_name: string; + deleted: boolean; + retained_archive_records: number; +} + +export class ProjectRepository { + readonly #transport: ProjectTransport; + #projects = new Map(); + #snapshot: ProjectRepositorySnapshot; + #listeners = new Set<(snapshot: ProjectRepositorySnapshot) => void>(); + #mutationQueue: Promise = Promise.resolve(); + + constructor(transport: ProjectTransport = nativeProjectTransport) { + this.#transport = transport; + this.#snapshot = emptySnapshot(transport.available()); + } + + get snapshot(): ProjectRepositorySnapshot { + return cloneSnapshot(this.#snapshot); + } + + get(projectId: string): Project | null { + const project = this.#projects.get(projectId); + return project ? cloneProject(project) : null; + } + + subscribe(listener: (snapshot: ProjectRepositorySnapshot) => void): () => void { + this.#listeners.add(listener); + return () => this.#listeners.delete(listener); + } + + load(): Promise { + return this.#serialize(async () => { + if (!this.#transport.available()) { + this.#projects.clear(); + this.#setSnapshot(emptySnapshot(false)); + return this.snapshot; + } + this.#setSnapshot({ ...this.#snapshot, available: true, loading: true, repository_error: null }); + try { + const ids = await this.#transport.listProjects(); + const loaded = await Promise.all(ids.map((id) => this.#readOne(id))); + this.#projects.clear(); + const readErrors: ProjectReadError[] = []; + for (const result of loaded) { + if (!result) continue; + if ('error' in result) readErrors.push(result); + else this.#projects.set(result.id, result.project); + } + this.#publish({ read_errors: readErrors }); + } catch (error) { + this.#projects.clear(); + this.#setSnapshot({ + available: true, + loading: false, + repository_error: messageOf(error), + projects: [], + read_errors: [], + }); + } + return this.snapshot; + }); + } + + create(templateId: string, name: string): Promise { + return this.#serialize(async () => { + this.#assertAvailable(); + const project = normalizeProject(createProject(getProjectTemplate(templateId), name)); + await this.#commitNew(project); + return cloneProject(project); + }); + } + + rename(projectId: string, name: string): Promise { + const normalizedName = normalizeProjectName(name); + return this.#update(projectId, (project) => { project.name = normalizedName; }); + } + + duplicate(projectId: string): Promise { + return this.#serialize(async () => { + this.#assertAvailable(); + const source = this.#require(projectId); + const duplicate = normalizeProject(duplicateProject(source)); + await this.#commitNew(duplicate); + return cloneProject(duplicate); + }); + } + + close(projectId: string): Promise { + return this.#update(projectId, (project, now) => { + if (project.status === 'closed') return; + project.status = 'closed'; + project.closed_at = now; + project.completed_at = null; + }); + } + + reopen(projectId: string): Promise { + return this.#update(projectId, (project, now) => { + if (project.status !== 'closed') return; + project.closed_at = null; + if (allStepsFinished(project)) { + project.status = 'completed'; + project.completed_at = now; + project.current_step_id = null; + clearCurrent(project); + } else { + project.status = 'active'; + project.completed_at = null; + ensureCurrent(project); + } + }); + } + + setNotes(projectId: string, notes: string): Promise { + const normalized = normalizeProjectNote(notes); + return this.#update(projectId, (project) => { project.notes = normalized; }); + } + + setStepNote(projectId: string, stepId: string, note: string): Promise { + const normalized = normalizeProjectNote(note, 5_000); + return this.#update(projectId, (project) => { requireStep(project, stepId).note = normalized; }); + } + + selectDestination(projectId: string, stepId: string, destination: ProjectDestination): Promise { + return this.#update(projectId, (project) => { + const step = requireStep(project, stepId); + if (step.status === 'completed' || step.status === 'skipped') throw new Error('Revisit this step before changing its chamber.'); + if (!step.destination_options.some((option) => option.destination === destination)) { + throw new Error(`${destination} is not available for this project step.`); + } + step.selected_destination = destination; + }); + } + + moveCurrent(projectId: string, direction: -1 | 1): Promise { + return this.#update(projectId, (project) => { + if (project.status !== 'active') throw new Error('Reopen this project before moving between steps.'); + const currentIndex = project.steps.findIndex((step) => step.id === project.current_step_id); + if (currentIndex < 0) throw new Error('This project has no current step.'); + const target = project.steps[currentIndex + direction]; + if (!target) throw new Error(direction < 0 ? 'This is the first step.' : 'This is the last step.'); + if (target.status === 'completed' || target.status === 'skipped') { + throw new Error('Use Revisit to reopen a completed or skipped step.'); + } + project.steps[currentIndex]!.status = 'pending'; + target.status = 'current'; + project.current_step_id = target.id; + }); + } + + skipStep(projectId: string, stepId: string): Promise { + return this.#update(projectId, (project, now) => { + if (project.status !== 'active') throw new Error('Reopen this project before skipping a step.'); + const step = requireStep(project, stepId); + if (step.status === 'completed') throw new Error('A completed step must be revisited before it can be skipped.'); + step.status = 'skipped'; + step.record_reference = null; + step.completed_at = null; + step.skipped_at = now; + advanceAfter(project, step.id, now); + }); + } + + revisitStep(projectId: string, stepId: string): Promise { + return this.#update(projectId, (project) => { + if (project.status === 'closed') throw new Error('Reopen this project before revisiting a step.'); + for (const step of project.steps) { + if (step.status === 'current') step.status = 'pending'; + } + const step = requireStep(project, stepId); + if (step.attempt >= 9999) throw new Error('This step has reached its revisit limit. Duplicate the project to continue.'); + step.attempt += 1; + step.status = 'current'; + step.record_reference = null; + step.completed_at = null; + step.skipped_at = null; + project.current_step_id = step.id; + project.status = 'active'; + project.completed_at = null; + project.closed_at = null; + }); + } + + attachRecord(projectId: string, stepId: string, expectedAttempt: number, reference: ProjectRecordReference): Promise { + return this.#update(projectId, (project, now) => { + if (project.status === 'closed') throw new Error('Reopen this project before attaching a result.'); + const step = requireStep(project, stepId); + if (step.attempt !== expectedAttempt) throw new Error('This result belongs to an earlier project step attempt.'); + if (step.record_reference?.id === reference.id) return; + if (step.status === 'completed' || step.record_reference) throw new Error('This step already has a different result. Revisit it first.'); + step.status = 'completed'; + step.record_reference = structuredClone(reference); + step.completed_at = now; + step.skipped_at = null; + advanceAfter(project, step.id, now); + }); + } + + delete(projectId: string, retainedArchiveRecords: number): Promise { + return this.#serialize(async () => { + this.#assertAvailable(); + const project = this.#require(projectId); + const deleted = await this.#transport.deleteProject(project.id); + this.#projects.delete(project.id); + this.#publish(); + return { + project_id: project.id, + project_name: project.name, + deleted, + retained_archive_records: Math.max(0, Math.trunc(retainedArchiveRecords)), + }; + }); + } + + #update(projectId: string, mutate: (project: Project, now: Rfc3339Timestamp) => void): Promise { + return this.#serialize(async () => { + this.#assertAvailable(); + const next = cloneProject(this.#require(projectId)); + const now = nowRfc3339(); + mutate(next, now); + next.updated_at = now; + const normalized = normalizeProject(next); + await this.#transport.writeProject(normalized.id, projectJson(normalized)); + this.#projects.set(normalized.id, normalized); + this.#publish(); + return cloneProject(normalized); + }); + } + + async #commitNew(project: Project): Promise { + if (this.#projects.has(project.id)) throw new Error('A project with this ID already exists.'); + await this.#transport.writeProject(project.id, projectJson(project)); + this.#projects.set(project.id, project); + this.#publish(); + } + + async #readOne(projectId: string): Promise<{ id: string; project: Project } | ProjectReadError | null> { + const filename = `${projectId}.json`; + try { + parseStableId(projectId); + const source = await this.#transport.readProject(projectId); + if (source == null) return null; + const project = parseProjectJson(source); + if (project.id !== projectId) throw new Error('document ID does not match its filename'); + return { id: projectId, project }; + } catch (error) { + return { project_id: projectId, filename, error: messageOf(error) }; + } + } + + #require(projectId: string): Project { + if (!isStableId(projectId)) throw new TypeError('project ID must be a valid UUID'); + const project = this.#projects.get(projectId); + if (!project) throw new Error('Project no longer exists. Refresh Projects to continue.'); + return project; + } + + #assertAvailable(): void { + if (!this.#transport.available()) throw new Error('Project storage is available only in the desktop build.'); + if (this.#snapshot.repository_error) throw new Error('Project storage could not be loaded and was not changed.'); + } + + #publish(extra: Partial> = {}): void { + this.#setSnapshot({ + available: this.#transport.available(), + loading: false, + repository_error: null, + projects: [...this.#projects.values()].sort((left, right) => right.updated_at.localeCompare(left.updated_at)), + read_errors: extra.read_errors ?? this.#snapshot.read_errors, + }); + } + + #setSnapshot(snapshot: ProjectRepositorySnapshot): void { + this.#snapshot = cloneSnapshot(snapshot); + for (const listener of this.#listeners) listener(this.snapshot); + } + + #serialize(operation: () => Promise): Promise { + const result = this.#mutationQueue.then(operation, operation); + this.#mutationQueue = result.then(() => undefined, () => undefined); + return result; + } +} + +function advanceAfter(project: Project, completedStepId: string, now: Rfc3339Timestamp): void { + const index = project.steps.findIndex((step) => step.id === completedStepId); + const next = [...project.steps.slice(index + 1), ...project.steps.slice(0, index)] + .find((step) => step.status === 'pending' || step.status === 'current'); + clearCurrent(project); + if (next) { + next.status = 'current'; + project.current_step_id = next.id; + project.status = 'active'; + project.completed_at = null; + } else { + project.current_step_id = null; + project.status = 'completed'; + project.completed_at = now; + } +} + +function clearCurrent(project: Project): void { + for (const step of project.steps) if (step.status === 'current') step.status = 'pending'; +} + +function ensureCurrent(project: Project): void { + const existing = project.steps.find((step) => step.status === 'current'); + if (existing) { + project.current_step_id = existing.id; + return; + } + const next = project.steps.find((step) => step.status === 'pending'); + if (!next) return; + next.status = 'current'; + project.current_step_id = next.id; +} + +function allStepsFinished(project: Project): boolean { + return project.steps.every((step) => step.status === 'completed' || step.status === 'skipped'); +} + +function requireStep(project: Project, stepId: string) { + const step = project.steps.find((candidate) => candidate.id === stepId); + if (!step) throw new Error(`Project step no longer exists: ${stepId}`); + return step; +} + +function emptySnapshot(available: boolean): ProjectRepositorySnapshot { + return { available, loading: false, repository_error: null, projects: [], read_errors: [] }; +} + +function cloneSnapshot(snapshot: ProjectRepositorySnapshot): ProjectRepositorySnapshot { + return structuredClone(snapshot); +} + +function messageOf(error: unknown): string { + if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') return error.message; + return error instanceof Error ? error.message : String(error); +} + +export const nativeProjectTransport: ProjectTransport = { + available: () => typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__), + listProjects: () => invoke('list_projects'), + readProject: (projectId) => invoke('read_project', { projectId }), + writeProject: (projectId, json) => invoke('write_project', { projectId, jsonText: json }), + deleteProject: (projectId) => invoke('delete_project', { projectId }), +}; + +export const projectRepository = new ProjectRepository(); diff --git a/src/projects/service.ts b/src/projects/service.ts new file mode 100644 index 0000000..80e5f2b --- /dev/null +++ b/src/projects/service.ts @@ -0,0 +1,355 @@ +import type { ArchiveRepository, NormalizedArchiveItem, SavedArchiveRecord } from '../archive/repository.js'; +import type { Relation } from '../domain/archive-record.js'; +import type { Project, ProjectContext, ProjectRecordReference, ProjectStep } from '../domain/projects.js'; +import { + projectContextFromRoute, + projectRecordReference, + projectRelation, + relationProjectContext, +} from '../domain/projects.js'; +import type { RouteReference } from '../domain/settings.js'; +import type { ProjectRepository } from './repository.js'; + +export interface ResolvedProjectContext { + context: ProjectContext; + project: Project; + step: ProjectStep; + destination: string | null; +} + +export interface ProjectRecovery { + context: ProjectContext; + reference: ProjectRecordReference; + error: string; +} + +export class ProjectService { + readonly repository: ProjectRepository; + readonly #archive: ArchiveRepository; + #stopArchiveListener: (() => void) | null = null; + #routeCleanup: (() => void) | null = null; + #recoveries = new Map(); + + constructor(repository: ProjectRepository, archive: ArchiveRepository) { + this.repository = repository; + this.#archive = archive; + } + + async start(): Promise { + await this.repository.load(); + if (!this.#stopArchiveListener) { + this.#stopArchiveListener = this.#archive.subscribeSaved((saved) => this.#onArchiveSaved(saved)); + } + await this.reconcile(); + } + + resolve(route?: RouteReference): ResolvedProjectContext | null { + const context = projectContextFromRoute(route); + if (!context) return null; + const project = this.repository.get(context.project_id); + const step = project?.steps.find((candidate) => candidate.id === context.project_step_id); + if (!project || !step || step.attempt !== context.project_step_attempt) return null; + const destination = step.selected_destination ?? step.destination; + if (route && route.destination !== destination) return null; + return { context, project, step, destination }; + } + + relations(route: RouteReference | undefined, existing: readonly Relation[] = []): Relation[] { + const resolved = this.resolve(route); + if (!resolved || resolved.project.status === 'closed' || resolved.step.record_reference + || resolved.step.status === 'completed' || resolved.step.status === 'skipped') { + return existing + .filter((relation) => relation.kind !== 'project') + .map((relation) => structuredClone(relation)); + } + const relation = projectRelation(resolved.context, resolved.project.template.id); + const metadata = relation.metadata && typeof relation.metadata === 'object' && !Array.isArray(relation.metadata) + ? relation.metadata + : {}; + relation.metadata = { + ...metadata, + project_name: resolved.project.name, + step_title: resolved.step.title, + }; + return [ + ...existing.filter((candidate) => candidate.kind !== 'project' || candidate.target_id !== resolved.project.id), + relation, + ].map((candidate) => structuredClone(candidate)); + } + + createRouteHost( + root: HTMLElement, + route: RouteReference, + navigate: (destination: string, params?: Record) => Promise, + ): HTMLElement { + this.resetRouteHost(); + const resolved = this.resolve(route); + if (!resolved) return root; + + const content = document.createElement('div'); + content.className = 'project-route-content'; + const strip = document.createElement('section'); + strip.className = 'project-context-strip'; + strip.setAttribute('aria-label', 'Current project context'); + const copy = document.createElement('div'); + copy.className = 'project-context-copy'; + const eyebrow = document.createElement('span'); + eyebrow.className = 'project-context-eyebrow'; + const title = document.createElement('strong'); + title.className = 'project-context-title'; + const status = document.createElement('span'); + status.className = 'project-context-status'; + status.setAttribute('aria-live', 'polite'); + const back = document.createElement('button'); + back.type = 'button'; + back.className = 'btn btn-ghost project-context-return'; + back.textContent = 'Return to project'; + back.addEventListener('click', () => { + void navigate('projects', { project_id: resolved.project.id }); + }); + copy.append(eyebrow, title, status); + strip.append(copy, back); + root.append(strip, content); + + const refresh = () => { + const project = this.repository.get(resolved.project.id); + const step = project?.steps.find((candidate) => candidate.id === resolved.step.id); + if (!project || !step) { + eyebrow.textContent = 'Project no longer available'; + title.textContent = resolved.step.title; + status.textContent = 'The archived chamber result remains available.'; + return; + } + const index = project.steps.findIndex((candidate) => candidate.id === step.id); + const finished = project.steps.filter((candidate) => candidate.status === 'completed' || candidate.status === 'skipped').length; + eyebrow.textContent = `${project.name} · step ${index + 1} of ${project.steps.length}`; + title.textContent = step.title; + status.textContent = step.record_reference + ? `Saved · ${finished} of ${project.steps.length} steps finished` + : step.status === 'skipped' ? 'Skipped — you can revisit it from Projects' + : `${finished} of ${project.steps.length} steps finished`; + }; + const onRecovery = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (contextKey(detail?.context) !== contextKey(resolved.context)) return; + status.textContent = 'Result saved to Archive; project progress will be recovered from Projects.'; + strip.dataset.state = 'warning'; + }; + const unsubscribe = this.repository.subscribe(refresh); + document.addEventListener('project-step-recovery-needed', onRecovery); + refresh(); + this.#routeCleanup = () => { + unsubscribe(); + document.removeEventListener('project-step-recovery-needed', onRecovery); + }; + return content; + } + + resetRouteHost(): void { + this.#routeCleanup?.(); + this.#routeCleanup = null; + } + + async reconcile(): Promise { + if (!this.repository.snapshot.available || this.repository.snapshot.repository_error) return 0; + const items = await this.#archive.list(); + let recovered = 0; + const visited = new Set(); + for (const item of items) { + if (item.status !== 'ok') continue; + for (const relation of item.relations) { + const context = relationProjectContext(relation); + if (!context) continue; + const key = contextKey(context); + if (visited.has(key)) continue; + visited.add(key); + const project = this.repository.get(context.project_id); + const step = project?.steps.find((candidate) => candidate.id === context.project_step_id); + if (!project || !step || step.attempt !== context.project_step_attempt || step.record_reference) continue; + try { + await this.repository.attachRecord(project.id, step.id, step.attempt, referenceFromItem(item)); + this.#recoveries.delete(key); + recovered++; + } catch (error) { + this.#rememberRecovery(context, referenceFromItem(item), error); + } + } + } + return recovered; + } + + async relatedRecords(projectId: string): Promise { + const items = await this.#archive.list(); + return items.filter((item): item is NormalizedArchiveItem => item.status === 'ok' + && item.relations.some((relation) => relation.kind === 'project' && relation.target_id === projectId)); + } + + async routeForStep(projectId: string, stepId: string): Promise { + const project = this.repository.get(projectId); + const step = project?.steps.find((candidate) => candidate.id === stepId); + if (!project || !step) throw new Error('This project step no longer exists.'); + if (project.status === 'closed') throw new Error('Reopen this project before continuing.'); + const destination = step.selected_destination ?? step.destination; + if (!destination) throw new Error('Choose a chamber for this step first.'); + const params: Record = { + project_id: project.id, + project_step_id: step.id, + project_step_attempt: String(step.attempt), + }; + if (project.template.id === 'daily-constellation' && destination !== 'today') { + Object.assign(params, await this.#dailyRouteParams(project, destination)); + } + return { destination, params }; + } + + async captureToday(route: RouteReference | undefined, daily: SavedArchiveRecord): Promise { + const resolved = this.resolve(route); + if (!resolved || resolved.destination !== 'today' || resolved.step.record_reference) return null; + const key = contextKey(resolved.context); + const recovery = this.#recoveries.get(key); + if (recovery) { + try { + await this.repository.attachRecord( + resolved.project.id, + resolved.step.id, + resolved.step.attempt, + recovery.reference, + ); + this.#recoveries.delete(key); + return null; + } catch { + // Preserve the prior recovery and let normal reconciliation surface it. + } + } + const relations: Relation[] = [{ + kind: 'daily-record', + target_id: daily.record.id, + target_schema: 'sortilune.daily-record', + metadata: { origin: 'today-project-step' }, + }]; + return this.#archive.save({ + chamber: 'today', + type: 'project-daily-selection', + summary: `Today selected for ${resolved.project.name}: ${daily.record.summary}`, + payload: { + daily_record_id: daily.record.id, + daily_record_path: daily.path, + project_id: resolved.project.id, + project_step_id: resolved.step.id, + }, + provenance: daily.record.provenance, + relations: this.relations(route, relations), + ...(daily.record.algorithm ? { algorithm: daily.record.algorithm } : {}), + }); + } + + get recoveries(): ProjectRecovery[] { + return structuredClone([...this.#recoveries.values()]); + } + + async #onArchiveSaved(saved: SavedArchiveRecord): Promise { + const relation = saved.record.relations.find((candidate) => relationProjectContext(candidate)); + if (!relation) return; + const context = relationProjectContext(relation); + if (!context) return; + const reference = projectRecordReference(saved); + try { + await this.repository.attachRecord( + context.project_id, + context.project_step_id, + context.project_step_attempt, + reference, + ); + this.#recoveries.delete(contextKey(context)); + dispatch('project-step-completed', { context, reference }); + } catch (error) { + this.#rememberRecovery(context, reference, error); + } + } + + async #dailyRouteParams(project: Project, destination: string): Promise> { + const todayReference = project.steps.find((step) => step.destination === 'today')?.record_reference; + if (!todayReference) throw new Error('Complete the Today step before opening this chamber.'); + const selection = await this.#archive.readPath(todayReference.path); + if (!selection || selection.status !== 'ok' || selection.type !== 'project-daily-selection') { + throw new Error('The Project link to Today is missing or unreadable. The original Archive records were not changed.'); + } + const selectionPayload = objectValue(selection.payload); + const dailyPath = selectionPayload.daily_record_path; + if (typeof dailyPath !== 'string') throw new Error('The Project link does not contain its DailyRecord path.'); + const dailyItem = await this.#archive.readPath(dailyPath); + if (!dailyItem || dailyItem.status !== 'ok' || dailyItem.type !== 'daily-record') { + throw new Error('The linked DailyRecord is missing or unreadable. Inspect the project timeline in Archive.'); + } + const daily = objectValue(dailyItem.payload); + const outputs = objectValue(daily.outputs); + const output = objectValue(outputs[destination]); + const base = { + daily_record_id: dailyItem.id, + daily_stream: destination, + }; + if (destination === 'oracle') return { + ...base, + daily_card_index: valueText(output.card_index), + daily_seed: valueText(output.illustration_seed), + }; + if (destination === 'constraint') return { + ...base, + daily_category: valueText(output.category), + daily_item_index: valueText(output.item_index), + }; + if (destination === 'canvas') return { + ...base, + daily_generator: valueText(output.generator), + daily_seed: valueText(output.seed), + }; + if (destination === 'diary') return { + ...base, + daily_date: valueText(daily.local_date), + daily_prompt_index: valueText(output.prompt_index), + daily_word_index: valueText(output.word_index), + daily_number: valueText(output.number), + daily_color: valueText(output.color), + daily_direction: valueText(output.direction), + }; + throw new Error(`${destination} does not accept a Today-derived project handoff.`); + } + + #rememberRecovery(context: ProjectContext, reference: ProjectRecordReference, error: unknown): void { + const recovery = { context, reference, error: messageOf(error) }; + this.#recoveries.set(contextKey(context), recovery); + dispatch('project-step-recovery-needed', recovery); + } +} + +function referenceFromItem(item: NormalizedArchiveItem): ProjectRecordReference { + return { + id: item.record.id, + path: item.path, + chamber: item.record.chamber, + type: item.record.type, + summary: item.record.summary, + created_at: item.record.created_at, + }; +} + +function contextKey(context: ProjectContext | null | undefined): string { + return context ? `${context.project_id}/${context.project_step_id}/${context.project_step_attempt}` : ''; +} + +function dispatch(type: string, detail: unknown): void { + if (typeof document !== 'undefined') document.dispatchEvent(new CustomEvent(type, { detail })); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function valueText(value: unknown): string { + if (value === undefined || value === null) throw new Error('The linked DailyRecord does not contain the expected chamber input.'); + return String(value); +} diff --git a/src/projects/templates.ts b/src/projects/templates.ts new file mode 100644 index 0000000..bad90a0 --- /dev/null +++ b/src/projects/templates.ts @@ -0,0 +1,56 @@ +import type { ProjectTemplate } from '../domain/projects.js'; +import { cloneTemplate } from '../domain/projects.js'; + +const templates: ProjectTemplate[] = [ + { + id: 'creative-session', + version: 1, + name: 'Creative session', + description: 'Move from a spark through a useful limit, make an image, then capture what emerged.', + steps: [ + { id: 'oracle', title: 'Find a spark', description: 'Draw an Oracle reading to open the session.', destination: 'oracle', destination_options: [] }, + { id: 'constraint', title: 'Choose a constraint', description: 'Draw a practical creative limit to give the spark a shape.', destination: 'constraint', destination_options: [] }, + { id: 'canvas', title: 'Make the field', description: 'Create a Canvas work from the direction you have gathered.', destination: 'canvas', destination_options: [] }, + { id: 'diary', title: 'Reflect', description: 'Close the session with a Diary entry.', destination: 'diary', destination_options: [] }, + ], + }, + { + id: 'decision-reflection', + version: 1, + name: 'Decision reflection', + description: 'Make a bounded choice, then write down what the result reveals rather than treating it as an order.', + steps: [ + { id: 'decider', title: 'Explore the choice', description: 'Use Decider to surface one option and its recorded provenance.', destination: 'decider', destination_options: [] }, + { id: 'diary', title: 'Reflect', description: 'Write what you agree with, resist, or learned from the result.', destination: 'diary', destination_options: [] }, + ], + }, + { + id: 'daily-constellation', + version: 1, + name: 'Daily constellation', + description: 'Compose Today, follow one strand into a chamber, and leave a short reflection.', + steps: [ + { id: 'today', title: 'Compose today', description: 'Create or reopen today’s deterministic constellation.', destination: 'today', destination_options: [] }, + { + id: 'chamber', + title: 'Follow one strand', + description: 'Choose the part of today’s constellation that feels useful now.', + destination: null, + destination_options: [ + { destination: 'oracle', label: 'Oracle', description: 'Follow today’s card stream into a reading.' }, + { destination: 'constraint', label: 'Constraint', description: 'Use today’s constraint stream as a creative limit.' }, + { destination: 'canvas', label: 'Canvas', description: 'Turn today’s palette and seed into an image.' }, + ], + }, + { id: 'diary', title: 'Reflect', description: 'Capture one thought from the daily constellation.', destination: 'diary', destination_options: [] }, + ], + }, +]; + +export const BUILT_IN_PROJECT_TEMPLATES: readonly ProjectTemplate[] = Object.freeze(templates.map((template) => Object.freeze(template))); + +export function getProjectTemplate(id: string): ProjectTemplate { + const template = BUILT_IN_PROJECT_TEMPLATES.find((candidate) => candidate.id === id); + if (!template) throw new Error(`unknown project template: ${id}`); + return cloneTemplate(template); +} diff --git a/src/projects/validation.ts b/src/projects/validation.ts new file mode 100644 index 0000000..254a126 --- /dev/null +++ b/src/projects/validation.ts @@ -0,0 +1,113 @@ +import type { Project, ProjectDestination, ProjectStep } from '../domain/projects.js'; +import { cloneProject } from '../domain/projects.js'; +import { validateProject } from '../schemas/validate.js'; + +const DESTINATIONS = new Set(['today', 'oracle', 'constraint', 'canvas', 'decider', 'diary']); + +export function parseProjectJson(source: string): Project { + let value: unknown; + try { + value = JSON.parse(source); + } catch (error) { + throw new TypeError(`Project JSON could not be parsed: ${messageOf(error)}`); + } + return normalizeProject(value); +} + +export function normalizeProject(value: unknown): Project { + if (!validateProject(value)) { + throw new TypeError(`Project does not match v1: ${formatValidationErrors(validateProject.errors)}`); + } + const project = cloneProject(value as Project); + const problems = projectSemanticProblems(project); + if (problems.length) throw new TypeError(`Project is inconsistent: ${problems.join('; ')}`); + return project; +} + +export function projectJson(project: Project): string { + const normalized = normalizeProject(project); + return `${JSON.stringify(normalized, null, 2)}\n`; +} + +export function projectSemanticProblems(project: Project): string[] { + const problems: string[] = []; + const stepIds = new Set(); + const templateIds = new Set(); + for (const step of project.template.steps) { + if (templateIds.has(step.id)) problems.push(`template step ${step.id} is duplicated`); + templateIds.add(step.id); + validateDestinations(step, problems, `template step ${step.id}`); + } + if (project.steps.length !== project.template.steps.length) problems.push('step count differs from the pinned template'); + let currentCount = 0; + for (const [index, step] of project.steps.entries()) { + if (stepIds.has(step.id)) problems.push(`step ${step.id} is duplicated`); + stepIds.add(step.id); + const pinned = project.template.steps[index]; + if (!pinned || pinned.id !== step.id || pinned.title !== step.title || pinned.description !== step.description + || pinned.destination !== step.destination || JSON.stringify(pinned.destination_options) !== JSON.stringify(step.destination_options)) { + problems.push(`step ${step.id} differs from the pinned template`); + } + validateDestinations(step, problems, `step ${step.id}`); + if (step.selected_destination && step.destination_options.length > 0 + && !step.destination_options.some((option) => option.destination === step.selected_destination)) { + problems.push(`step ${step.id} selected destination is not an available option`); + } + if (!step.destination_options.length && step.selected_destination !== step.destination) { + problems.push(`step ${step.id} selected destination must match its fixed destination`); + } + if (step.status === 'current') currentCount++; + validateStepState(step, problems); + } + const current = project.current_step_id == null ? null : project.steps.find((step) => step.id === project.current_step_id); + if (project.current_step_id != null && (!current || current.status !== 'current')) problems.push('current_step_id does not identify the current step'); + if (currentCount > 1) problems.push('more than one step is current'); + if ((currentCount === 1) !== (project.current_step_id !== null)) problems.push('current step and current_step_id do not agree'); + const allFinished = project.steps.every((step) => step.status === 'completed' || step.status === 'skipped'); + if (project.status === 'completed') { + if (!allFinished || project.current_step_id !== null || !project.completed_at || project.closed_at) problems.push('completed project state is inconsistent'); + } else if (project.status === 'active') { + if (allFinished || currentCount !== 1 || project.completed_at || project.closed_at) problems.push('active project state is inconsistent'); + } else if (!project.closed_at || project.completed_at) { + problems.push('closed project state is inconsistent'); + } + if (project.updated_at < project.created_at) problems.push('updated_at predates created_at'); + return problems; +} + +function validateDestinations(step: Pick, problems: string[], label: string): void { + if (step.destination && !DESTINATIONS.has(step.destination)) problems.push(`${label} uses an unsupported destination`); + const seen = new Set(); + for (const option of step.destination_options) { + if (!DESTINATIONS.has(option.destination)) problems.push(`${label} offers an unsupported destination`); + if (seen.has(option.destination)) problems.push(`${label} repeats destination ${option.destination}`); + seen.add(option.destination); + } + if (step.destination_options.length && step.destination !== null) problems.push(`${label} cannot have both a fixed destination and destination options`); + if (!step.destination_options.length && !step.destination) problems.push(`${label} needs a destination or destination options`); +} + +function validateStepState(step: ProjectStep, problems: string[]): void { + const label = `step ${step.id}`; + if (step.status === 'completed') { + if (!step.record_reference || !step.completed_at || step.skipped_at) problems.push(`${label} completed state lacks a record or timestamp`); + return; + } + if (step.status === 'skipped') { + if (step.record_reference || step.completed_at || !step.skipped_at) problems.push(`${label} skipped state is inconsistent`); + return; + } + if (step.record_reference || step.completed_at || step.skipped_at) problems.push(`${label} unfinished state contains completion data`); +} + +function formatValidationErrors(errors: unknown): string { + if (!Array.isArray(errors)) return 'unknown schema error'; + return errors.slice(0, 5).map((error) => { + const item = error as { instancePath?: string; message?: string }; + return `${item.instancePath || '/'} ${item.message || 'is invalid'}`; + }).join('; '); +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/receipts/receipt.ts b/src/receipts/receipt.ts new file mode 100644 index 0000000..9265be8 --- /dev/null +++ b/src/receipts/receipt.ts @@ -0,0 +1,165 @@ +import type { JsonValue } from '../domain/archive-record.js'; +import { nowRfc3339, parseSha256Hex } from '../domain/identifiers.js'; +import type { PortableReceipt, ReceiptSourceKind, ReceiptVerification } from '../domain/receipts.js'; +import { validateReceipt } from '../schemas/validate.js'; + +const MAX_VALUES = 50_000; +const MAX_DEPTH = 64; + +export interface ReceiptDraft { + sourceKind: ReceiptSourceKind; + sourceId: string; + title: string; + content: JsonValue; + limitations?: string[]; +} + +export async function createReceipt(draft: ReceiptDraft): Promise { + const base = { + schema: 'sortilune.receipt' as const, + schema_version: 1 as const, + kind: 'result' as const, + created_at: nowRfc3339(), + source: { + kind: draft.sourceKind, + id: boundedText(draft.sourceId, 200, 'receipt source ID'), + title: boundedText(draft.title, 320, 'receipt title'), + }, + content: structuredClone(draft.content), + limitations: (draft.limitations ?? defaultLimitations()).map((item) => boundedText(item, 400, 'receipt limitation')).slice(0, 8), + }; + const value = parseSha256Hex(await sha256(canonicalJson(base))); + const receipt: PortableReceipt = { + ...base, + integrity: { + algorithm: 'SHA-256', + canonicalization: 'sortilune.canonical-json/v1', + value, + }, + }; + if (!validateReceipt(receipt)) throw new TypeError(`Could not create a valid receipt: ${validationMessage()}`); + return receipt; +} + +export async function verifyReceipt(input: unknown): Promise { + const envelope = objectValue(input); + if (envelope.schema !== 'sortilune.receipt' || envelope.schema_version !== 1) { + return { status: 'unsupported', reason: 'This is not a supported Sortilune receipt version.' }; + } + if (!validateReceipt(input)) return { status: 'invalid', reason: validationMessage() }; + if (input.integrity.algorithm !== 'SHA-256' || input.integrity.canonicalization !== 'sortilune.canonical-json/v1') { + return { status: 'unsupported', reason: 'The receipt uses an unsupported integrity recipe.' }; + } + const { integrity, ...base } = input; + let actual: string; + try { + actual = await sha256(canonicalJson(base)); + } catch (error) { + return { status: 'invalid', reason: messageOf(error) }; + } + return actual === integrity.value + ? { status: 'valid', receipt: structuredClone(input), expected: integrity.value, actual } + : { status: 'changed', receipt: structuredClone(input), expected: integrity.value, actual }; +} + +export function canonicalJson(value: unknown): string { + const state = { count: 0, ancestors: new Set() }; + return canonicalValue(value, 0, state); +} + +export function receiptJson(receipt: PortableReceipt): string { + if (!validateReceipt(receipt)) throw new TypeError(`Cannot export an invalid receipt: ${validationMessage()}`); + return `${JSON.stringify(receipt, null, 2)}\n`; +} + +export function downloadReceipt(receipt: PortableReceipt): void { + const blob = new Blob([receiptJson(receipt)], { type: 'application/json;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `${safeName(receipt.source.title)}.sortilune-receipt.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function canonicalValue(value: unknown, depth: number, state: { count: number; ancestors: Set }): string { + state.count += 1; + if (state.count > MAX_VALUES) throw new TypeError('Receipt content contains too many values.'); + if (depth > MAX_DEPTH) throw new TypeError('Receipt content is too deeply nested.'); + if (value === null || typeof value === 'boolean') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Receipt content contains a non-finite number.'); + return JSON.stringify(value); + } + if (typeof value === 'string') { + rejectLoneSurrogates(value); + return JSON.stringify(value); + } + if (!value || typeof value !== 'object') throw new TypeError('Receipt content must contain JSON values only.'); + if (state.ancestors.has(value)) throw new TypeError('Receipt content contains a circular reference.'); + state.ancestors.add(value); + try { + if (Array.isArray(value)) return `[${value.map((item) => canonicalValue(item, depth + 1, state)).join(',')}]`; + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys.map((key) => { + rejectLoneSurrogates(key); + return `${JSON.stringify(key)}:${canonicalValue(record[key], depth + 1, state)}`; + }).join(',')}}`; + } finally { + state.ancestors.delete(value); + } +} + +function rejectLoneSurrogates(value: string): void { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new TypeError('Receipt text contains an unpaired Unicode surrogate.'); + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + throw new TypeError('Receipt text contains an unpaired Unicode surrogate.'); + } + } +} + +async function sha256(value: string): Promise { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function defaultLimitations(): string[] { + return [ + 'The checksum detects changes to this receipt; it does not prove authorship or trusted time.', + 'The receipt is a snapshot and does not modify or replace the original Archive record.', + 'Referenced image or audio files are not embedded in the JSON receipt.', + ]; +} + +function boundedText(value: string, maximum: number, label: string): string { + const normalized = value.trim(); + if (!normalized || normalized.length > maximum) throw new TypeError(`${label} must contain 1 to ${maximum} characters.`); + return normalized; +} + +function safeName(value: string): string { + const result = value.normalize('NFC').toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '').slice(0, 80); + return result || 'sortilune-result'; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} + +function validationMessage(): string { + const error = validateReceipt.errors?.[0]; + return error ? `${error.instancePath || '/'} ${error.keyword}` : 'unknown receipt validation error'; +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/schemas/generated/pack-validator.generated.d.ts b/src/schemas/generated/pack-validator.generated.d.ts new file mode 100644 index 0000000..46fd163 --- /dev/null +++ b/src/schemas/generated/pack-validator.generated.d.ts @@ -0,0 +1,4 @@ +import type { SortilunePack } from '../../domain/packs.js'; +import type { StandaloneValidator } from './validators.generated.js'; + +export const validatePack: StandaloneValidator; diff --git a/src/schemas/generated/pack-validator.generated.js b/src/schemas/generated/pack-validator.generated.js new file mode 100644 index 0000000..b59fe39 --- /dev/null +++ b/src/schemas/generated/pack-validator.generated.js @@ -0,0 +1,2983 @@ +// Generated by scripts/generate-validators.mjs. Do not edit by hand. +// Ajv compilation runs only at build time; runtime helpers perform no schema compilation. +import equalRuntime from "ajv/dist/runtime/equal.js"; +import ucs2LengthRuntime from "ajv/dist/runtime/ucs2length.js"; +import { isRfc3339Timestamp } from "../../domain/identifiers.js"; +function isAbsoluteUri(value) { + if (typeof value !== "string" || value.length > 2048 || /[\u0000-\u0020]/u.test(value)) return false; + try { return Boolean(new URL(value).protocol); } catch { return false; } +} +const formatDefinitions = { fullFormats: { "date-time": { validate: isRfc3339Timestamp }, uri: isAbsoluteUri } }; +"use strict"; +export const validatePack = validate52; +const func0 = Object.prototype.hasOwnProperty; +const func69 = ucs2LengthRuntime.default; +const pattern3 = new RegExp("^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", "u"); + +function validate54(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate54.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func69(data) > 96){ +validate54.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func69(data) < 1){ +validate54.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate54.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate54.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate54.errors = vErrors; +return errors === 0; +} +validate54.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate56(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate56.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func69(data) > 120){ +validate56.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 120}}]; +return false; +} +else { +if(func69(data) < 1){ +validate56.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate56.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate56.errors = vErrors; +return errors === 0; +} +validate56.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate53(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate53.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((data.cards === undefined) || (!(func0.call(data, "cards")))) && (missing0 = "cards")){ +validate53.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(key0 === "cards")){ +validate53.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.cards !== undefined && func0.call(data, "cards")){ +let data0 = data.cards; +const _errs2 = errors; +if(errors === _errs2){ +if(Array.isArray(data0)){ +if(data0.length > 500){ +validate53.errors = [{instancePath:instancePath+"/cards",schemaPath:"#/properties/cards/maxItems",keyword:"maxItems",params:{limit: 500}}]; +return false; +} +else { +if(data0.length < 1){ +validate53.errors = [{instancePath:instancePath+"/cards",schemaPath:"#/properties/cards/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid1 = true; +const len0 = data0.length; +for(let i0=0; i0 2000){ +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/meaning",schemaPath:"#/properties/cards/items/properties/meaning/maxLength",keyword:"maxLength",params:{limit: 2000}}]; +return false; +} +else { +if(func69(data4) < 1){ +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/meaning",schemaPath:"#/properties/cards/items/properties/meaning/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/meaning",schemaPath:"#/properties/cards/items/properties/meaning/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid2 = _errs9 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data1.symbol !== undefined && func0.call(data1, "symbol")){ +let data5 = data1.symbol; +const _errs11 = errors; +if(errors === _errs11){ +if(typeof data5 === "string"){ +if(func69(data5) > 16){ +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/symbol",schemaPath:"#/properties/cards/items/properties/symbol/maxLength",keyword:"maxLength",params:{limit: 16}}]; +return false; +} +} +else { +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/symbol",schemaPath:"#/properties/cards/items/properties/symbol/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid2 = _errs11 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data1.category !== undefined && func0.call(data1, "category")){ +let data6 = data1.category; +const _errs13 = errors; +if(errors === _errs13){ +if(typeof data6 === "string"){ +if(func69(data6) > 64){ +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/category",schemaPath:"#/properties/cards/items/properties/category/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +} +else { +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/category",schemaPath:"#/properties/cards/items/properties/category/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid2 = _errs13 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data1.keywords !== undefined && func0.call(data1, "keywords")){ +let data7 = data1.keywords; +const _errs15 = errors; +if(errors === _errs15){ +if(Array.isArray(data7)){ +if(data7.length > 16){ +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/keywords",schemaPath:"#/properties/cards/items/properties/keywords/maxItems",keyword:"maxItems",params:{limit: 16}}]; +return false; +} +else { +var valid3 = true; +const len1 = data7.length; +for(let i1=0; i1 64){ +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/keywords/" + i1,schemaPath:"#/properties/cards/items/properties/keywords/items/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(func69(data8) < 1){ +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/keywords/" + i1,schemaPath:"#/properties/cards/items/properties/keywords/items/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/keywords/" + i1,schemaPath:"#/properties/cards/items/properties/keywords/items/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid3 = _errs17 === errors; +if(!valid3){ +break; +} +} +} +} +else { +validate53.errors = [{instancePath:instancePath+"/cards/" + i0+"/keywords",schemaPath:"#/properties/cards/items/properties/keywords/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +var valid2 = _errs15 === errors; +} +else { +var valid2 = true; +} +} +} +} +} +} +} +} +} +else { +validate53.errors = [{instancePath:instancePath+"/cards/" + i0,schemaPath:"#/properties/cards/items/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid1 = _errs4 === errors; +if(!valid1){ +break; +} +} +} +} +} +else { +validate53.errors = [{instancePath:instancePath+"/cards",schemaPath:"#/properties/cards/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +} +} +} +} +else { +validate53.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate53.errors = vErrors; +return errors === 0; +} +validate53.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate59(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate59.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((data.items === undefined) || (!(func0.call(data, "items")))) && (missing0 = "items")){ +validate59.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(key0 === "items")){ +validate59.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.items !== undefined && func0.call(data, "items")){ +let data0 = data.items; +const _errs2 = errors; +if(errors === _errs2){ +if(Array.isArray(data0)){ +if(data0.length > 2000){ +validate59.errors = [{instancePath:instancePath+"/items",schemaPath:"#/properties/items/maxItems",keyword:"maxItems",params:{limit: 2000}}]; +return false; +} +else { +if(data0.length < 1){ +validate59.errors = [{instancePath:instancePath+"/items",schemaPath:"#/properties/items/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid1 = true; +const len0 = data0.length; +for(let i0=0; i0 500){ +validate59.errors = [{instancePath:instancePath+"/items/" + i0+"/text",schemaPath:"#/properties/items/items/properties/text/maxLength",keyword:"maxLength",params:{limit: 500}}]; +return false; +} +else { +if(func69(data3) < 1){ +validate59.errors = [{instancePath:instancePath+"/items/" + i0+"/text",schemaPath:"#/properties/items/items/properties/text/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate59.errors = [{instancePath:instancePath+"/items/" + i0+"/text",schemaPath:"#/properties/items/items/properties/text/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid2 = _errs8 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data1.category !== undefined && func0.call(data1, "category")){ +let data4 = data1.category; +const _errs10 = errors; +if(errors === _errs10){ +if(typeof data4 === "string"){ +if(func69(data4) > 64){ +validate59.errors = [{instancePath:instancePath+"/items/" + i0+"/category",schemaPath:"#/properties/items/items/properties/category/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +} +else { +validate59.errors = [{instancePath:instancePath+"/items/" + i0+"/category",schemaPath:"#/properties/items/items/properties/category/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid2 = _errs10 === errors; +} +else { +var valid2 = true; +} +} +} +} +} +} +else { +validate59.errors = [{instancePath:instancePath+"/items/" + i0,schemaPath:"#/properties/items/items/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid1 = _errs4 === errors; +if(!valid1){ +break; +} +} +} +} +} +else { +validate59.errors = [{instancePath:instancePath+"/items",schemaPath:"#/properties/items/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +} +} +} +} +else { +validate59.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate59.errors = vErrors; +return errors === 0; +} +validate59.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate62(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate62.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs1 = errors; +let valid0 = false; +const _errs2 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.prompts !== undefined && func0.call(data, "prompts")){ +let data0 = data.prompts; +const _errs3 = errors; +if(errors === _errs3){ +if(Array.isArray(data0)){ +if(data0.length < 1){ +const err0 = {instancePath:instancePath+"/prompts",schemaPath:"#/anyOf/0/properties/prompts/minItems",keyword:"minItems",params:{limit: 1}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +} +else { +const err1 = {instancePath:instancePath+"/prompts",schemaPath:"#/anyOf/0/properties/prompts/type",keyword:"type",params:{type: "array"}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +} +} +} +var _valid0 = _errs2 === errors; +valid0 = valid0 || _valid0; +if(_valid0){ +var props0 = {}; +props0.prompts = true; +} +const _errs5 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.words !== undefined && func0.call(data, "words")){ +let data1 = data.words; +const _errs6 = errors; +if(errors === _errs6){ +if(Array.isArray(data1)){ +if(data1.length < 1){ +const err2 = {instancePath:instancePath+"/words",schemaPath:"#/anyOf/1/properties/words/minItems",keyword:"minItems",params:{limit: 1}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +} +else { +const err3 = {instancePath:instancePath+"/words",schemaPath:"#/anyOf/1/properties/words/type",keyword:"type",params:{type: "array"}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +} +} +} +var _valid0 = _errs5 === errors; +valid0 = valid0 || _valid0; +if(_valid0){ +if(props0 !== true){ +props0 = props0 || {}; +props0.words = true; +} +} +if(!valid0){ +const err4 = {instancePath,schemaPath:"#/anyOf",keyword:"anyOf",params:{}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +validate62.errors = vErrors; +return false; +} +else { +errors = _errs1; +if(vErrors !== null){ +if(_errs1){ +vErrors.length = _errs1; +} +else { +vErrors = null; +} +} +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((data.prompts === undefined) || (!(func0.call(data, "prompts")))) && (missing0 = "prompts")) || (((data.words === undefined) || (!(func0.call(data, "words")))) && (missing0 = "words"))){ +validate62.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs8 = errors; +for(const key0 of Object.keys(data)){ +if(!((key0 === "prompts") || (key0 === "words"))){ +validate62.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs8 === errors){ +if(data.prompts !== undefined && func0.call(data, "prompts")){ +let data2 = data.prompts; +const _errs9 = errors; +if(errors === _errs9){ +if(Array.isArray(data2)){ +if(data2.length > 2000){ +validate62.errors = [{instancePath:instancePath+"/prompts",schemaPath:"#/properties/prompts/maxItems",keyword:"maxItems",params:{limit: 2000}}]; +return false; +} +else { +var valid4 = true; +const len0 = data2.length; +for(let i0=0; i0 1000){ +validate62.errors = [{instancePath:instancePath+"/prompts/" + i0+"/text",schemaPath:"#/properties/prompts/items/properties/text/maxLength",keyword:"maxLength",params:{limit: 1000}}]; +return false; +} +else { +if(func69(data5) < 1){ +validate62.errors = [{instancePath:instancePath+"/prompts/" + i0+"/text",schemaPath:"#/properties/prompts/items/properties/text/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate62.errors = [{instancePath:instancePath+"/prompts/" + i0+"/text",schemaPath:"#/properties/prompts/items/properties/text/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid5 = _errs15 === errors; +} +else { +var valid5 = true; +} +} +} +} +} +else { +validate62.errors = [{instancePath:instancePath+"/prompts/" + i0,schemaPath:"#/properties/prompts/items/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid4 = _errs11 === errors; +if(!valid4){ +break; +} +} +} +} +else { +validate62.errors = [{instancePath:instancePath+"/prompts",schemaPath:"#/properties/prompts/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +var valid3 = _errs9 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.words !== undefined && func0.call(data, "words")){ +let data6 = data.words; +const _errs17 = errors; +if(errors === _errs17){ +if(Array.isArray(data6)){ +if(data6.length > 5000){ +validate62.errors = [{instancePath:instancePath+"/words",schemaPath:"#/properties/words/maxItems",keyword:"maxItems",params:{limit: 5000}}]; +return false; +} +else { +var valid6 = true; +const len1 = data6.length; +for(let i1=0; i1 80){ +validate62.errors = [{instancePath:instancePath+"/words/" + i1+"/text",schemaPath:"#/properties/words/items/properties/text/maxLength",keyword:"maxLength",params:{limit: 80}}]; +return false; +} +else { +if(func69(data9) < 1){ +validate62.errors = [{instancePath:instancePath+"/words/" + i1+"/text",schemaPath:"#/properties/words/items/properties/text/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate62.errors = [{instancePath:instancePath+"/words/" + i1+"/text",schemaPath:"#/properties/words/items/properties/text/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid7 = _errs23 === errors; +} +else { +var valid7 = true; +} +} +} +} +} +else { +validate62.errors = [{instancePath:instancePath+"/words/" + i1,schemaPath:"#/properties/words/items/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid6 = _errs19 === errors; +if(!valid6){ +break; +} +} +} +} +else { +validate62.errors = [{instancePath:instancePath+"/words",schemaPath:"#/properties/words/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +var valid3 = _errs17 === errors; +} +else { +var valid3 = true; +} +} +} +} +} +else { +validate62.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate62.errors = vErrors; +return errors === 0; +} +validate62.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate70(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate70.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(Array.isArray(data)){ +if(data.length > 1000){ +validate70.errors = [{instancePath,schemaPath:"#/maxItems",keyword:"maxItems",params:{limit: 1000}}]; +return false; +} +else { +if(data.length < 1){ +validate70.errors = [{instancePath,schemaPath:"#/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid0 = true; +const len0 = data.length; +for(let i0=0; i0 200){ +validate70.errors = [{instancePath:instancePath+"/" + i0,schemaPath:"#/items/maxLength",keyword:"maxLength",params:{limit: 200}}]; +return false; +} +else { +if(func69(data0) < 1){ +validate70.errors = [{instancePath:instancePath+"/" + i0,schemaPath:"#/items/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate70.errors = [{instancePath:instancePath+"/" + i0,schemaPath:"#/items/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs1 === errors; +if(!valid0){ +break; +} +} +} +} +} +else { +validate70.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +validate70.errors = vErrors; +return errors === 0; +} +validate70.evaluated = {"items":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate67(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate67.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(errors === _errs1){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing0 = "name"))) || (((data.tool === undefined) || (!(func0.call(data, "tool")))) && (missing0 = "tool"))) || (((data.items === undefined) || (!(func0.call(data, "items")))) && (missing0 = "items"))){ +const err0 = {instancePath,schemaPath:"#/oneOf/0/required",keyword:"required",params:{missingProperty: missing0}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +else { +const _errs3 = errors; +for(const key0 of Object.keys(data)){ +if(!((((key0 === "id") || (key0 === "name")) || (key0 === "tool")) || (key0 === "items"))){ +const err1 = {instancePath,schemaPath:"#/oneOf/0/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +break; +} +} +if(_errs3 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs4 = errors; +if(!(validate54(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate54.errors : vErrors.concat(validate54.errors); +errors = vErrors.length; +} +var valid1 = _errs4 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data.name !== undefined && func0.call(data, "name")){ +const _errs5 = errors; +if(!(validate56(data.name, {instancePath:instancePath+"/name",parentData:data,parentDataProperty:"name",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +var valid1 = _errs5 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data.tool !== undefined && func0.call(data, "tool")){ +let data2 = data.tool; +const _errs6 = errors; +if(!(((data2 === "wheel") || (data2 === "name-picker")) || (data2 === "shuffle"))){ +const err2 = {instancePath:instancePath+"/tool",schemaPath:"#/oneOf/0/properties/tool/enum",keyword:"enum",params:{allowedValues: [{"type":"object","required":["id","name","tool","items"],"properties":{"id":{"$ref":"#/$defs/itemId"},"name":{"$ref":"#/$defs/name"},"tool":{"enum":["wheel","name-picker","shuffle"]},"items":{"$ref":"#/$defs/boundedList"}},"additionalProperties":false},{"type":"object","required":["id","name","tool","minimum","maximum","integer"],"properties":{"id":{"$ref":"#/$defs/itemId"},"name":{"$ref":"#/$defs/name"},"tool":{"const":"number"},"minimum":{"type":"number","minimum":-1000000000,"maximum":1000000000},"maximum":{"type":"number","minimum":-1000000000,"maximum":1000000000},"integer":{"type":"boolean"}},"additionalProperties":false},{"type":"object","required":["id","name","tool","count","sides"],"properties":{"id":{"$ref":"#/$defs/itemId"},"name":{"$ref":"#/$defs/name"},"tool":{"const":"dice"},"count":{"type":"integer","minimum":1,"maximum":20},"sides":{"type":"integer","minimum":2,"maximum":1000}},"additionalProperties":false},{"type":"object","required":["id","name","tool","heads","tails"],"properties":{"id":{"$ref":"#/$defs/itemId"},"name":{"$ref":"#/$defs/name"},"tool":{"const":"coin"},"heads":{"type":"string","minLength":1,"maxLength":80},"tails":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":false}][0].properties.tool.enum}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +var valid1 = _errs6 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data.items !== undefined && func0.call(data, "items")){ +const _errs7 = errors; +if(!(validate70(data.items, {instancePath:instancePath+"/items",parentData:data,parentDataProperty:"items",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate70.errors : vErrors.concat(validate70.errors); +errors = vErrors.length; +} +var valid1 = _errs7 === errors; +} +else { +var valid1 = true; +} +} +} +} +} +} +} +else { +const err3 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +var props0 = true; +} +const _errs8 = errors; +if(errors === _errs8){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing1; +if((((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing1 = "id")) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing1 = "name"))) || (((data.tool === undefined) || (!(func0.call(data, "tool")))) && (missing1 = "tool"))) || (((data.minimum === undefined) || (!(func0.call(data, "minimum")))) && (missing1 = "minimum"))) || (((data.maximum === undefined) || (!(func0.call(data, "maximum")))) && (missing1 = "maximum"))) || (((data.integer === undefined) || (!(func0.call(data, "integer")))) && (missing1 = "integer"))){ +const err4 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: missing1}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +else { +const _errs10 = errors; +for(const key1 of Object.keys(data)){ +if(!((((((key1 === "id") || (key1 === "name")) || (key1 === "tool")) || (key1 === "minimum")) || (key1 === "maximum")) || (key1 === "integer"))){ +const err5 = {instancePath,schemaPath:"#/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +break; +} +} +if(_errs10 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs11 = errors; +if(!(validate54(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate54.errors : vErrors.concat(validate54.errors); +errors = vErrors.length; +} +var valid2 = _errs11 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.name !== undefined && func0.call(data, "name")){ +const _errs12 = errors; +if(!(validate56(data.name, {instancePath:instancePath+"/name",parentData:data,parentDataProperty:"name",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +var valid2 = _errs12 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.tool !== undefined && func0.call(data, "tool")){ +const _errs13 = errors; +if("number" !== data.tool){ +const err6 = {instancePath:instancePath+"/tool",schemaPath:"#/oneOf/1/properties/tool/const",keyword:"const",params:{allowedValue: "number"}}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +var valid2 = _errs13 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.minimum !== undefined && func0.call(data, "minimum")){ +let data7 = data.minimum; +const _errs14 = errors; +if(errors === _errs14){ +if((typeof data7 == "number") && (isFinite(data7))){ +if(data7 > 1000000000 || isNaN(data7)){ +const err7 = {instancePath:instancePath+"/minimum",schemaPath:"#/oneOf/1/properties/minimum/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1000000000}}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +else { +if(data7 < -1000000000 || isNaN(data7)){ +const err8 = {instancePath:instancePath+"/minimum",schemaPath:"#/oneOf/1/properties/minimum/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1000000000}}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +} +else { +const err9 = {instancePath:instancePath+"/minimum",schemaPath:"#/oneOf/1/properties/minimum/type",keyword:"type",params:{type: "number"}}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +} +var valid2 = _errs14 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.maximum !== undefined && func0.call(data, "maximum")){ +let data8 = data.maximum; +const _errs16 = errors; +if(errors === _errs16){ +if((typeof data8 == "number") && (isFinite(data8))){ +if(data8 > 1000000000 || isNaN(data8)){ +const err10 = {instancePath:instancePath+"/maximum",schemaPath:"#/oneOf/1/properties/maximum/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1000000000}}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +else { +if(data8 < -1000000000 || isNaN(data8)){ +const err11 = {instancePath:instancePath+"/maximum",schemaPath:"#/oneOf/1/properties/maximum/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1000000000}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +} +} +} +else { +const err12 = {instancePath:instancePath+"/maximum",schemaPath:"#/oneOf/1/properties/maximum/type",keyword:"type",params:{type: "number"}}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +} +var valid2 = _errs16 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.integer !== undefined && func0.call(data, "integer")){ +const _errs18 = errors; +if(typeof data.integer !== "boolean"){ +const err13 = {instancePath:instancePath+"/integer",schemaPath:"#/oneOf/1/properties/integer/type",keyword:"type",params:{type: "boolean"}}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +} +var valid2 = _errs18 === errors; +} +else { +var valid2 = true; +} +} +} +} +} +} +} +} +} +else { +const err14 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +} +} +var _valid0 = _errs8 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +if(props0 !== true){ +props0 = true; +} +} +const _errs20 = errors; +if(errors === _errs20){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing2; +if(((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing2 = "id")) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing2 = "name"))) || (((data.tool === undefined) || (!(func0.call(data, "tool")))) && (missing2 = "tool"))) || (((data.count === undefined) || (!(func0.call(data, "count")))) && (missing2 = "count"))) || (((data.sides === undefined) || (!(func0.call(data, "sides")))) && (missing2 = "sides"))){ +const err15 = {instancePath,schemaPath:"#/oneOf/2/required",keyword:"required",params:{missingProperty: missing2}}; +if(vErrors === null){ +vErrors = [err15]; +} +else { +vErrors.push(err15); +} +errors++; +} +else { +const _errs22 = errors; +for(const key2 of Object.keys(data)){ +if(!(((((key2 === "id") || (key2 === "name")) || (key2 === "tool")) || (key2 === "count")) || (key2 === "sides"))){ +const err16 = {instancePath,schemaPath:"#/oneOf/2/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2}}; +if(vErrors === null){ +vErrors = [err16]; +} +else { +vErrors.push(err16); +} +errors++; +break; +} +} +if(_errs22 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs23 = errors; +if(!(validate54(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate54.errors : vErrors.concat(validate54.errors); +errors = vErrors.length; +} +var valid3 = _errs23 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.name !== undefined && func0.call(data, "name")){ +const _errs24 = errors; +if(!(validate56(data.name, {instancePath:instancePath+"/name",parentData:data,parentDataProperty:"name",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +var valid3 = _errs24 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.tool !== undefined && func0.call(data, "tool")){ +const _errs25 = errors; +if("dice" !== data.tool){ +const err17 = {instancePath:instancePath+"/tool",schemaPath:"#/oneOf/2/properties/tool/const",keyword:"const",params:{allowedValue: "dice"}}; +if(vErrors === null){ +vErrors = [err17]; +} +else { +vErrors.push(err17); +} +errors++; +} +var valid3 = _errs25 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.count !== undefined && func0.call(data, "count")){ +let data13 = data.count; +const _errs26 = errors; +if(!(((typeof data13 == "number") && (!(data13 % 1) && !isNaN(data13))) && (isFinite(data13)))){ +const err18 = {instancePath:instancePath+"/count",schemaPath:"#/oneOf/2/properties/count/type",keyword:"type",params:{type: "integer"}}; +if(vErrors === null){ +vErrors = [err18]; +} +else { +vErrors.push(err18); +} +errors++; +} +if(errors === _errs26){ +if((typeof data13 == "number") && (isFinite(data13))){ +if(data13 > 20 || isNaN(data13)){ +const err19 = {instancePath:instancePath+"/count",schemaPath:"#/oneOf/2/properties/count/maximum",keyword:"maximum",params:{comparison: "<=", limit: 20}}; +if(vErrors === null){ +vErrors = [err19]; +} +else { +vErrors.push(err19); +} +errors++; +} +else { +if(data13 < 1 || isNaN(data13)){ +const err20 = {instancePath:instancePath+"/count",schemaPath:"#/oneOf/2/properties/count/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}; +if(vErrors === null){ +vErrors = [err20]; +} +else { +vErrors.push(err20); +} +errors++; +} +} +} +} +var valid3 = _errs26 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.sides !== undefined && func0.call(data, "sides")){ +let data14 = data.sides; +const _errs28 = errors; +if(!(((typeof data14 == "number") && (!(data14 % 1) && !isNaN(data14))) && (isFinite(data14)))){ +const err21 = {instancePath:instancePath+"/sides",schemaPath:"#/oneOf/2/properties/sides/type",keyword:"type",params:{type: "integer"}}; +if(vErrors === null){ +vErrors = [err21]; +} +else { +vErrors.push(err21); +} +errors++; +} +if(errors === _errs28){ +if((typeof data14 == "number") && (isFinite(data14))){ +if(data14 > 1000 || isNaN(data14)){ +const err22 = {instancePath:instancePath+"/sides",schemaPath:"#/oneOf/2/properties/sides/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1000}}; +if(vErrors === null){ +vErrors = [err22]; +} +else { +vErrors.push(err22); +} +errors++; +} +else { +if(data14 < 2 || isNaN(data14)){ +const err23 = {instancePath:instancePath+"/sides",schemaPath:"#/oneOf/2/properties/sides/minimum",keyword:"minimum",params:{comparison: ">=", limit: 2}}; +if(vErrors === null){ +vErrors = [err23]; +} +else { +vErrors.push(err23); +} +errors++; +} +} +} +} +var valid3 = _errs28 === errors; +} +else { +var valid3 = true; +} +} +} +} +} +} +} +} +else { +const err24 = {instancePath,schemaPath:"#/oneOf/2/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err24]; +} +else { +vErrors.push(err24); +} +errors++; +} +} +var _valid0 = _errs20 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 2]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 2; +if(props0 !== true){ +props0 = true; +} +} +const _errs30 = errors; +if(errors === _errs30){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing3; +if(((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing3 = "id")) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing3 = "name"))) || (((data.tool === undefined) || (!(func0.call(data, "tool")))) && (missing3 = "tool"))) || (((data.heads === undefined) || (!(func0.call(data, "heads")))) && (missing3 = "heads"))) || (((data.tails === undefined) || (!(func0.call(data, "tails")))) && (missing3 = "tails"))){ +const err25 = {instancePath,schemaPath:"#/oneOf/3/required",keyword:"required",params:{missingProperty: missing3}}; +if(vErrors === null){ +vErrors = [err25]; +} +else { +vErrors.push(err25); +} +errors++; +} +else { +const _errs32 = errors; +for(const key3 of Object.keys(data)){ +if(!(((((key3 === "id") || (key3 === "name")) || (key3 === "tool")) || (key3 === "heads")) || (key3 === "tails"))){ +const err26 = {instancePath,schemaPath:"#/oneOf/3/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3}}; +if(vErrors === null){ +vErrors = [err26]; +} +else { +vErrors.push(err26); +} +errors++; +break; +} +} +if(_errs32 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs33 = errors; +if(!(validate54(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate54.errors : vErrors.concat(validate54.errors); +errors = vErrors.length; +} +var valid4 = _errs33 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data.name !== undefined && func0.call(data, "name")){ +const _errs34 = errors; +if(!(validate56(data.name, {instancePath:instancePath+"/name",parentData:data,parentDataProperty:"name",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +var valid4 = _errs34 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data.tool !== undefined && func0.call(data, "tool")){ +const _errs35 = errors; +if("coin" !== data.tool){ +const err27 = {instancePath:instancePath+"/tool",schemaPath:"#/oneOf/3/properties/tool/const",keyword:"const",params:{allowedValue: "coin"}}; +if(vErrors === null){ +vErrors = [err27]; +} +else { +vErrors.push(err27); +} +errors++; +} +var valid4 = _errs35 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data.heads !== undefined && func0.call(data, "heads")){ +let data18 = data.heads; +const _errs36 = errors; +if(errors === _errs36){ +if(typeof data18 === "string"){ +if(func69(data18) > 80){ +const err28 = {instancePath:instancePath+"/heads",schemaPath:"#/oneOf/3/properties/heads/maxLength",keyword:"maxLength",params:{limit: 80}}; +if(vErrors === null){ +vErrors = [err28]; +} +else { +vErrors.push(err28); +} +errors++; +} +else { +if(func69(data18) < 1){ +const err29 = {instancePath:instancePath+"/heads",schemaPath:"#/oneOf/3/properties/heads/minLength",keyword:"minLength",params:{limit: 1}}; +if(vErrors === null){ +vErrors = [err29]; +} +else { +vErrors.push(err29); +} +errors++; +} +} +} +else { +const err30 = {instancePath:instancePath+"/heads",schemaPath:"#/oneOf/3/properties/heads/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err30]; +} +else { +vErrors.push(err30); +} +errors++; +} +} +var valid4 = _errs36 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data.tails !== undefined && func0.call(data, "tails")){ +let data19 = data.tails; +const _errs38 = errors; +if(errors === _errs38){ +if(typeof data19 === "string"){ +if(func69(data19) > 80){ +const err31 = {instancePath:instancePath+"/tails",schemaPath:"#/oneOf/3/properties/tails/maxLength",keyword:"maxLength",params:{limit: 80}}; +if(vErrors === null){ +vErrors = [err31]; +} +else { +vErrors.push(err31); +} +errors++; +} +else { +if(func69(data19) < 1){ +const err32 = {instancePath:instancePath+"/tails",schemaPath:"#/oneOf/3/properties/tails/minLength",keyword:"minLength",params:{limit: 1}}; +if(vErrors === null){ +vErrors = [err32]; +} +else { +vErrors.push(err32); +} +errors++; +} +} +} +else { +const err33 = {instancePath:instancePath+"/tails",schemaPath:"#/oneOf/3/properties/tails/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err33]; +} +else { +vErrors.push(err33); +} +errors++; +} +} +var valid4 = _errs38 === errors; +} +else { +var valid4 = true; +} +} +} +} +} +} +} +} +else { +const err34 = {instancePath,schemaPath:"#/oneOf/3/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err34]; +} +else { +vErrors.push(err34); +} +errors++; +} +} +var _valid0 = _errs30 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 3]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 3; +if(props0 !== true){ +props0 = true; +} +} +} +} +} +if(!valid0){ +const err35 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err35]; +} +else { +vErrors.push(err35); +} +errors++; +validate67.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate67.errors = vErrors; +evaluated0.props = props0; +return errors === 0; +} +validate67.evaluated = {"dynamicProps":true,"dynamicItems":false}; + + +function validate66(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate66.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((data.presets === undefined) || (!(func0.call(data, "presets")))) && (missing0 = "presets")){ +validate66.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(key0 === "presets")){ +validate66.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.presets !== undefined && func0.call(data, "presets")){ +let data0 = data.presets; +const _errs2 = errors; +if(errors === _errs2){ +if(Array.isArray(data0)){ +if(data0.length > 500){ +validate66.errors = [{instancePath:instancePath+"/presets",schemaPath:"#/properties/presets/maxItems",keyword:"maxItems",params:{limit: 500}}]; +return false; +} +else { +if(data0.length < 1){ +validate66.errors = [{instancePath:instancePath+"/presets",schemaPath:"#/properties/presets/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid1 = true; +const len0 = data0.length; +for(let i0=0; i0 100){ +validate80.errors = [{instancePath:instancePath+"/palettes",schemaPath:"#/properties/palettes/maxItems",keyword:"maxItems",params:{limit: 100}}]; +return false; +} +else { +if(data0.length < 1){ +validate80.errors = [{instancePath:instancePath+"/palettes",schemaPath:"#/properties/palettes/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid1 = true; +const len0 = data0.length; +for(let i0=0; i0 12){ +validate80.errors = [{instancePath:instancePath+"/palettes/" + i0+"/colors",schemaPath:"#/properties/palettes/items/properties/colors/maxItems",keyword:"maxItems",params:{limit: 12}}]; +return false; +} +else { +if(data4.length < 3){ +validate80.errors = [{instancePath:instancePath+"/palettes/" + i0+"/colors",schemaPath:"#/properties/palettes/items/properties/colors/minItems",keyword:"minItems",params:{limit: 3}}]; +return false; +} +else { +var valid3 = true; +const len1 = data4.length; +for(let i1=0; i1 96){ +validate85.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func69(data) < 1){ +validate85.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate85.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate85.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate85.errors = vErrors; +return errors === 0; +} +validate85.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const pattern6 = new RegExp("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "u"); + +function validate87(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate87.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func69(data) > 64){ +validate87.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(func69(data) < 5){ +validate87.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 5}}]; +return false; +} +else { +if(!pattern6.test(data)){ +validate87.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"}}]; +return false; +} +} +} +} +else { +validate87.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate87.errors = vErrors; +return errors === 0; +} +validate87.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate91(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate91.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(errors === _errs1){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((data.type === undefined) || (!(func0.call(data, "type")))) && (missing0 = "type")) || (((data.expression === undefined) || (!(func0.call(data, "expression")))) && (missing0 = "expression"))){ +const err0 = {instancePath,schemaPath:"#/oneOf/0/required",keyword:"required",params:{missingProperty: missing0}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +else { +const _errs3 = errors; +for(const key0 of Object.keys(data)){ +if(!((key0 === "type") || (key0 === "expression"))){ +const err1 = {instancePath,schemaPath:"#/oneOf/0/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +break; +} +} +if(_errs3 === errors){ +if(data.type !== undefined && func0.call(data, "type")){ +const _errs4 = errors; +if("spdx" !== data.type){ +const err2 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/0/properties/type/const",keyword:"const",params:{allowedValue: "spdx"}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +var valid1 = _errs4 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data.expression !== undefined && func0.call(data, "expression")){ +let data1 = data.expression; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data1 === "string"){ +if(func69(data1) > 240){ +const err3 = {instancePath:instancePath+"/expression",schemaPath:"#/oneOf/0/properties/expression/maxLength",keyword:"maxLength",params:{limit: 240}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +else { +if(func69(data1) < 1){ +const err4 = {instancePath:instancePath+"/expression",schemaPath:"#/oneOf/0/properties/expression/minLength",keyword:"minLength",params:{limit: 1}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +} +} +else { +const err5 = {instancePath:instancePath+"/expression",schemaPath:"#/oneOf/0/properties/expression/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +} +var valid1 = _errs5 === errors; +} +else { +var valid1 = true; +} +} +} +} +} +else { +const err6 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +var props0 = true; +} +const _errs7 = errors; +if(errors === _errs7){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing1; +if(((((data.type === undefined) || (!(func0.call(data, "type")))) && (missing1 = "type")) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing1 = "name"))) || (((data.text === undefined) || (!(func0.call(data, "text")))) && (missing1 = "text"))){ +const err7 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: missing1}}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +else { +const _errs9 = errors; +for(const key1 of Object.keys(data)){ +if(!(((key1 === "type") || (key1 === "name")) || (key1 === "text"))){ +const err8 = {instancePath,schemaPath:"#/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1}}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +break; +} +} +if(_errs9 === errors){ +if(data.type !== undefined && func0.call(data, "type")){ +const _errs10 = errors; +if("custom" !== data.type){ +const err9 = {instancePath:instancePath+"/type",schemaPath:"#/oneOf/1/properties/type/const",keyword:"const",params:{allowedValue: "custom"}}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +var valid2 = _errs10 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.name !== undefined && func0.call(data, "name")){ +const _errs11 = errors; +if(!(validate56(data.name, {instancePath:instancePath+"/name",parentData:data,parentDataProperty:"name",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +var valid2 = _errs11 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.text !== undefined && func0.call(data, "text")){ +let data4 = data.text; +const _errs12 = errors; +if(errors === _errs12){ +if(typeof data4 === "string"){ +if(func69(data4) > 10000){ +const err10 = {instancePath:instancePath+"/text",schemaPath:"#/oneOf/1/properties/text/maxLength",keyword:"maxLength",params:{limit: 10000}}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +else { +if(func69(data4) < 1){ +const err11 = {instancePath:instancePath+"/text",schemaPath:"#/oneOf/1/properties/text/minLength",keyword:"minLength",params:{limit: 1}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +} +} +} +else { +const err12 = {instancePath:instancePath+"/text",schemaPath:"#/oneOf/1/properties/text/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +} +var valid2 = _errs12 === errors; +} +else { +var valid2 = true; +} +} +} +} +} +} +else { +const err13 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +} +} +var _valid0 = _errs7 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +if(props0 !== true){ +props0 = true; +} +} +} +if(!valid0){ +const err14 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +validate91.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate91.errors = vErrors; +evaluated0.props = props0; +return errors === 0; +} +validate91.evaluated = {"dynamicProps":true,"dynamicItems":false}; + + +function validate52(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/pack/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate52.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs1 = errors; +const _errs2 = errors; +let valid1 = true; +const _errs3 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing0 = "kind")){ +const err0 = {}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +else { +if(data.kind !== undefined && func0.call(data, "kind")){ +if("oracle-deck" !== data.kind){ +const err1 = {}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +} +} +} +var _valid0 = _errs3 === errors; +errors = _errs2; +if(vErrors !== null){ +if(_errs2){ +vErrors.length = _errs2; +} +else { +vErrors = null; +} +} +if(_valid0){ +const _errs5 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.content !== undefined && func0.call(data, "content")){ +if(!(validate53(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate53.errors : vErrors.concat(validate53.errors); +errors = vErrors.length; +} +} +} +var _valid0 = _errs5 === errors; +valid1 = _valid0; +if(valid1){ +var props0 = {}; +props0.content = true; +props0.kind = true; +} +} +if(!valid1){ +const err2 = {instancePath,schemaPath:"#/allOf/0/if",keyword:"if",params:{failingKeyword: "then"}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +validate52.errors = vErrors; +return false; +} +var valid0 = _errs1 === errors; +if(valid0){ +const _errs7 = errors; +const _errs8 = errors; +let valid4 = true; +const _errs9 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing1; +if(((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing1 = "kind")){ +const err3 = {}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +else { +if(data.kind !== undefined && func0.call(data, "kind")){ +if("constraints" !== data.kind){ +const err4 = {}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +} +} +} +var _valid1 = _errs9 === errors; +errors = _errs8; +if(vErrors !== null){ +if(_errs8){ +vErrors.length = _errs8; +} +else { +vErrors = null; +} +} +if(_valid1){ +const _errs11 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.content !== undefined && func0.call(data, "content")){ +if(!(validate59(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate59.errors : vErrors.concat(validate59.errors); +errors = vErrors.length; +} +} +} +var _valid1 = _errs11 === errors; +valid4 = _valid1; +if(valid4){ +var props1 = {}; +props1.content = true; +props1.kind = true; +} +} +if(!valid4){ +const err5 = {instancePath,schemaPath:"#/allOf/1/if",keyword:"if",params:{failingKeyword: "then"}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +validate52.errors = vErrors; +return false; +} +var valid0 = _errs7 === errors; +if(valid0){ +if(props0 !== true && props1 !== undefined){ +if(props1 === true){ +props0 = true; +} +else { +props0 = props0 || {}; +Object.assign(props0, props1); +} +} +const _errs13 = errors; +const _errs14 = errors; +let valid7 = true; +const _errs15 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing2; +if(((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing2 = "kind")){ +const err6 = {}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +else { +if(data.kind !== undefined && func0.call(data, "kind")){ +if("diary-prompts" !== data.kind){ +const err7 = {}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +} +} +} +var _valid2 = _errs15 === errors; +errors = _errs14; +if(vErrors !== null){ +if(_errs14){ +vErrors.length = _errs14; +} +else { +vErrors = null; +} +} +if(_valid2){ +const _errs17 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.content !== undefined && func0.call(data, "content")){ +if(!(validate62(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate62.errors : vErrors.concat(validate62.errors); +errors = vErrors.length; +} +} +} +var _valid2 = _errs17 === errors; +valid7 = _valid2; +if(valid7){ +var props2 = {}; +props2.content = true; +props2.kind = true; +} +} +if(!valid7){ +const err8 = {instancePath,schemaPath:"#/allOf/2/if",keyword:"if",params:{failingKeyword: "then"}}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +validate52.errors = vErrors; +return false; +} +var valid0 = _errs13 === errors; +if(valid0){ +if(props0 !== true && props2 !== undefined){ +if(props2 === true){ +props0 = true; +} +else { +props0 = props0 || {}; +Object.assign(props0, props2); +} +} +const _errs19 = errors; +const _errs20 = errors; +let valid10 = true; +const _errs21 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing3; +if(((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing3 = "kind")){ +const err9 = {}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +else { +if(data.kind !== undefined && func0.call(data, "kind")){ +if("lottery-presets" !== data.kind){ +const err10 = {}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +} +} +var _valid3 = _errs21 === errors; +errors = _errs20; +if(vErrors !== null){ +if(_errs20){ +vErrors.length = _errs20; +} +else { +vErrors = null; +} +} +if(_valid3){ +const _errs23 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.content !== undefined && func0.call(data, "content")){ +if(!(validate66(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate66.errors : vErrors.concat(validate66.errors); +errors = vErrors.length; +} +} +} +var _valid3 = _errs23 === errors; +valid10 = _valid3; +if(valid10){ +var props3 = {}; +props3.content = true; +props3.kind = true; +} +} +if(!valid10){ +const err11 = {instancePath,schemaPath:"#/allOf/3/if",keyword:"if",params:{failingKeyword: "then"}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +validate52.errors = vErrors; +return false; +} +var valid0 = _errs19 === errors; +if(valid0){ +if(props0 !== true && props3 !== undefined){ +if(props3 === true){ +props0 = true; +} +else { +props0 = props0 || {}; +Object.assign(props0, props3); +} +} +const _errs25 = errors; +const _errs26 = errors; +let valid13 = true; +const _errs27 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing4; +if(((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing4 = "kind")){ +const err12 = {}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +else { +if(data.kind !== undefined && func0.call(data, "kind")){ +if("canvas-palettes" !== data.kind){ +const err13 = {}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +} +} +} +} +var _valid4 = _errs27 === errors; +errors = _errs26; +if(vErrors !== null){ +if(_errs26){ +vErrors.length = _errs26; +} +else { +vErrors = null; +} +} +if(_valid4){ +const _errs29 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +if(data.content !== undefined && func0.call(data, "content")){ +if(!(validate80(data.content, {instancePath:instancePath+"/content",parentData:data,parentDataProperty:"content",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate80.errors : vErrors.concat(validate80.errors); +errors = vErrors.length; +} +} +} +var _valid4 = _errs29 === errors; +valid13 = _valid4; +if(valid13){ +var props4 = {}; +props4.content = true; +props4.kind = true; +} +} +if(!valid13){ +const err14 = {instancePath,schemaPath:"#/allOf/4/if",keyword:"if",params:{failingKeyword: "then"}}; +if(vErrors === null){ +vErrors = [err14]; +} +else { +vErrors.push(err14); +} +errors++; +validate52.errors = vErrors; +return false; +} +var valid0 = _errs25 === errors; +if(valid0){ +if(props0 !== true && props4 !== undefined){ +if(props4 === true){ +props0 = true; +} +else { +props0 = props0 || {}; +Object.assign(props0, props4); +} +} +} +} +} +} +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing5; +if((((((((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing5 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing5 = "schema_version"))) || (((data.pack_id === undefined) || (!(func0.call(data, "pack_id")))) && (missing5 = "pack_id"))) || (((data.version === undefined) || (!(func0.call(data, "version")))) && (missing5 = "version"))) || (((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing5 = "kind"))) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing5 = "name"))) || (((data.author === undefined) || (!(func0.call(data, "author")))) && (missing5 = "author"))) || (((data.attribution === undefined) || (!(func0.call(data, "attribution")))) && (missing5 = "attribution"))) || (((data.license === undefined) || (!(func0.call(data, "license")))) && (missing5 = "license"))) || (((data.content === undefined) || (!(func0.call(data, "content")))) && (missing5 = "content"))){ +validate52.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing5}}]; +return false; +} +else { +const _errs31 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"schema":{"const":"sortilune.pack"},"schema_version":{"const":1},"pack_id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"version":{"$ref":"#/$defs/semver"},"kind":{"enum":["oracle-deck","constraints","diary-prompts","lottery-presets","canvas-palettes"]},"name":{"$ref":"#/$defs/name"},"description":{"type":"string","maxLength":1000},"author":{"type":"object","required":["name"],"properties":{"name":{"$ref":"#/$defs/name"}},"additionalProperties":false},"attribution":{"type":"string","minLength":1,"maxLength":1000},"license":{"$ref":"#/$defs/license"},"dependencies":{"type":"array","maxItems":32,"items":{"type":"object","required":["pack_id","version"],"properties":{"pack_id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"version":{"$ref":"#/$defs/semver"}},"additionalProperties":false}},"content":{}}, key0))){ +validate52.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs31 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs32 = errors; +if("sortilune.pack" !== data.schema){ +validate52.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.pack"}}]; +return false; +} +var valid16 = _errs32 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs33 = errors; +if(1 !== data.schema_version){ +validate52.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid16 = _errs33 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.pack_id !== undefined && func0.call(data, "pack_id")){ +const _errs34 = errors; +if(!(validate85(data.pack_id, {instancePath:instancePath+"/pack_id",parentData:data,parentDataProperty:"pack_id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate85.errors : vErrors.concat(validate85.errors); +errors = vErrors.length; +} +var valid16 = _errs34 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.version !== undefined && func0.call(data, "version")){ +const _errs35 = errors; +if(!(validate87(data.version, {instancePath:instancePath+"/version",parentData:data,parentDataProperty:"version",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate87.errors : vErrors.concat(validate87.errors); +errors = vErrors.length; +} +var valid16 = _errs35 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.kind !== undefined && func0.call(data, "kind")){ +let data14 = data.kind; +const _errs36 = errors; +if(!(((((data14 === "oracle-deck") || (data14 === "constraints")) || (data14 === "diary-prompts")) || (data14 === "lottery-presets")) || (data14 === "canvas-palettes"))){ +validate52.errors = [{instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: ["oracle-deck","constraints","diary-prompts","lottery-presets","canvas-palettes"]}}]; +return false; +} +var valid16 = _errs36 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.name !== undefined && func0.call(data, "name")){ +const _errs37 = errors; +if(!(validate56(data.name, {instancePath:instancePath+"/name",parentData:data,parentDataProperty:"name",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +var valid16 = _errs37 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.description !== undefined && func0.call(data, "description")){ +let data16 = data.description; +const _errs38 = errors; +if(errors === _errs38){ +if(typeof data16 === "string"){ +if(func69(data16) > 1000){ +validate52.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 1000}}]; +return false; +} +} +else { +validate52.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid16 = _errs38 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.author !== undefined && func0.call(data, "author")){ +let data17 = data.author; +const _errs40 = errors; +if(errors === _errs40){ +if(data17 && typeof data17 == "object" && !Array.isArray(data17)){ +let missing6; +if(((data17.name === undefined) || (!(func0.call(data17, "name")))) && (missing6 = "name")){ +validate52.errors = [{instancePath:instancePath+"/author",schemaPath:"#/properties/author/required",keyword:"required",params:{missingProperty: missing6}}]; +return false; +} +else { +const _errs42 = errors; +for(const key1 of Object.keys(data17)){ +if(!(key1 === "name")){ +validate52.errors = [{instancePath:instancePath+"/author",schemaPath:"#/properties/author/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1}}]; +return false; +break; +} +} +if(_errs42 === errors){ +if(data17.name !== undefined && func0.call(data17, "name")){ +if(!(validate56(data17.name, {instancePath:instancePath+"/author/name",parentData:data17,parentDataProperty:"name",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +} +} +} +} +else { +validate52.errors = [{instancePath:instancePath+"/author",schemaPath:"#/properties/author/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid16 = _errs40 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.attribution !== undefined && func0.call(data, "attribution")){ +let data19 = data.attribution; +const _errs44 = errors; +if(errors === _errs44){ +if(typeof data19 === "string"){ +if(func69(data19) > 1000){ +validate52.errors = [{instancePath:instancePath+"/attribution",schemaPath:"#/properties/attribution/maxLength",keyword:"maxLength",params:{limit: 1000}}]; +return false; +} +else { +if(func69(data19) < 1){ +validate52.errors = [{instancePath:instancePath+"/attribution",schemaPath:"#/properties/attribution/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate52.errors = [{instancePath:instancePath+"/attribution",schemaPath:"#/properties/attribution/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid16 = _errs44 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.license !== undefined && func0.call(data, "license")){ +const _errs46 = errors; +if(!(validate91(data.license, {instancePath:instancePath+"/license",parentData:data,parentDataProperty:"license",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate91.errors : vErrors.concat(validate91.errors); +errors = vErrors.length; +} +var valid16 = _errs46 === errors; +} +else { +var valid16 = true; +} +if(valid16){ +if(data.dependencies !== undefined && func0.call(data, "dependencies")){ +let data21 = data.dependencies; +const _errs47 = errors; +if(errors === _errs47){ +if(Array.isArray(data21)){ +if(data21.length > 32){ +validate52.errors = [{instancePath:instancePath+"/dependencies",schemaPath:"#/properties/dependencies/maxItems",keyword:"maxItems",params:{limit: 32}}]; +return false; +} +else { +var valid18 = true; +const len0 = data21.length; +for(let i0=0; i0; + message?: string; +} + +export interface StandaloneValidator { + (data: unknown): data is T; + errors: ValidationError[] | null; +} + +export const validateProvenance: StandaloneValidator; +export const validateRelation: StandaloneValidator; +export const validateAssetReference: StandaloneValidator; +export const validateArchiveRecord: StandaloneValidator; +export const validateArchiveAnnotationStore: StandaloneValidator; +export const validateDailyRecord: StandaloneValidator; +export const validateProject: StandaloneValidator; +export const validatePracticeStore: StandaloneValidator; +export const validateSymphonyScore: StandaloneValidator; +export const validateReceipt: StandaloneValidator; +export const validateSettingsV2: StandaloneValidator; diff --git a/src/schemas/generated/validators.generated.js b/src/schemas/generated/validators.generated.js new file mode 100644 index 0000000..4657032 --- /dev/null +++ b/src/schemas/generated/validators.generated.js @@ -0,0 +1,8839 @@ +// Generated by scripts/generate-validators.mjs. Do not edit by hand. +// Ajv compilation runs only at build time; runtime helpers perform no schema compilation. +import equalRuntime from "ajv/dist/runtime/equal.js"; +import ucs2LengthRuntime from "ajv/dist/runtime/ucs2length.js"; +import { isRfc3339Timestamp } from "../../domain/identifiers.js"; +function isAbsoluteUri(value) { + if (typeof value !== "string" || value.length > 2048 || /[\u0000-\u0020]/u.test(value)) return false; + try { return Boolean(new URL(value).protocol); } catch { return false; } +} +const formatDefinitions = { fullFormats: { "date-time": { validate: isRfc3339Timestamp }, uri: isAbsoluteUri } }; +"use strict"; +export const validateProvenance = validate52; +const func0 = Object.prototype.hasOwnProperty; +const func68 = ucs2LengthRuntime.default; +const pattern3 = new RegExp("^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", "u"); + +function validate54(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate54.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 96){ +validate54.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func68(data) < 1){ +validate54.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate54.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate54.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate54.errors = vErrors; +return errors === 0; +} +validate54.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const formats2 = formatDefinitions.fullFormats["date-time"]; + +function validate56(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate56.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 64){ +validate56.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(!(formats2.validate(data))){ +validate56.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "date-time"}}]; +return false; +} +} +} +else { +validate56.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate56.errors = vErrors; +return errors === 0; +} +validate56.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const wrapper0 = {validate: validate58}; + +function validate58(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate58.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(data !== null){ +const err0 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +} +const _errs3 = errors; +if(typeof data !== "boolean"){ +const err1 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "boolean"}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +var _valid0 = _errs3 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +} +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data === "string"){ +if(func68(data) > 1000000){ +const err2 = {instancePath,schemaPath:"#/oneOf/2/maxLength",keyword:"maxLength",params:{limit: 1000000}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +} +else { +const err3 = {instancePath,schemaPath:"#/oneOf/2/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +} +var _valid0 = _errs5 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 2]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 2; +} +const _errs7 = errors; +if(!((typeof data == "number") && (isFinite(data)))){ +const err4 = {instancePath,schemaPath:"#/oneOf/3/type",keyword:"type",params:{type: "number"}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +var _valid0 = _errs7 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 3]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 3; +} +const _errs9 = errors; +if(errors === _errs9){ +if(Array.isArray(data)){ +if(data.length > 10000){ +const err5 = {instancePath,schemaPath:"#/oneOf/4/maxItems",keyword:"maxItems",params:{limit: 10000}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +else { +var valid1 = true; +const len0 = data.length; +for(let i0=0; i0 1000){ +const err7 = {instancePath,schemaPath:"#/oneOf/5/maxProperties",keyword:"maxProperties",params:{limit: 1000}}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +else { +for(const key0 of Object.keys(data)){ +const _errs14 = errors; +if(typeof key0 === "string"){ +if(func68(key0) > 128){ +const err8 = {instancePath,schemaPath:"#/oneOf/5/propertyNames/maxLength",keyword:"maxLength",params:{limit: 128},propertyName:key0}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +var valid2 = _errs14 === errors; +if(!valid2){ +const err9 = {instancePath,schemaPath:"#/oneOf/5/propertyNames",keyword:"propertyNames",params:{propertyName: key0}}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +break; +} +} +if(valid2){ +for(const key1 of Object.keys(data)){ +const _errs16 = errors; +if(!(wrapper0.validate(data[key1], {instancePath:instancePath+"/" + key1.replace(/~/g, "~0").replace(/\//g, "~1"),parentData:data,parentDataProperty:key1,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? wrapper0.validate.errors : vErrors.concat(wrapper0.validate.errors); +errors = vErrors.length; +} +var valid3 = _errs16 === errors; +if(!valid3){ +break; +} +} +} +} +} +else { +const err10 = {instancePath,schemaPath:"#/oneOf/5/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +var _valid0 = _errs12 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 5]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 5; +var props2 = true; +} +} +} +} +} +} +if(!valid0){ +const err11 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +validate58.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate58.errors = vErrors; +evaluated0.props = props2; +evaluated0.items = items1; +return errors === 0; +} +validate58.evaluated = {"dynamicProps":true,"dynamicItems":true}; + +const formats0 = formatDefinitions.fullFormats.uri; + +function validate52(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/provenance/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate52.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((data.source === undefined) || (!(func0.call(data, "source")))) && (missing0 = "source")) || (((data.fetched_at === undefined) || (!(func0.call(data, "fetched_at")))) && (missing0 = "fetched_at"))) || (((data.raw === undefined) || (!(func0.call(data, "raw")))) && (missing0 = "raw"))){ +validate52.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(((((key0 === "source") || (key0 === "fetched_at")) || (key0 === "raw")) || (key0 === "signature")) || (key0 === "details"))){ +validate52.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.source !== undefined && func0.call(data, "source")){ +let data0 = data.source; +const _errs2 = errors; +if(errors === _errs2){ +if(data0 && typeof data0 == "object" && !Array.isArray(data0)){ +let missing1; +if(((((data0.id === undefined) || (!(func0.call(data0, "id")))) && (missing1 = "id")) || (((data0.label === undefined) || (!(func0.call(data0, "label")))) && (missing1 = "label"))) || (((data0.kind === undefined) || (!(func0.call(data0, "kind")))) && (missing1 = "kind"))){ +validate52.errors = [{instancePath:instancePath+"/source",schemaPath:"#/properties/source/required",keyword:"required",params:{missingProperty: missing1}}]; +return false; +} +else { +const _errs4 = errors; +for(const key1 of Object.keys(data0)){ +if(!((((key1 === "id") || (key1 === "label")) || (key1 === "kind")) || (key1 === "url"))){ +validate52.errors = [{instancePath:instancePath+"/source",schemaPath:"#/properties/source/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1}}]; +return false; +break; +} +} +if(_errs4 === errors){ +if(data0.id !== undefined && func0.call(data0, "id")){ +const _errs5 = errors; +if(!(validate54(data0.id, {instancePath:instancePath+"/source/id",parentData:data0,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate54.errors : vErrors.concat(validate54.errors); +errors = vErrors.length; +} +var valid1 = _errs5 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data0.label !== undefined && func0.call(data0, "label")){ +let data2 = data0.label; +const _errs6 = errors; +if(errors === _errs6){ +if(typeof data2 === "string"){ +if(func68(data2) > 200){ +validate52.errors = [{instancePath:instancePath+"/source/label",schemaPath:"#/properties/source/properties/label/maxLength",keyword:"maxLength",params:{limit: 200}}]; +return false; +} +else { +if(func68(data2) < 1){ +validate52.errors = [{instancePath:instancePath+"/source/label",schemaPath:"#/properties/source/properties/label/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate52.errors = [{instancePath:instancePath+"/source/label",schemaPath:"#/properties/source/properties/label/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid1 = _errs6 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data0.kind !== undefined && func0.call(data0, "kind")){ +let data3 = data0.kind; +const _errs8 = errors; +if(!((((((((data3 === "public-randomness") || (data3 === "quantum")) || (data3 === "atmospheric")) || (data3 === "seismic")) || (data3 === "weather")) || (data3 === "system")) || (data3 === "fixture")) || (data3 === "imported"))){ +validate52.errors = [{instancePath:instancePath+"/source/kind",schemaPath:"#/properties/source/properties/kind/enum",keyword:"enum",params:{allowedValues: ["public-randomness","quantum","atmospheric","seismic","weather","system","fixture","imported"]}}]; +return false; +} +var valid1 = _errs8 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data0.url !== undefined && func0.call(data0, "url")){ +let data4 = data0.url; +const _errs9 = errors; +if(errors === _errs9){ +if(errors === _errs9){ +if(typeof data4 === "string"){ +if(func68(data4) > 2048){ +validate52.errors = [{instancePath:instancePath+"/source/url",schemaPath:"#/properties/source/properties/url/maxLength",keyword:"maxLength",params:{limit: 2048}}]; +return false; +} +else { +if(!(formats0(data4))){ +validate52.errors = [{instancePath:instancePath+"/source/url",schemaPath:"#/properties/source/properties/url/format",keyword:"format",params:{format: "uri"}}]; +return false; +} +} +} +else { +validate52.errors = [{instancePath:instancePath+"/source/url",schemaPath:"#/properties/source/properties/url/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +var valid1 = _errs9 === errors; +} +else { +var valid1 = true; +} +} +} +} +} +} +} +else { +validate52.errors = [{instancePath:instancePath+"/source",schemaPath:"#/properties/source/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.fetched_at !== undefined && func0.call(data, "fetched_at")){ +const _errs11 = errors; +if(!(validate56(data.fetched_at, {instancePath:instancePath+"/fetched_at",parentData:data,parentDataProperty:"fetched_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate56.errors : vErrors.concat(validate56.errors); +errors = vErrors.length; +} +var valid0 = _errs11 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.raw !== undefined && func0.call(data, "raw")){ +let data6 = data.raw; +const _errs12 = errors; +if(errors === _errs12){ +if(typeof data6 === "string"){ +if(func68(data6) > 1000000){ +validate52.errors = [{instancePath:instancePath+"/raw",schemaPath:"#/properties/raw/maxLength",keyword:"maxLength",params:{limit: 1000000}}]; +return false; +} +} +else { +validate52.errors = [{instancePath:instancePath+"/raw",schemaPath:"#/properties/raw/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs12 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.signature !== undefined && func0.call(data, "signature")){ +let data7 = data.signature; +const _errs14 = errors; +if((typeof data7 !== "string") && (data7 !== null)){ +validate52.errors = [{instancePath:instancePath+"/signature",schemaPath:"#/properties/signature/type",keyword:"type",params:{type: ["string","null"]}}]; +return false; +} +if(errors === _errs14){ +if(typeof data7 === "string"){ +if(func68(data7) > 1000000){ +validate52.errors = [{instancePath:instancePath+"/signature",schemaPath:"#/properties/signature/maxLength",keyword:"maxLength",params:{limit: 1000000}}]; +return false; +} +} +} +var valid0 = _errs14 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.details !== undefined && func0.call(data, "details")){ +const _errs16 = errors; +if(!(validate58(data.details, {instancePath:instancePath+"/details",parentData:data,parentDataProperty:"details",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate58.errors : vErrors.concat(validate58.errors); +errors = vErrors.length; +} +var valid0 = _errs16 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +else { +validate52.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate52.errors = vErrors; +return errors === 0; +} +validate52.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +export const validateRelation = validate60; +const formats4 = /^(?:urn:uuid:)?[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i; + +function validate61(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate61.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(!(formats4.test(data))){ +validate61.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "uuid"}}]; +return false; +} +} +else { +validate61.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate61.errors = vErrors; +return errors === 0; +} +validate61.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate63(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate63.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 96){ +validate63.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func68(data) < 1){ +validate63.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate63.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate63.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate63.errors = vErrors; +return errors === 0; +} +validate63.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate65(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate65.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(data !== null){ +const err0 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +} +const _errs3 = errors; +if(typeof data !== "boolean"){ +const err1 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "boolean"}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +var _valid0 = _errs3 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +} +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data === "string"){ +if(func68(data) > 1000000){ +const err2 = {instancePath,schemaPath:"#/oneOf/2/maxLength",keyword:"maxLength",params:{limit: 1000000}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +} +else { +const err3 = {instancePath,schemaPath:"#/oneOf/2/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +} +var _valid0 = _errs5 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 2]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 2; +} +const _errs7 = errors; +if(!((typeof data == "number") && (isFinite(data)))){ +const err4 = {instancePath,schemaPath:"#/oneOf/3/type",keyword:"type",params:{type: "number"}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +var _valid0 = _errs7 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 3]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 3; +} +const _errs9 = errors; +if(errors === _errs9){ +if(Array.isArray(data)){ +if(data.length > 10000){ +const err5 = {instancePath,schemaPath:"#/oneOf/4/maxItems",keyword:"maxItems",params:{limit: 10000}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +else { +var valid1 = true; +const len0 = data.length; +for(let i0=0; i0 1000){ +const err7 = {instancePath,schemaPath:"#/oneOf/5/maxProperties",keyword:"maxProperties",params:{limit: 1000}}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +else { +for(const key0 of Object.keys(data)){ +const _errs14 = errors; +if(typeof key0 === "string"){ +if(func68(key0) > 128){ +const err8 = {instancePath,schemaPath:"#/oneOf/5/propertyNames/maxLength",keyword:"maxLength",params:{limit: 128},propertyName:key0}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +var valid2 = _errs14 === errors; +if(!valid2){ +const err9 = {instancePath,schemaPath:"#/oneOf/5/propertyNames",keyword:"propertyNames",params:{propertyName: key0}}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +break; +} +} +if(valid2){ +for(const key1 of Object.keys(data)){ +const _errs16 = errors; +if(!(validate58(data[key1], {instancePath:instancePath+"/" + key1.replace(/~/g, "~0").replace(/\//g, "~1"),parentData:data,parentDataProperty:key1,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate58.errors : vErrors.concat(validate58.errors); +errors = vErrors.length; +} +var valid3 = _errs16 === errors; +if(!valid3){ +break; +} +} +} +} +} +else { +const err10 = {instancePath,schemaPath:"#/oneOf/5/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +var _valid0 = _errs12 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 5]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 5; +var props2 = true; +} +} +} +} +} +} +if(!valid0){ +const err11 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +validate65.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate65.errors = vErrors; +evaluated0.props = props2; +evaluated0.items = items1; +return errors === 0; +} +validate65.evaluated = {"dynamicProps":true,"dynamicItems":true}; + + +function validate60(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/relation/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate60.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing0 = "kind")) || (((data.target_id === undefined) || (!(func0.call(data, "target_id")))) && (missing0 = "target_id"))){ +validate60.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((key0 === "kind") || (key0 === "target_id")) || (key0 === "target_schema")) || (key0 === "metadata"))){ +validate60.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.kind !== undefined && func0.call(data, "kind")){ +let data0 = data.kind; +const _errs2 = errors; +if(!((((((((data0 === "project") || (data0 === "practice")) || (data0 === "daily-record")) || (data0 === "rerolled-from")) || (data0 === "rerolled-to")) || (data0 === "source-record")) || (data0 === "derived-from")) || (data0 === "annotation-for"))){ +validate60.errors = [{instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: ["project","practice","daily-record","rerolled-from","rerolled-to","source-record","derived-from","annotation-for"]}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.target_id !== undefined && func0.call(data, "target_id")){ +const _errs3 = errors; +if(!(validate61(data.target_id, {instancePath:instancePath+"/target_id",parentData:data,parentDataProperty:"target_id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate61.errors : vErrors.concat(validate61.errors); +errors = vErrors.length; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.target_schema !== undefined && func0.call(data, "target_schema")){ +const _errs4 = errors; +if(!(validate63(data.target_schema, {instancePath:instancePath+"/target_schema",parentData:data,parentDataProperty:"target_schema",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate63.errors : vErrors.concat(validate63.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.metadata !== undefined && func0.call(data, "metadata")){ +const _errs5 = errors; +if(!(validate65(data.metadata, {instancePath:instancePath+"/metadata",parentData:data,parentDataProperty:"metadata",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate65.errors : vErrors.concat(validate65.errors); +errors = vErrors.length; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +else { +validate60.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate60.errors = vErrors; +return errors === 0; +} +validate60.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +export const validateAssetReference = validate69; + +function validate70(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate70.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(!(formats4.test(data))){ +validate70.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "uuid"}}]; +return false; +} +} +else { +validate70.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate70.errors = vErrors; +return errors === 0; +} +validate70.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate72(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate72.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 96){ +validate72.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func68(data) < 1){ +validate72.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate72.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate72.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate72.errors = vErrors; +return errors === 0; +} +validate72.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const pattern6 = new RegExp("^archive/(?!.*(?:^|/)\\.\\.?(?:/|$))[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$", "u"); + +function validate74(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate74.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 520){ +validate74.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 520}}]; +return false; +} +else { +if(!pattern6.test(data)){ +validate74.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^archive/(?!.*(?:^|/)\\.\\.?(?:/|$))[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$"}}]; +return false; +} +} +} +else { +validate74.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate74.errors = vErrors; +return errors === 0; +} +validate74.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const pattern7 = new RegExp("^[0-9a-f]{64}$", "u"); + +function validate76(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate76.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(!pattern7.test(data)){ +validate76.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"}}]; +return false; +} +} +else { +validate76.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate76.errors = vErrors; +return errors === 0; +} +validate76.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate69(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/asset-reference/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate69.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.role === undefined) || (!(func0.call(data, "role")))) && (missing0 = "role"))) || (((data.media_type === undefined) || (!(func0.call(data, "media_type")))) && (missing0 = "media_type"))) || (((data.path === undefined) || (!(func0.call(data, "path")))) && (missing0 = "path"))) || (((data.sha256 === undefined) || (!(func0.call(data, "sha256")))) && (missing0 = "sha256"))) || (((data.bytes === undefined) || (!(func0.call(data, "bytes")))) && (missing0 = "bytes"))){ +validate69.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((((((key0 === "id") || (key0 === "role")) || (key0 === "media_type")) || (key0 === "path")) || (key0 === "sha256")) || (key0 === "bytes")) || (key0 === "width")) || (key0 === "height"))){ +validate69.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate70(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate70.errors : vErrors.concat(validate70.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.role !== undefined && func0.call(data, "role")){ +const _errs3 = errors; +if(!(validate72(data.role, {instancePath:instancePath+"/role",parentData:data,parentDataProperty:"role",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate72.errors : vErrors.concat(validate72.errors); +errors = vErrors.length; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.media_type !== undefined && func0.call(data, "media_type")){ +let data2 = data.media_type; +const _errs4 = errors; +if(!(((((data2 === "image/svg+xml") || (data2 === "image/png")) || (data2 === "application/json")) || (data2 === "text/markdown")) || (data2 === "audio/wav"))){ +validate69.errors = [{instancePath:instancePath+"/media_type",schemaPath:"#/properties/media_type/enum",keyword:"enum",params:{allowedValues: ["image/svg+xml","image/png","application/json","text/markdown","audio/wav"]}}]; +return false; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.path !== undefined && func0.call(data, "path")){ +const _errs5 = errors; +if(!(validate74(data.path, {instancePath:instancePath+"/path",parentData:data,parentDataProperty:"path",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate74.errors : vErrors.concat(validate74.errors); +errors = vErrors.length; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.sha256 !== undefined && func0.call(data, "sha256")){ +const _errs6 = errors; +if(!(validate76(data.sha256, {instancePath:instancePath+"/sha256",parentData:data,parentDataProperty:"sha256",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate76.errors : vErrors.concat(validate76.errors); +errors = vErrors.length; +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.bytes !== undefined && func0.call(data, "bytes")){ +let data5 = data.bytes; +const _errs7 = errors; +if(!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))){ +validate69.errors = [{instancePath:instancePath+"/bytes",schemaPath:"#/properties/bytes/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs7){ +if((typeof data5 == "number") && (isFinite(data5))){ +if(data5 > 26214400 || isNaN(data5)){ +validate69.errors = [{instancePath:instancePath+"/bytes",schemaPath:"#/properties/bytes/maximum",keyword:"maximum",params:{comparison: "<=", limit: 26214400}}]; +return false; +} +else { +if(data5 < 0 || isNaN(data5)){ +validate69.errors = [{instancePath:instancePath+"/bytes",schemaPath:"#/properties/bytes/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.width !== undefined && func0.call(data, "width")){ +let data6 = data.width; +const _errs9 = errors; +if(!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))){ +validate69.errors = [{instancePath:instancePath+"/width",schemaPath:"#/properties/width/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs9){ +if((typeof data6 == "number") && (isFinite(data6))){ +if(data6 > 16384 || isNaN(data6)){ +validate69.errors = [{instancePath:instancePath+"/width",schemaPath:"#/properties/width/maximum",keyword:"maximum",params:{comparison: "<=", limit: 16384}}]; +return false; +} +else { +if(data6 < 1 || isNaN(data6)){ +validate69.errors = [{instancePath:instancePath+"/width",schemaPath:"#/properties/width/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}]; +return false; +} +} +} +} +var valid0 = _errs9 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.height !== undefined && func0.call(data, "height")){ +let data7 = data.height; +const _errs11 = errors; +if(!(((typeof data7 == "number") && (!(data7 % 1) && !isNaN(data7))) && (isFinite(data7)))){ +validate69.errors = [{instancePath:instancePath+"/height",schemaPath:"#/properties/height/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs11){ +if((typeof data7 == "number") && (isFinite(data7))){ +if(data7 > 16384 || isNaN(data7)){ +validate69.errors = [{instancePath:instancePath+"/height",schemaPath:"#/properties/height/maximum",keyword:"maximum",params:{comparison: "<=", limit: 16384}}]; +return false; +} +else { +if(data7 < 1 || isNaN(data7)){ +validate69.errors = [{instancePath:instancePath+"/height",schemaPath:"#/properties/height/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}]; +return false; +} +} +} +} +var valid0 = _errs11 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +} +else { +validate69.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate69.errors = vErrors; +return errors === 0; +} +validate69.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +export const validateArchiveRecord = validate78; + +function validate79(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate79.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(!(formats4.test(data))){ +validate79.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "uuid"}}]; +return false; +} +} +else { +validate79.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate79.errors = vErrors; +return errors === 0; +} +validate79.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate81(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate81.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 96){ +validate81.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func68(data) < 1){ +validate81.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate81.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate81.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate81.errors = vErrors; +return errors === 0; +} +validate81.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate84(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate84.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 64){ +validate84.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(!(formats2.validate(data))){ +validate84.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "date-time"}}]; +return false; +} +} +} +else { +validate84.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate84.errors = vErrors; +return errors === 0; +} +validate84.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate86(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate86.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(data !== null){ +const err0 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +} +const _errs3 = errors; +if(typeof data !== "boolean"){ +const err1 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "boolean"}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +var _valid0 = _errs3 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +} +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data === "string"){ +if(func68(data) > 1000000){ +const err2 = {instancePath,schemaPath:"#/oneOf/2/maxLength",keyword:"maxLength",params:{limit: 1000000}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +} +else { +const err3 = {instancePath,schemaPath:"#/oneOf/2/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +} +var _valid0 = _errs5 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 2]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 2; +} +const _errs7 = errors; +if(!((typeof data == "number") && (isFinite(data)))){ +const err4 = {instancePath,schemaPath:"#/oneOf/3/type",keyword:"type",params:{type: "number"}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +var _valid0 = _errs7 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 3]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 3; +} +const _errs9 = errors; +if(errors === _errs9){ +if(Array.isArray(data)){ +if(data.length > 10000){ +const err5 = {instancePath,schemaPath:"#/oneOf/4/maxItems",keyword:"maxItems",params:{limit: 10000}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +else { +var valid1 = true; +const len0 = data.length; +for(let i0=0; i0 1000){ +const err7 = {instancePath,schemaPath:"#/oneOf/5/maxProperties",keyword:"maxProperties",params:{limit: 1000}}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +else { +for(const key0 of Object.keys(data)){ +const _errs14 = errors; +if(typeof key0 === "string"){ +if(func68(key0) > 128){ +const err8 = {instancePath,schemaPath:"#/oneOf/5/propertyNames/maxLength",keyword:"maxLength",params:{limit: 128},propertyName:key0}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +var valid2 = _errs14 === errors; +if(!valid2){ +const err9 = {instancePath,schemaPath:"#/oneOf/5/propertyNames",keyword:"propertyNames",params:{propertyName: key0}}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +break; +} +} +if(valid2){ +for(const key1 of Object.keys(data)){ +const _errs16 = errors; +if(!(validate58(data[key1], {instancePath:instancePath+"/" + key1.replace(/~/g, "~0").replace(/\//g, "~1"),parentData:data,parentDataProperty:key1,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate58.errors : vErrors.concat(validate58.errors); +errors = vErrors.length; +} +var valid3 = _errs16 === errors; +if(!valid3){ +break; +} +} +} +} +} +else { +const err10 = {instancePath,schemaPath:"#/oneOf/5/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +var _valid0 = _errs12 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 5]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 5; +var props2 = true; +} +} +} +} +} +} +if(!valid0){ +const err11 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +validate86.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate86.errors = vErrors; +evaluated0.props = props2; +evaluated0.items = items1; +return errors === 0; +} +validate86.evaluated = {"dynamicProps":true,"dynamicItems":true}; + + +function validate94(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate94.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(!pattern7.test(data)){ +validate94.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"}}]; +return false; +} +} +else { +validate94.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate94.errors = vErrors; +return errors === 0; +} +validate94.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const pattern10 = new RegExp("^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", "u"); + +function validate78(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/archive-record/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate78.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing0 = "schema_version"))) || (((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id"))) || (((data.chamber === undefined) || (!(func0.call(data, "chamber")))) && (missing0 = "chamber"))) || (((data.type === undefined) || (!(func0.call(data, "type")))) && (missing0 = "type"))) || (((data.created_at === undefined) || (!(func0.call(data, "created_at")))) && (missing0 = "created_at"))) || (((data.summary === undefined) || (!(func0.call(data, "summary")))) && (missing0 = "summary"))) || (((data.payload === undefined) || (!(func0.call(data, "payload")))) && (missing0 = "payload"))) || (((data.provenance === undefined) || (!(func0.call(data, "provenance")))) && (missing0 = "provenance"))) || (((data.relations === undefined) || (!(func0.call(data, "relations")))) && (missing0 = "relations"))) || (((data.assets === undefined) || (!(func0.call(data, "assets")))) && (missing0 = "assets"))){ +validate78.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"schema":{"const":"sortilune.archive-record"},"schema_version":{"const":1},"id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/stableId"},"chamber":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"type":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"created_at":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp"},"summary":{"type":"string","minLength":1,"maxLength":2000},"payload":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/boundedJson"},"provenance":{"type":"array","maxItems":64,"items":{"$ref":"https://sortilune.app/schemas/provenance/v1"}},"relations":{"type":"array","maxItems":256,"items":{"$ref":"https://sortilune.app/schemas/relation/v1"}},"assets":{"type":"array","maxItems":256,"items":{"$ref":"https://sortilune.app/schemas/asset-reference/v1"}},"pack":{"oneOf":[{"type":"object","required":["id","version"],"properties":{"id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/stableId"},"version":{"type":"integer","minimum":1,"maximum":2147483647},"content_hash":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/sha256"}},"additionalProperties":false},{"type":"object","required":["id","version","digest","item_id","content_snapshot"],"properties":{"id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"version":{"type":"string","minLength":5,"maxLength":64,"pattern":"^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"},"digest":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/sha256"},"item_id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"content_snapshot":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/boundedJson"}},"additionalProperties":false}]},"algorithm":{"type":"object","required":["id","version"],"properties":{"id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"version":{"type":"integer","minimum":1,"maximum":2147483647},"parameters":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/boundedJson"}},"additionalProperties":false}}, key0))){ +validate78.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if("sortilune.archive-record" !== data.schema){ +validate78.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.archive-record"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs3 = errors; +if(1 !== data.schema_version){ +validate78.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs4 = errors; +if(!(validate79(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate79.errors : vErrors.concat(validate79.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.chamber !== undefined && func0.call(data, "chamber")){ +const _errs5 = errors; +if(!(validate81(data.chamber, {instancePath:instancePath+"/chamber",parentData:data,parentDataProperty:"chamber",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate81.errors : vErrors.concat(validate81.errors); +errors = vErrors.length; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.type !== undefined && func0.call(data, "type")){ +const _errs6 = errors; +if(!(validate81(data.type, {instancePath:instancePath+"/type",parentData:data,parentDataProperty:"type",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate81.errors : vErrors.concat(validate81.errors); +errors = vErrors.length; +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.created_at !== undefined && func0.call(data, "created_at")){ +const _errs7 = errors; +if(!(validate84(data.created_at, {instancePath:instancePath+"/created_at",parentData:data,parentDataProperty:"created_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate84.errors : vErrors.concat(validate84.errors); +errors = vErrors.length; +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.summary !== undefined && func0.call(data, "summary")){ +let data6 = data.summary; +const _errs8 = errors; +if(errors === _errs8){ +if(typeof data6 === "string"){ +if(func68(data6) > 2000){ +validate78.errors = [{instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/maxLength",keyword:"maxLength",params:{limit: 2000}}]; +return false; +} +else { +if(func68(data6) < 1){ +validate78.errors = [{instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate78.errors = [{instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.payload !== undefined && func0.call(data, "payload")){ +const _errs10 = errors; +if(!(validate86(data.payload, {instancePath:instancePath+"/payload",parentData:data,parentDataProperty:"payload",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate86.errors : vErrors.concat(validate86.errors); +errors = vErrors.length; +} +var valid0 = _errs10 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.provenance !== undefined && func0.call(data, "provenance")){ +let data8 = data.provenance; +const _errs11 = errors; +if(errors === _errs11){ +if(Array.isArray(data8)){ +if(data8.length > 64){ +validate78.errors = [{instancePath:instancePath+"/provenance",schemaPath:"#/properties/provenance/maxItems",keyword:"maxItems",params:{limit: 64}}]; +return false; +} +else { +var valid1 = true; +const len0 = data8.length; +for(let i0=0; i0 256){ +validate78.errors = [{instancePath:instancePath+"/relations",schemaPath:"#/properties/relations/maxItems",keyword:"maxItems",params:{limit: 256}}]; +return false; +} +else { +var valid2 = true; +const len1 = data10.length; +for(let i1=0; i1 256){ +validate78.errors = [{instancePath:instancePath+"/assets",schemaPath:"#/properties/assets/maxItems",keyword:"maxItems",params:{limit: 256}}]; +return false; +} +else { +var valid3 = true; +const len2 = data12.length; +for(let i2=0; i2 2147483647 || isNaN(data16)){ +const err3 = {instancePath:instancePath+"/pack/version",schemaPath:"#/properties/pack/oneOf/0/properties/version/maximum",keyword:"maximum",params:{comparison: "<=", limit: 2147483647}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +else { +if(data16 < 1 || isNaN(data16)){ +const err4 = {instancePath:instancePath+"/pack/version",schemaPath:"#/properties/pack/oneOf/0/properties/version/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +} +} +} +var valid5 = _errs26 === errors; +} +else { +var valid5 = true; +} +if(valid5){ +if(data14.content_hash !== undefined && func0.call(data14, "content_hash")){ +const _errs28 = errors; +if(!(validate94(data14.content_hash, {instancePath:instancePath+"/pack/content_hash",parentData:data14,parentDataProperty:"content_hash",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors); +errors = vErrors.length; +} +var valid5 = _errs28 === errors; +} +else { +var valid5 = true; +} +} +} +} +} +} +else { +const err5 = {instancePath:instancePath+"/pack",schemaPath:"#/properties/pack/oneOf/0/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +} +var _valid0 = _errs22 === errors; +if(_valid0){ +valid4 = true; +passing0 = 0; +var props1 = true; +} +const _errs29 = errors; +if(errors === _errs29){ +if(data14 && typeof data14 == "object" && !Array.isArray(data14)){ +let missing2; +if(((((((data14.id === undefined) || (!(func0.call(data14, "id")))) && (missing2 = "id")) || (((data14.version === undefined) || (!(func0.call(data14, "version")))) && (missing2 = "version"))) || (((data14.digest === undefined) || (!(func0.call(data14, "digest")))) && (missing2 = "digest"))) || (((data14.item_id === undefined) || (!(func0.call(data14, "item_id")))) && (missing2 = "item_id"))) || (((data14.content_snapshot === undefined) || (!(func0.call(data14, "content_snapshot")))) && (missing2 = "content_snapshot"))){ +const err6 = {instancePath:instancePath+"/pack",schemaPath:"#/properties/pack/oneOf/1/required",keyword:"required",params:{missingProperty: missing2}}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +} +else { +const _errs31 = errors; +for(const key2 of Object.keys(data14)){ +if(!(((((key2 === "id") || (key2 === "version")) || (key2 === "digest")) || (key2 === "item_id")) || (key2 === "content_snapshot"))){ +const err7 = {instancePath:instancePath+"/pack",schemaPath:"#/properties/pack/oneOf/1/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2}}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +break; +} +} +if(_errs31 === errors){ +if(data14.id !== undefined && func0.call(data14, "id")){ +const _errs32 = errors; +if(!(validate81(data14.id, {instancePath:instancePath+"/pack/id",parentData:data14,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate81.errors : vErrors.concat(validate81.errors); +errors = vErrors.length; +} +var valid6 = _errs32 === errors; +} +else { +var valid6 = true; +} +if(valid6){ +if(data14.version !== undefined && func0.call(data14, "version")){ +let data19 = data14.version; +const _errs33 = errors; +if(errors === _errs33){ +if(typeof data19 === "string"){ +if(func68(data19) > 64){ +const err8 = {instancePath:instancePath+"/pack/version",schemaPath:"#/properties/pack/oneOf/1/properties/version/maxLength",keyword:"maxLength",params:{limit: 64}}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +else { +if(func68(data19) < 5){ +const err9 = {instancePath:instancePath+"/pack/version",schemaPath:"#/properties/pack/oneOf/1/properties/version/minLength",keyword:"minLength",params:{limit: 5}}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +} +else { +if(!pattern10.test(data19)){ +const err10 = {instancePath:instancePath+"/pack/version",schemaPath:"#/properties/pack/oneOf/1/properties/version/pattern",keyword:"pattern",params:{pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$"}}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +} +} +else { +const err11 = {instancePath:instancePath+"/pack/version",schemaPath:"#/properties/pack/oneOf/1/properties/version/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +} +} +var valid6 = _errs33 === errors; +} +else { +var valid6 = true; +} +if(valid6){ +if(data14.digest !== undefined && func0.call(data14, "digest")){ +const _errs35 = errors; +if(!(validate94(data14.digest, {instancePath:instancePath+"/pack/digest",parentData:data14,parentDataProperty:"digest",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate94.errors : vErrors.concat(validate94.errors); +errors = vErrors.length; +} +var valid6 = _errs35 === errors; +} +else { +var valid6 = true; +} +if(valid6){ +if(data14.item_id !== undefined && func0.call(data14, "item_id")){ +const _errs36 = errors; +if(!(validate81(data14.item_id, {instancePath:instancePath+"/pack/item_id",parentData:data14,parentDataProperty:"item_id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate81.errors : vErrors.concat(validate81.errors); +errors = vErrors.length; +} +var valid6 = _errs36 === errors; +} +else { +var valid6 = true; +} +if(valid6){ +if(data14.content_snapshot !== undefined && func0.call(data14, "content_snapshot")){ +const _errs37 = errors; +if(!(validate86(data14.content_snapshot, {instancePath:instancePath+"/pack/content_snapshot",parentData:data14,parentDataProperty:"content_snapshot",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate86.errors : vErrors.concat(validate86.errors); +errors = vErrors.length; +} +var valid6 = _errs37 === errors; +} +else { +var valid6 = true; +} +} +} +} +} +} +} +} +else { +const err12 = {instancePath:instancePath+"/pack",schemaPath:"#/properties/pack/oneOf/1/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err12]; +} +else { +vErrors.push(err12); +} +errors++; +} +} +var _valid0 = _errs29 === errors; +if(_valid0 && valid4){ +valid4 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid4 = true; +passing0 = 1; +if(props1 !== true){ +props1 = true; +} +} +} +if(!valid4){ +const err13 = {instancePath:instancePath+"/pack",schemaPath:"#/properties/pack/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err13]; +} +else { +vErrors.push(err13); +} +errors++; +validate78.errors = vErrors; +return false; +} +else { +errors = _errs21; +if(vErrors !== null){ +if(_errs21){ +vErrors.length = _errs21; +} +else { +vErrors = null; +} +} +} +var valid0 = _errs20 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.algorithm !== undefined && func0.call(data, "algorithm")){ +let data23 = data.algorithm; +const _errs38 = errors; +if(errors === _errs38){ +if(data23 && typeof data23 == "object" && !Array.isArray(data23)){ +let missing3; +if((((data23.id === undefined) || (!(func0.call(data23, "id")))) && (missing3 = "id")) || (((data23.version === undefined) || (!(func0.call(data23, "version")))) && (missing3 = "version"))){ +validate78.errors = [{instancePath:instancePath+"/algorithm",schemaPath:"#/properties/algorithm/required",keyword:"required",params:{missingProperty: missing3}}]; +return false; +} +else { +const _errs40 = errors; +for(const key3 of Object.keys(data23)){ +if(!(((key3 === "id") || (key3 === "version")) || (key3 === "parameters"))){ +validate78.errors = [{instancePath:instancePath+"/algorithm",schemaPath:"#/properties/algorithm/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key3}}]; +return false; +break; +} +} +if(_errs40 === errors){ +if(data23.id !== undefined && func0.call(data23, "id")){ +const _errs41 = errors; +if(!(validate81(data23.id, {instancePath:instancePath+"/algorithm/id",parentData:data23,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate81.errors : vErrors.concat(validate81.errors); +errors = vErrors.length; +} +var valid7 = _errs41 === errors; +} +else { +var valid7 = true; +} +if(valid7){ +if(data23.version !== undefined && func0.call(data23, "version")){ +let data25 = data23.version; +const _errs42 = errors; +if(!(((typeof data25 == "number") && (!(data25 % 1) && !isNaN(data25))) && (isFinite(data25)))){ +validate78.errors = [{instancePath:instancePath+"/algorithm/version",schemaPath:"#/properties/algorithm/properties/version/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs42){ +if((typeof data25 == "number") && (isFinite(data25))){ +if(data25 > 2147483647 || isNaN(data25)){ +validate78.errors = [{instancePath:instancePath+"/algorithm/version",schemaPath:"#/properties/algorithm/properties/version/maximum",keyword:"maximum",params:{comparison: "<=", limit: 2147483647}}]; +return false; +} +else { +if(data25 < 1 || isNaN(data25)){ +validate78.errors = [{instancePath:instancePath+"/algorithm/version",schemaPath:"#/properties/algorithm/properties/version/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}]; +return false; +} +} +} +} +var valid7 = _errs42 === errors; +} +else { +var valid7 = true; +} +if(valid7){ +if(data23.parameters !== undefined && func0.call(data23, "parameters")){ +const _errs44 = errors; +if(!(validate86(data23.parameters, {instancePath:instancePath+"/algorithm/parameters",parentData:data23,parentDataProperty:"parameters",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate86.errors : vErrors.concat(validate86.errors); +errors = vErrors.length; +} +var valid7 = _errs44 === errors; +} +else { +var valid7 = true; +} +} +} +} +} +} +else { +validate78.errors = [{instancePath:instancePath+"/algorithm",schemaPath:"#/properties/algorithm/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs38 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +} +} +} +} +} +} +else { +validate78.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate78.errors = vErrors; +return errors === 0; +} +validate78.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +export const validateArchiveAnnotationStore = validate102; + +function validate103(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate103.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 64){ +validate103.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(!(formats2.validate(data))){ +validate103.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "date-time"}}]; +return false; +} +} +} +else { +validate103.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate103.errors = vErrors; +return errors === 0; +} +validate103.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate105(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate105.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(!(formats4.test(data))){ +validate105.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "uuid"}}]; +return false; +} +} +else { +validate105.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate105.errors = vErrors; +return errors === 0; +} +validate105.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const func27 = equalRuntime.default; + +function validate107(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate107.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((data.tags === undefined) || (!(func0.call(data, "tags")))) && (missing0 = "tags")) || (((data.favorite === undefined) || (!(func0.call(data, "favorite")))) && (missing0 = "favorite"))) || (((data.hidden === undefined) || (!(func0.call(data, "hidden")))) && (missing0 = "hidden"))) || (((data.collections === undefined) || (!(func0.call(data, "collections")))) && (missing0 = "collections"))) || (((data.updated_at === undefined) || (!(func0.call(data, "updated_at")))) && (missing0 = "updated_at"))){ +validate107.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((((key0 === "title") || (key0 === "tags")) || (key0 === "favorite")) || (key0 === "hidden")) || (key0 === "collections")) || (key0 === "updated_at"))){ +validate107.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.title !== undefined && func0.call(data, "title")){ +let data0 = data.title; +const _errs2 = errors; +if(errors === _errs2){ +if(typeof data0 === "string"){ +if(func68(data0) > 240){ +validate107.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/maxLength",keyword:"maxLength",params:{limit: 240}}]; +return false; +} +else { +if(func68(data0) < 1){ +validate107.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate107.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.tags !== undefined && func0.call(data, "tags")){ +let data1 = data.tags; +const _errs4 = errors; +if(errors === _errs4){ +if(Array.isArray(data1)){ +if(data1.length > 32){ +validate107.errors = [{instancePath:instancePath+"/tags",schemaPath:"#/properties/tags/maxItems",keyword:"maxItems",params:{limit: 32}}]; +return false; +} +else { +var valid1 = true; +const len0 = data1.length; +for(let i0=0; i0 64){ +validate107.errors = [{instancePath:instancePath+"/tags/" + i0,schemaPath:"#/properties/tags/items/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(func68(data2) < 1){ +validate107.errors = [{instancePath:instancePath+"/tags/" + i0,schemaPath:"#/properties/tags/items/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate107.errors = [{instancePath:instancePath+"/tags/" + i0,schemaPath:"#/properties/tags/items/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid1 = _errs6 === errors; +if(!valid1){ +break; +} +} +if(valid1){ +let i1 = data1.length; +let j0; +if(i1 > 1){ +const indices0 = {}; +for(;i1--;){ +let item0 = data1[i1]; +if(typeof item0 !== "string"){ +continue; +} +if(typeof indices0[item0] == "number"){ +j0 = indices0[item0]; +validate107.errors = [{instancePath:instancePath+"/tags",schemaPath:"#/properties/tags/uniqueItems",keyword:"uniqueItems",params:{i: i1, j: j0}}]; +return false; +break; +} +indices0[item0] = i1; +} +} +} +} +} +else { +validate107.errors = [{instancePath:instancePath+"/tags",schemaPath:"#/properties/tags/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.favorite !== undefined && func0.call(data, "favorite")){ +const _errs8 = errors; +if(typeof data.favorite !== "boolean"){ +validate107.errors = [{instancePath:instancePath+"/favorite",schemaPath:"#/properties/favorite/type",keyword:"type",params:{type: "boolean"}}]; +return false; +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.hidden !== undefined && func0.call(data, "hidden")){ +const _errs10 = errors; +if(typeof data.hidden !== "boolean"){ +validate107.errors = [{instancePath:instancePath+"/hidden",schemaPath:"#/properties/hidden/type",keyword:"type",params:{type: "boolean"}}]; +return false; +} +var valid0 = _errs10 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.collections !== undefined && func0.call(data, "collections")){ +let data5 = data.collections; +const _errs12 = errors; +if(errors === _errs12){ +if(Array.isArray(data5)){ +if(data5.length > 32){ +validate107.errors = [{instancePath:instancePath+"/collections",schemaPath:"#/properties/collections/maxItems",keyword:"maxItems",params:{limit: 32}}]; +return false; +} +else { +var valid3 = true; +const len1 = data5.length; +for(let i2=0; i2 1){ +outer0: +for(;i3--;){ +for(j1 = i3; j1--;){ +if(func27(data5[i3], data5[j1])){ +validate107.errors = [{instancePath:instancePath+"/collections",schemaPath:"#/properties/collections/uniqueItems",keyword:"uniqueItems",params:{i: i3, j: j1}}]; +return false; +break outer0; +} +} +} +} +} +} +} +else { +validate107.errors = [{instancePath:instancePath+"/collections",schemaPath:"#/properties/collections/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +var valid0 = _errs12 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.updated_at !== undefined && func0.call(data, "updated_at")){ +const _errs15 = errors; +if(!(validate103(data.updated_at, {instancePath:instancePath+"/updated_at",parentData:data,parentDataProperty:"updated_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate103.errors : vErrors.concat(validate103.errors); +errors = vErrors.length; +} +var valid0 = _errs15 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +else { +validate107.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate107.errors = vErrors; +return errors === 0; +} +validate107.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate112(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate112.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing0 = "name"))) || (((data.created_at === undefined) || (!(func0.call(data, "created_at")))) && (missing0 = "created_at"))) || (((data.updated_at === undefined) || (!(func0.call(data, "updated_at")))) && (missing0 = "updated_at"))){ +validate112.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((key0 === "id") || (key0 === "name")) || (key0 === "created_at")) || (key0 === "updated_at"))){ +validate112.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate105(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate105.errors : vErrors.concat(validate105.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.name !== undefined && func0.call(data, "name")){ +let data1 = data.name; +const _errs3 = errors; +if(errors === _errs3){ +if(typeof data1 === "string"){ +if(func68(data1) > 120){ +validate112.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/maxLength",keyword:"maxLength",params:{limit: 120}}]; +return false; +} +else { +if(func68(data1) < 1){ +validate112.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate112.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.created_at !== undefined && func0.call(data, "created_at")){ +const _errs5 = errors; +if(!(validate103(data.created_at, {instancePath:instancePath+"/created_at",parentData:data,parentDataProperty:"created_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate103.errors : vErrors.concat(validate103.errors); +errors = vErrors.length; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.updated_at !== undefined && func0.call(data, "updated_at")){ +const _errs6 = errors; +if(!(validate103(data.updated_at, {instancePath:instancePath+"/updated_at",parentData:data,parentDataProperty:"updated_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate103.errors : vErrors.concat(validate103.errors); +errors = vErrors.length; +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +else { +validate112.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate112.errors = vErrors; +return errors === 0; +} +validate112.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate102(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/archive-annotations/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate102.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing0 = "schema_version"))) || (((data.updated_at === undefined) || (!(func0.call(data, "updated_at")))) && (missing0 = "updated_at"))) || (((data.records === undefined) || (!(func0.call(data, "records")))) && (missing0 = "records"))) || (((data.collections === undefined) || (!(func0.call(data, "collections")))) && (missing0 = "collections"))){ +validate102.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(((((key0 === "schema") || (key0 === "schema_version")) || (key0 === "updated_at")) || (key0 === "records")) || (key0 === "collections"))){ +validate102.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if("sortilune.archive-annotations" !== data.schema){ +validate102.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.archive-annotations"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs3 = errors; +if(1 !== data.schema_version){ +validate102.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.updated_at !== undefined && func0.call(data, "updated_at")){ +const _errs4 = errors; +if(!(validate103(data.updated_at, {instancePath:instancePath+"/updated_at",parentData:data,parentDataProperty:"updated_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate103.errors : vErrors.concat(validate103.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.records !== undefined && func0.call(data, "records")){ +let data3 = data.records; +const _errs5 = errors; +if(errors === _errs5){ +if(data3 && typeof data3 == "object" && !Array.isArray(data3)){ +if(Object.keys(data3).length > 25000){ +validate102.errors = [{instancePath:instancePath+"/records",schemaPath:"#/properties/records/maxProperties",keyword:"maxProperties",params:{limit: 25000}}]; +return false; +} +else { +for(const key1 of Object.keys(data3)){ +const _errs7 = errors; +if(!(validate105(key1, {instancePath:instancePath+"/records",parentData:data3,parentDataProperty:"records",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate105.errors : vErrors.concat(validate105.errors); +errors = vErrors.length; +} +var valid1 = _errs7 === errors; +if(!valid1){ +const err0 = {instancePath:instancePath+"/records",schemaPath:"#/properties/records/propertyNames",keyword:"propertyNames",params:{propertyName: key1}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +validate102.errors = vErrors; +return false; +break; +} +} +if(valid1){ +for(const key2 of Object.keys(data3)){ +const _errs9 = errors; +if(!(validate107(data3[key2], {instancePath:instancePath+"/records/" + key2.replace(/~/g, "~0").replace(/\//g, "~1"),parentData:data3,parentDataProperty:key2,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate107.errors : vErrors.concat(validate107.errors); +errors = vErrors.length; +} +var valid2 = _errs9 === errors; +if(!valid2){ +break; +} +} +} +} +} +else { +validate102.errors = [{instancePath:instancePath+"/records",schemaPath:"#/properties/records/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.collections !== undefined && func0.call(data, "collections")){ +let data5 = data.collections; +const _errs10 = errors; +if(errors === _errs10){ +if(data5 && typeof data5 == "object" && !Array.isArray(data5)){ +if(Object.keys(data5).length > 1000){ +validate102.errors = [{instancePath:instancePath+"/collections",schemaPath:"#/properties/collections/maxProperties",keyword:"maxProperties",params:{limit: 1000}}]; +return false; +} +else { +for(const key3 of Object.keys(data5)){ +const _errs12 = errors; +if(!(validate105(key3, {instancePath:instancePath+"/collections",parentData:data5,parentDataProperty:"collections",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate105.errors : vErrors.concat(validate105.errors); +errors = vErrors.length; +} +var valid3 = _errs12 === errors; +if(!valid3){ +const err1 = {instancePath:instancePath+"/collections",schemaPath:"#/properties/collections/propertyNames",keyword:"propertyNames",params:{propertyName: key3}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +validate102.errors = vErrors; +return false; +break; +} +} +if(valid3){ +for(const key4 of Object.keys(data5)){ +const _errs14 = errors; +if(!(validate112(data5[key4], {instancePath:instancePath+"/collections/" + key4.replace(/~/g, "~0").replace(/\//g, "~1"),parentData:data5,parentDataProperty:key4,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate112.errors : vErrors.concat(validate112.errors); +errors = vErrors.length; +} +var valid4 = _errs14 === errors; +if(!valid4){ +break; +} +} +} +} +} +else { +validate102.errors = [{instancePath:instancePath+"/collections",schemaPath:"#/properties/collections/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs10 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +else { +validate102.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate102.errors = vErrors; +return errors === 0; +} +validate102.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +export const validateDailyRecord = validate117; + +function validate118(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate118.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(!(formats4.test(data))){ +validate118.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "uuid"}}]; +return false; +} +} +else { +validate118.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate118.errors = vErrors; +return errors === 0; +} +validate118.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate120(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate120.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 64){ +validate120.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(!(formats2.validate(data))){ +validate120.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "date-time"}}]; +return false; +} +} +} +else { +validate120.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate120.errors = vErrors; +return errors === 0; +} +validate120.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const pattern12 = new RegExp("^[0-9a-f]{128}$", "u"); + +function validate123(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate123.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(!pattern12.test(data)){ +validate123.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{128}$"}}]; +return false; +} +} +else { +validate123.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate123.errors = vErrors; +return errors === 0; +} +validate123.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate128(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate128.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(data !== null){ +const err0 = {instancePath,schemaPath:"#/oneOf/0/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +} +const _errs3 = errors; +if(typeof data !== "boolean"){ +const err1 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "boolean"}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +var _valid0 = _errs3 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +} +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data === "string"){ +if(func68(data) > 1000000){ +const err2 = {instancePath,schemaPath:"#/oneOf/2/maxLength",keyword:"maxLength",params:{limit: 1000000}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +} +else { +const err3 = {instancePath,schemaPath:"#/oneOf/2/type",keyword:"type",params:{type: "string"}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +} +var _valid0 = _errs5 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 2]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 2; +} +const _errs7 = errors; +if(!((typeof data == "number") && (isFinite(data)))){ +const err4 = {instancePath,schemaPath:"#/oneOf/3/type",keyword:"type",params:{type: "number"}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +var _valid0 = _errs7 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 3]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 3; +} +const _errs9 = errors; +if(errors === _errs9){ +if(Array.isArray(data)){ +if(data.length > 10000){ +const err5 = {instancePath,schemaPath:"#/oneOf/4/maxItems",keyword:"maxItems",params:{limit: 10000}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +else { +var valid1 = true; +const len0 = data.length; +for(let i0=0; i0 1000){ +const err7 = {instancePath,schemaPath:"#/oneOf/5/maxProperties",keyword:"maxProperties",params:{limit: 1000}}; +if(vErrors === null){ +vErrors = [err7]; +} +else { +vErrors.push(err7); +} +errors++; +} +else { +for(const key0 of Object.keys(data)){ +const _errs14 = errors; +if(typeof key0 === "string"){ +if(func68(key0) > 128){ +const err8 = {instancePath,schemaPath:"#/oneOf/5/propertyNames/maxLength",keyword:"maxLength",params:{limit: 128},propertyName:key0}; +if(vErrors === null){ +vErrors = [err8]; +} +else { +vErrors.push(err8); +} +errors++; +} +} +var valid2 = _errs14 === errors; +if(!valid2){ +const err9 = {instancePath,schemaPath:"#/oneOf/5/propertyNames",keyword:"propertyNames",params:{propertyName: key0}}; +if(vErrors === null){ +vErrors = [err9]; +} +else { +vErrors.push(err9); +} +errors++; +break; +} +} +if(valid2){ +for(const key1 of Object.keys(data)){ +const _errs16 = errors; +if(!(validate58(data[key1], {instancePath:instancePath+"/" + key1.replace(/~/g, "~0").replace(/\//g, "~1"),parentData:data,parentDataProperty:key1,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate58.errors : vErrors.concat(validate58.errors); +errors = vErrors.length; +} +var valid3 = _errs16 === errors; +if(!valid3){ +break; +} +} +} +} +} +else { +const err10 = {instancePath,schemaPath:"#/oneOf/5/type",keyword:"type",params:{type: "object"}}; +if(vErrors === null){ +vErrors = [err10]; +} +else { +vErrors.push(err10); +} +errors++; +} +} +var _valid0 = _errs12 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 5]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 5; +var props2 = true; +} +} +} +} +} +} +if(!valid0){ +const err11 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err11]; +} +else { +vErrors.push(err11); +} +errors++; +validate128.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate128.errors = vErrors; +evaluated0.props = props2; +evaluated0.items = items1; +return errors === 0; +} +validate128.evaluated = {"dynamicProps":true,"dynamicItems":true}; + + +function validate122(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate122.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs1 = errors; +let valid0 = false; +let passing0 = null; +const _errs2 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((data.nist === undefined) || (!(func0.call(data, "nist")))) && (missing0 = "nist")){ +const err0 = {instancePath,schemaPath:"#/oneOf/0/required",keyword:"required",params:{missingProperty: missing0}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +else { +if(data.kind !== undefined && func0.call(data, "kind")){ +const _errs3 = errors; +if("nist" !== data.kind){ +const err1 = {instancePath:instancePath+"/kind",schemaPath:"#/oneOf/0/properties/kind/const",keyword:"const",params:{allowedValue: "nist"}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +} +var valid1 = _errs3 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data.local !== undefined && func0.call(data, "local")){ +var valid1 = false; +const err2 = {instancePath:instancePath+"/local",schemaPath:"#/oneOf/0/properties/local/false schema",keyword:"false schema",params:{}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +} +else { +var valid1 = true; +} +} +} +} +var _valid0 = _errs2 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +var props0 = {}; +props0.kind = true; +props0.nist = true; +props0.local = true; +} +const _errs4 = errors; +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing1; +if(((data.local === undefined) || (!(func0.call(data, "local")))) && (missing1 = "local")){ +const err3 = {instancePath,schemaPath:"#/oneOf/1/required",keyword:"required",params:{missingProperty: missing1}}; +if(vErrors === null){ +vErrors = [err3]; +} +else { +vErrors.push(err3); +} +errors++; +} +else { +if(data.kind !== undefined && func0.call(data, "kind")){ +const _errs5 = errors; +if("local" !== data.kind){ +const err4 = {instancePath:instancePath+"/kind",schemaPath:"#/oneOf/1/properties/kind/const",keyword:"const",params:{allowedValue: "local"}}; +if(vErrors === null){ +vErrors = [err4]; +} +else { +vErrors.push(err4); +} +errors++; +} +var valid2 = _errs5 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data.nist !== undefined && func0.call(data, "nist")){ +var valid2 = false; +const err5 = {instancePath:instancePath+"/nist",schemaPath:"#/oneOf/1/properties/nist/false schema",keyword:"false schema",params:{}}; +if(vErrors === null){ +vErrors = [err5]; +} +else { +vErrors.push(err5); +} +errors++; +} +else { +var valid2 = true; +} +} +} +} +var _valid0 = _errs4 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +if(props0 !== true){ +props0 = props0 || {}; +props0.kind = true; +props0.local = true; +props0.nist = true; +} +} +} +if(!valid0){ +const err6 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err6]; +} +else { +vErrors.push(err6); +} +errors++; +validate122.errors = vErrors; +return false; +} +else { +errors = _errs1; +if(vErrors !== null){ +if(_errs1){ +vErrors.length = _errs1; +} +else { +vErrors = null; +} +} +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing2; +if(((((((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing2 = "kind")) || (((data.root_value === undefined) || (!(func0.call(data, "root_value")))) && (missing2 = "root_value"))) || (((data.public === undefined) || (!(func0.call(data, "public")))) && (missing2 = "public"))) || (((data.day_start === undefined) || (!(func0.call(data, "day_start")))) && (missing2 = "day_start"))) || (((data.day_end === undefined) || (!(func0.call(data, "day_end")))) && (missing2 = "day_end"))){ +validate122.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing2}}]; +return false; +} +else { +const _errs6 = errors; +for(const key0 of Object.keys(data)){ +if(!(((((((key0 === "kind") || (key0 === "root_value")) || (key0 === "public")) || (key0 === "day_start")) || (key0 === "day_end")) || (key0 === "nist")) || (key0 === "local"))){ +validate122.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs6 === errors){ +if(data.kind !== undefined && func0.call(data, "kind")){ +let data4 = data.kind; +const _errs7 = errors; +if(!((data4 === "nist") || (data4 === "local"))){ +validate122.errors = [{instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: ["nist","local"]}}]; +return false; +} +var valid3 = _errs7 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.root_value !== undefined && func0.call(data, "root_value")){ +const _errs8 = errors; +if(!(validate123(data.root_value, {instancePath:instancePath+"/root_value",parentData:data,parentDataProperty:"root_value",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate123.errors : vErrors.concat(validate123.errors); +errors = vErrors.length; +} +var valid3 = _errs8 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.public !== undefined && func0.call(data, "public")){ +const _errs9 = errors; +if(true !== data.public){ +validate122.errors = [{instancePath:instancePath+"/public",schemaPath:"#/properties/public/const",keyword:"const",params:{allowedValue: true}}]; +return false; +} +var valid3 = _errs9 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.day_start !== undefined && func0.call(data, "day_start")){ +const _errs10 = errors; +if(!(validate120(data.day_start, {instancePath:instancePath+"/day_start",parentData:data,parentDataProperty:"day_start",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate120.errors : vErrors.concat(validate120.errors); +errors = vErrors.length; +} +var valid3 = _errs10 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.day_end !== undefined && func0.call(data, "day_end")){ +const _errs11 = errors; +if(!(validate120(data.day_end, {instancePath:instancePath+"/day_end",parentData:data,parentDataProperty:"day_end",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate120.errors : vErrors.concat(validate120.errors); +errors = vErrors.length; +} +var valid3 = _errs11 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.nist !== undefined && func0.call(data, "nist")){ +let data9 = data.nist; +const _errs12 = errors; +if(errors === _errs12){ +if(data9 && typeof data9 == "object" && !Array.isArray(data9)){ +let missing3; +if((((((((data9.request_url === undefined) || (!(func0.call(data9, "request_url")))) && (missing3 = "request_url")) || (((data9.requested_at === undefined) || (!(func0.call(data9, "requested_at")))) && (missing3 = "requested_at"))) || (((data9.requested_epoch_ms === undefined) || (!(func0.call(data9, "requested_epoch_ms")))) && (missing3 = "requested_epoch_ms"))) || (((data9.verifier_profile === undefined) || (!(func0.call(data9, "verifier_profile")))) && (missing3 = "verifier_profile"))) || (((data9.pulse === undefined) || (!(func0.call(data9, "pulse")))) && (missing3 = "pulse"))) || (((data9.certificate_pem === undefined) || (!(func0.call(data9, "certificate_pem")))) && (missing3 = "certificate_pem"))){ +validate122.errors = [{instancePath:instancePath+"/nist",schemaPath:"#/properties/nist/required",keyword:"required",params:{missingProperty: missing3}}]; +return false; +} +else { +const _errs14 = errors; +for(const key1 of Object.keys(data9)){ +if(!((((((key1 === "request_url") || (key1 === "requested_at")) || (key1 === "requested_epoch_ms")) || (key1 === "verifier_profile")) || (key1 === "pulse")) || (key1 === "certificate_pem"))){ +validate122.errors = [{instancePath:instancePath+"/nist",schemaPath:"#/properties/nist/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1}}]; +return false; +break; +} +} +if(_errs14 === errors){ +if(data9.request_url !== undefined && func0.call(data9, "request_url")){ +let data10 = data9.request_url; +const _errs15 = errors; +if(errors === _errs15){ +if(errors === _errs15){ +if(typeof data10 === "string"){ +if(func68(data10) > 2048){ +validate122.errors = [{instancePath:instancePath+"/nist/request_url",schemaPath:"#/properties/nist/properties/request_url/maxLength",keyword:"maxLength",params:{limit: 2048}}]; +return false; +} +else { +if(!(formats0(data10))){ +validate122.errors = [{instancePath:instancePath+"/nist/request_url",schemaPath:"#/properties/nist/properties/request_url/format",keyword:"format",params:{format: "uri"}}]; +return false; +} +} +} +else { +validate122.errors = [{instancePath:instancePath+"/nist/request_url",schemaPath:"#/properties/nist/properties/request_url/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +var valid4 = _errs15 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data9.requested_at !== undefined && func0.call(data9, "requested_at")){ +const _errs17 = errors; +if(!(validate120(data9.requested_at, {instancePath:instancePath+"/nist/requested_at",parentData:data9,parentDataProperty:"requested_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate120.errors : vErrors.concat(validate120.errors); +errors = vErrors.length; +} +var valid4 = _errs17 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data9.requested_epoch_ms !== undefined && func0.call(data9, "requested_epoch_ms")){ +let data12 = data9.requested_epoch_ms; +const _errs18 = errors; +if(!(((typeof data12 == "number") && (!(data12 % 1) && !isNaN(data12))) && (isFinite(data12)))){ +validate122.errors = [{instancePath:instancePath+"/nist/requested_epoch_ms",schemaPath:"#/properties/nist/properties/requested_epoch_ms/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs18){ +if((typeof data12 == "number") && (isFinite(data12))){ +if(data12 > 8640000000000000 || isNaN(data12)){ +validate122.errors = [{instancePath:instancePath+"/nist/requested_epoch_ms",schemaPath:"#/properties/nist/properties/requested_epoch_ms/maximum",keyword:"maximum",params:{comparison: "<=", limit: 8640000000000000}}]; +return false; +} +else { +if(data12 < 0 || isNaN(data12)){ +validate122.errors = [{instancePath:instancePath+"/nist/requested_epoch_ms",schemaPath:"#/properties/nist/properties/requested_epoch_ms/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +} +var valid4 = _errs18 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data9.verifier_profile !== undefined && func0.call(data9, "verifier_profile")){ +const _errs20 = errors; +if("nist-beacon-v2-api-2019" !== data9.verifier_profile){ +validate122.errors = [{instancePath:instancePath+"/nist/verifier_profile",schemaPath:"#/properties/nist/properties/verifier_profile/const",keyword:"const",params:{allowedValue: "nist-beacon-v2-api-2019"}}]; +return false; +} +var valid4 = _errs20 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data9.pulse !== undefined && func0.call(data9, "pulse")){ +const _errs21 = errors; +if(!(validate128(data9.pulse, {instancePath:instancePath+"/nist/pulse",parentData:data9,parentDataProperty:"pulse",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate128.errors : vErrors.concat(validate128.errors); +errors = vErrors.length; +} +var valid4 = _errs21 === errors; +} +else { +var valid4 = true; +} +if(valid4){ +if(data9.certificate_pem !== undefined && func0.call(data9, "certificate_pem")){ +let data15 = data9.certificate_pem; +const _errs22 = errors; +if(errors === _errs22){ +if(typeof data15 === "string"){ +if(func68(data15) > 32768){ +validate122.errors = [{instancePath:instancePath+"/nist/certificate_pem",schemaPath:"#/properties/nist/properties/certificate_pem/maxLength",keyword:"maxLength",params:{limit: 32768}}]; +return false; +} +else { +if(func68(data15) < 256){ +validate122.errors = [{instancePath:instancePath+"/nist/certificate_pem",schemaPath:"#/properties/nist/properties/certificate_pem/minLength",keyword:"minLength",params:{limit: 256}}]; +return false; +} +} +} +else { +validate122.errors = [{instancePath:instancePath+"/nist/certificate_pem",schemaPath:"#/properties/nist/properties/certificate_pem/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid4 = _errs22 === errors; +} +else { +var valid4 = true; +} +} +} +} +} +} +} +} +} +else { +validate122.errors = [{instancePath:instancePath+"/nist",schemaPath:"#/properties/nist/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid3 = _errs12 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data.local !== undefined && func0.call(data, "local")){ +let data16 = data.local; +const _errs24 = errors; +if(errors === _errs24){ +if(data16 && typeof data16 == "object" && !Array.isArray(data16)){ +let missing4; +if((((data16.generated_at === undefined) || (!(func0.call(data16, "generated_at")))) && (missing4 = "generated_at")) || (((data16.generator === undefined) || (!(func0.call(data16, "generator")))) && (missing4 = "generator"))){ +validate122.errors = [{instancePath:instancePath+"/local",schemaPath:"#/properties/local/required",keyword:"required",params:{missingProperty: missing4}}]; +return false; +} +else { +const _errs26 = errors; +for(const key2 of Object.keys(data16)){ +if(!((key2 === "generated_at") || (key2 === "generator"))){ +validate122.errors = [{instancePath:instancePath+"/local",schemaPath:"#/properties/local/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2}}]; +return false; +break; +} +} +if(_errs26 === errors){ +if(data16.generated_at !== undefined && func0.call(data16, "generated_at")){ +const _errs27 = errors; +if(!(validate120(data16.generated_at, {instancePath:instancePath+"/local/generated_at",parentData:data16,parentDataProperty:"generated_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate120.errors : vErrors.concat(validate120.errors); +errors = vErrors.length; +} +var valid5 = _errs27 === errors; +} +else { +var valid5 = true; +} +if(valid5){ +if(data16.generator !== undefined && func0.call(data16, "generator")){ +const _errs28 = errors; +if("webcrypto.getrandomvalues" !== data16.generator){ +validate122.errors = [{instancePath:instancePath+"/local/generator",schemaPath:"#/properties/local/properties/generator/const",keyword:"const",params:{allowedValue: "webcrypto.getrandomvalues"}}]; +return false; +} +var valid5 = _errs28 === errors; +} +else { +var valid5 = true; +} +} +} +} +} +else { +validate122.errors = [{instancePath:instancePath+"/local",schemaPath:"#/properties/local/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid3 = _errs24 === errors; +} +else { +var valid3 = true; +} +} +} +} +} +} +} +} +} +} +else { +validate122.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate122.errors = vErrors; +return errors === 0; +} +validate122.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate134(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate134.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.version === undefined) || (!(func0.call(data, "version")))) && (missing0 = "version"))) || (((data.hkdf === undefined) || (!(func0.call(data, "hkdf")))) && (missing0 = "hkdf"))) || (((data.context === undefined) || (!(func0.call(data, "context")))) && (missing0 = "context"))){ +validate134.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((key0 === "id") || (key0 === "version")) || (key0 === "hkdf")) || (key0 === "context"))){ +validate134.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if("sortilune.today" !== data.id){ +validate134.errors = [{instancePath:instancePath+"/id",schemaPath:"#/properties/id/const",keyword:"const",params:{allowedValue: "sortilune.today"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.version !== undefined && func0.call(data, "version")){ +const _errs3 = errors; +if(1 !== data.version){ +validate134.errors = [{instancePath:instancePath+"/version",schemaPath:"#/properties/version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.hkdf !== undefined && func0.call(data, "hkdf")){ +const _errs4 = errors; +if("hkdf-sha-256" !== data.hkdf){ +validate134.errors = [{instancePath:instancePath+"/hkdf",schemaPath:"#/properties/hkdf/const",keyword:"const",params:{allowedValue: "hkdf-sha-256"}}]; +return false; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.context !== undefined && func0.call(data, "context")){ +let data3 = data.context; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data3 === "string"){ +if(func68(data3) > 512){ +validate134.errors = [{instancePath:instancePath+"/context",schemaPath:"#/properties/context/maxLength",keyword:"maxLength",params:{limit: 512}}]; +return false; +} +else { +if(func68(data3) < 1){ +validate134.errors = [{instancePath:instancePath+"/context",schemaPath:"#/properties/context/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate134.errors = [{instancePath:instancePath+"/context",schemaPath:"#/properties/context/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +else { +validate134.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate134.errors = vErrors; +return errors === 0; +} +validate134.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate138(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate138.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 96){ +validate138.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func68(data) < 1){ +validate138.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate138.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate138.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate138.errors = vErrors; +return errors === 0; +} +validate138.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const pattern16 = new RegExp("^(?:[0-9a-f]{2})+$", "u"); + +function validate140(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate140.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 16320){ +validate140.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 16320}}]; +return false; +} +else { +if(!pattern16.test(data)){ +validate140.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^(?:[0-9a-f]{2})+$"}}]; +return false; +} +} +} +else { +validate140.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate140.errors = vErrors; +return errors === 0; +} +validate140.evaluated = {"dynamicProps":false,"dynamicItems":false}; + +const pattern15 = new RegExp("^sortilune/today/v1/", "u"); + +function validate137(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate137.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.info === undefined) || (!(func0.call(data, "info")))) && (missing0 = "info"))) || (((data.bytes === undefined) || (!(func0.call(data, "bytes")))) && (missing0 = "bytes"))){ +validate137.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(((key0 === "id") || (key0 === "info")) || (key0 === "bytes"))){ +validate137.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate138(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate138.errors : vErrors.concat(validate138.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.info !== undefined && func0.call(data, "info")){ +let data1 = data.info; +const _errs3 = errors; +if(errors === _errs3){ +if(typeof data1 === "string"){ +if(func68(data1) > 96){ +validate137.errors = [{instancePath:instancePath+"/info",schemaPath:"#/properties/info/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(!pattern15.test(data1)){ +validate137.errors = [{instancePath:instancePath+"/info",schemaPath:"#/properties/info/pattern",keyword:"pattern",params:{pattern: "^sortilune/today/v1/"}}]; +return false; +} +} +} +else { +validate137.errors = [{instancePath:instancePath+"/info",schemaPath:"#/properties/info/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.bytes !== undefined && func0.call(data, "bytes")){ +const _errs5 = errors; +if(!(validate140(data.bytes, {instancePath:instancePath+"/bytes",parentData:data,parentDataProperty:"bytes",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate140.errors : vErrors.concat(validate140.errors); +errors = vErrors.length; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +else { +validate137.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate137.errors = vErrors; +return errors === 0; +} +validate137.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate136(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate136.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((data.salt === undefined) || (!(func0.call(data, "salt")))) && (missing0 = "salt")) || (((data.streams === undefined) || (!(func0.call(data, "streams")))) && (missing0 = "streams"))){ +validate136.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((key0 === "salt") || (key0 === "streams"))){ +validate136.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.salt !== undefined && func0.call(data, "salt")){ +let data0 = data.salt; +const _errs2 = errors; +if(errors === _errs2){ +if(typeof data0 === "string"){ +if(!pattern7.test(data0)){ +validate136.errors = [{instancePath:instancePath+"/salt",schemaPath:"#/properties/salt/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"}}]; +return false; +} +} +else { +validate136.errors = [{instancePath:instancePath+"/salt",schemaPath:"#/properties/salt/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.streams !== undefined && func0.call(data, "streams")){ +let data1 = data.streams; +const _errs4 = errors; +if(errors === _errs4){ +if(Array.isArray(data1)){ +if(data1.length > 9){ +validate136.errors = [{instancePath:instancePath+"/streams",schemaPath:"#/properties/streams/maxItems",keyword:"maxItems",params:{limit: 9}}]; +return false; +} +else { +if(data1.length < 9){ +validate136.errors = [{instancePath:instancePath+"/streams",schemaPath:"#/properties/streams/minItems",keyword:"minItems",params:{limit: 9}}]; +return false; +} +else { +var valid1 = true; +const len0 = data1.length; +for(let i0=0; i0 512){ +validate152.errors = [{instancePath:instancePath+"/detail",schemaPath:"#/properties/detail/maxLength",keyword:"maxLength",params:{limit: 512}}]; +return false; +} +else { +if(func68(data2) < 1){ +validate152.errors = [{instancePath:instancePath+"/detail",schemaPath:"#/properties/detail/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate152.errors = [{instancePath:instancePath+"/detail",schemaPath:"#/properties/detail/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +else { +validate152.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate152.errors = vErrors; +return errors === 0; +} +validate152.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate151(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate151.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.output_hash === undefined) || (!(func0.call(data, "output_hash")))) && (missing0 = "output_hash"))) || (((data.certificate_digest === undefined) || (!(func0.call(data, "certificate_digest")))) && (missing0 = "certificate_digest"))) || (((data.signature === undefined) || (!(func0.call(data, "signature")))) && (missing0 = "signature"))) || (((data.live_refetch === undefined) || (!(func0.call(data, "live_refetch")))) && (missing0 = "live_refetch"))) || (((data.adjacent_links === undefined) || (!(func0.call(data, "adjacent_links")))) && (missing0 = "adjacent_links"))){ +validate151.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((((key0 === "schema") || (key0 === "output_hash")) || (key0 === "certificate_digest")) || (key0 === "signature")) || (key0 === "live_refetch")) || (key0 === "adjacent_links"))){ +validate151.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if(!(validate152(data.schema, {instancePath:instancePath+"/schema",parentData:data,parentDataProperty:"schema",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate152.errors : vErrors.concat(validate152.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.output_hash !== undefined && func0.call(data, "output_hash")){ +const _errs3 = errors; +if(!(validate152(data.output_hash, {instancePath:instancePath+"/output_hash",parentData:data,parentDataProperty:"output_hash",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate152.errors : vErrors.concat(validate152.errors); +errors = vErrors.length; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.certificate_digest !== undefined && func0.call(data, "certificate_digest")){ +const _errs4 = errors; +if(!(validate152(data.certificate_digest, {instancePath:instancePath+"/certificate_digest",parentData:data,parentDataProperty:"certificate_digest",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate152.errors : vErrors.concat(validate152.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.signature !== undefined && func0.call(data, "signature")){ +const _errs5 = errors; +if(!(validate152(data.signature, {instancePath:instancePath+"/signature",parentData:data,parentDataProperty:"signature",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate152.errors : vErrors.concat(validate152.errors); +errors = vErrors.length; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.live_refetch !== undefined && func0.call(data, "live_refetch")){ +const _errs6 = errors; +if(!(validate152(data.live_refetch, {instancePath:instancePath+"/live_refetch",parentData:data,parentDataProperty:"live_refetch",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate152.errors : vErrors.concat(validate152.errors); +errors = vErrors.length; +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.adjacent_links !== undefined && func0.call(data, "adjacent_links")){ +const _errs7 = errors; +if(!(validate152(data.adjacent_links, {instancePath:instancePath+"/adjacent_links",parentData:data,parentDataProperty:"adjacent_links",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate152.errors : vErrors.concat(validate152.errors); +errors = vErrors.length; +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +else { +validate151.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate151.errors = vErrors; +return errors === 0; +} +validate151.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +const pattern11 = new RegExp("^[0-9]{4}-[0-9]{2}-[0-9]{2}$", "u"); + +function validate117(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/daily-record/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate117.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((((((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing0 = "schema_version"))) || (((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id"))) || (((data.local_date === undefined) || (!(func0.call(data, "local_date")))) && (missing0 = "local_date"))) || (((data.time_zone === undefined) || (!(func0.call(data, "time_zone")))) && (missing0 = "time_zone"))) || (((data.utc_offset_minutes === undefined) || (!(func0.call(data, "utc_offset_minutes")))) && (missing0 = "utc_offset_minutes"))) || (((data.edition === undefined) || (!(func0.call(data, "edition")))) && (missing0 = "edition"))) || (((data.created_at === undefined) || (!(func0.call(data, "created_at")))) && (missing0 = "created_at"))) || (((data.source === undefined) || (!(func0.call(data, "source")))) && (missing0 = "source"))) || (((data.algorithm === undefined) || (!(func0.call(data, "algorithm")))) && (missing0 = "algorithm"))) || (((data.derivation === undefined) || (!(func0.call(data, "derivation")))) && (missing0 = "derivation"))) || (((data.outputs === undefined) || (!(func0.call(data, "outputs")))) && (missing0 = "outputs"))) || (((data.verification === undefined) || (!(func0.call(data, "verification")))) && (missing0 = "verification"))){ +validate117.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"schema":{"const":"sortilune.daily-record"},"schema_version":{"const":1},"id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/stableId"},"local_date":{"type":"string","pattern":"^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},"time_zone":{"type":"string","minLength":1,"maxLength":128},"utc_offset_minutes":{"type":"integer","minimum":-1440,"maximum":1440},"edition":{"type":"integer","minimum":1,"maximum":9999},"created_at":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp"},"source":{"$ref":"#/$defs/source"},"algorithm":{"$ref":"#/$defs/algorithm"},"derivation":{"$ref":"#/$defs/derivation"},"outputs":{"$ref":"#/$defs/outputs"},"verification":{"$ref":"#/$defs/verification"}}, key0))){ +validate117.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if("sortilune.daily-record" !== data.schema){ +validate117.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.daily-record"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs3 = errors; +if(1 !== data.schema_version){ +validate117.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs4 = errors; +if(!(validate118(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate118.errors : vErrors.concat(validate118.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.local_date !== undefined && func0.call(data, "local_date")){ +let data3 = data.local_date; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data3 === "string"){ +if(!pattern11.test(data3)){ +validate117.errors = [{instancePath:instancePath+"/local_date",schemaPath:"#/properties/local_date/pattern",keyword:"pattern",params:{pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}}]; +return false; +} +} +else { +validate117.errors = [{instancePath:instancePath+"/local_date",schemaPath:"#/properties/local_date/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.time_zone !== undefined && func0.call(data, "time_zone")){ +let data4 = data.time_zone; +const _errs7 = errors; +if(errors === _errs7){ +if(typeof data4 === "string"){ +if(func68(data4) > 128){ +validate117.errors = [{instancePath:instancePath+"/time_zone",schemaPath:"#/properties/time_zone/maxLength",keyword:"maxLength",params:{limit: 128}}]; +return false; +} +else { +if(func68(data4) < 1){ +validate117.errors = [{instancePath:instancePath+"/time_zone",schemaPath:"#/properties/time_zone/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate117.errors = [{instancePath:instancePath+"/time_zone",schemaPath:"#/properties/time_zone/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.utc_offset_minutes !== undefined && func0.call(data, "utc_offset_minutes")){ +let data5 = data.utc_offset_minutes; +const _errs9 = errors; +if(!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))){ +validate117.errors = [{instancePath:instancePath+"/utc_offset_minutes",schemaPath:"#/properties/utc_offset_minutes/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs9){ +if((typeof data5 == "number") && (isFinite(data5))){ +if(data5 > 1440 || isNaN(data5)){ +validate117.errors = [{instancePath:instancePath+"/utc_offset_minutes",schemaPath:"#/properties/utc_offset_minutes/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1440}}]; +return false; +} +else { +if(data5 < -1440 || isNaN(data5)){ +validate117.errors = [{instancePath:instancePath+"/utc_offset_minutes",schemaPath:"#/properties/utc_offset_minutes/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1440}}]; +return false; +} +} +} +} +var valid0 = _errs9 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.edition !== undefined && func0.call(data, "edition")){ +let data6 = data.edition; +const _errs11 = errors; +if(!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))){ +validate117.errors = [{instancePath:instancePath+"/edition",schemaPath:"#/properties/edition/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs11){ +if((typeof data6 == "number") && (isFinite(data6))){ +if(data6 > 9999 || isNaN(data6)){ +validate117.errors = [{instancePath:instancePath+"/edition",schemaPath:"#/properties/edition/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9999}}]; +return false; +} +else { +if(data6 < 1 || isNaN(data6)){ +validate117.errors = [{instancePath:instancePath+"/edition",schemaPath:"#/properties/edition/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}]; +return false; +} +} +} +} +var valid0 = _errs11 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.created_at !== undefined && func0.call(data, "created_at")){ +const _errs13 = errors; +if(!(validate120(data.created_at, {instancePath:instancePath+"/created_at",parentData:data,parentDataProperty:"created_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate120.errors : vErrors.concat(validate120.errors); +errors = vErrors.length; +} +var valid0 = _errs13 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.source !== undefined && func0.call(data, "source")){ +const _errs14 = errors; +if(!(validate122(data.source, {instancePath:instancePath+"/source",parentData:data,parentDataProperty:"source",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate122.errors : vErrors.concat(validate122.errors); +errors = vErrors.length; +} +var valid0 = _errs14 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.algorithm !== undefined && func0.call(data, "algorithm")){ +const _errs15 = errors; +if(!(validate134(data.algorithm, {instancePath:instancePath+"/algorithm",parentData:data,parentDataProperty:"algorithm",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate134.errors : vErrors.concat(validate134.errors); +errors = vErrors.length; +} +var valid0 = _errs15 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.derivation !== undefined && func0.call(data, "derivation")){ +const _errs16 = errors; +if(!(validate136(data.derivation, {instancePath:instancePath+"/derivation",parentData:data,parentDataProperty:"derivation",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate136.errors : vErrors.concat(validate136.errors); +errors = vErrors.length; +} +var valid0 = _errs16 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.outputs !== undefined && func0.call(data, "outputs")){ +const _errs17 = errors; +if(!(validate144(data.outputs, {instancePath:instancePath+"/outputs",parentData:data,parentDataProperty:"outputs",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate144.errors : vErrors.concat(validate144.errors); +errors = vErrors.length; +} +var valid0 = _errs17 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.verification !== undefined && func0.call(data, "verification")){ +const _errs18 = errors; +if(!(validate151(data.verification, {instancePath:instancePath+"/verification",parentData:data,parentDataProperty:"verification",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate151.errors : vErrors.concat(validate151.errors); +errors = vErrors.length; +} +var valid0 = _errs18 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +} +} +} +} +} +} +else { +validate117.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate117.errors = vErrors; +return errors === 0; +} +validate117.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +export const validateProject = validate161; + +function validate162(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate162.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(!(formats4.test(data))){ +validate162.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "uuid"}}]; +return false; +} +} +else { +validate162.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate162.errors = vErrors; +return errors === 0; +} +validate162.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate164(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate164.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 64){ +validate164.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(!(formats2.validate(data))){ +validate164.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "date-time"}}]; +return false; +} +} +} +else { +validate164.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate164.errors = vErrors; +return errors === 0; +} +validate164.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate167(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate167.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(!(validate164(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate164.errors : vErrors.concat(validate164.errors); +errors = vErrors.length; +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +} +const _errs2 = errors; +if(data !== null){ +const err0 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs2 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +} +} +if(!valid0){ +const err1 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +validate167.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate167.errors = vErrors; +return errors === 0; +} +validate167.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate171(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate171.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 96){ +validate171.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func68(data) < 1){ +validate171.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate171.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate171.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate171.errors = vErrors; +return errors === 0; +} +validate171.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate177(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate177.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +const _errs0 = errors; +let valid0 = false; +let passing0 = null; +const _errs1 = errors; +if(!(validate171(data, {instancePath,parentData,parentDataProperty,rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate171.errors : vErrors.concat(validate171.errors); +errors = vErrors.length; +} +var _valid0 = _errs1 === errors; +if(_valid0){ +valid0 = true; +passing0 = 0; +} +const _errs2 = errors; +if(data !== null){ +const err0 = {instancePath,schemaPath:"#/oneOf/1/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs2 === errors; +if(_valid0 && valid0){ +valid0 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid0 = true; +passing0 = 1; +} +} +if(!valid0){ +const err1 = {instancePath,schemaPath:"#/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +validate177.errors = vErrors; +return false; +} +else { +errors = _errs0; +if(vErrors !== null){ +if(_errs0){ +vErrors.length = _errs0; +} +else { +vErrors = null; +} +} +} +validate177.errors = vErrors; +return errors === 0; +} +validate177.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate180(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate180.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((data.destination === undefined) || (!(func0.call(data, "destination")))) && (missing0 = "destination")) || (((data.label === undefined) || (!(func0.call(data, "label")))) && (missing0 = "label"))) || (((data.description === undefined) || (!(func0.call(data, "description")))) && (missing0 = "description"))){ +validate180.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(((key0 === "destination") || (key0 === "label")) || (key0 === "description"))){ +validate180.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.destination !== undefined && func0.call(data, "destination")){ +const _errs2 = errors; +if(!(validate171(data.destination, {instancePath:instancePath+"/destination",parentData:data,parentDataProperty:"destination",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate171.errors : vErrors.concat(validate171.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.label !== undefined && func0.call(data, "label")){ +let data1 = data.label; +const _errs3 = errors; +if(errors === _errs3){ +if(typeof data1 === "string"){ +if(func68(data1) > 80){ +validate180.errors = [{instancePath:instancePath+"/label",schemaPath:"#/properties/label/maxLength",keyword:"maxLength",params:{limit: 80}}]; +return false; +} +else { +if(func68(data1) < 1){ +validate180.errors = [{instancePath:instancePath+"/label",schemaPath:"#/properties/label/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate180.errors = [{instancePath:instancePath+"/label",schemaPath:"#/properties/label/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.description !== undefined && func0.call(data, "description")){ +let data2 = data.description; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data2 === "string"){ +if(func68(data2) > 240){ +validate180.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 240}}]; +return false; +} +else { +if(func68(data2) < 1){ +validate180.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate180.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +else { +validate180.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate180.errors = vErrors; +return errors === 0; +} +validate180.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate175(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate175.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.title === undefined) || (!(func0.call(data, "title")))) && (missing0 = "title"))) || (((data.description === undefined) || (!(func0.call(data, "description")))) && (missing0 = "description"))) || (((data.destination === undefined) || (!(func0.call(data, "destination")))) && (missing0 = "destination"))) || (((data.destination_options === undefined) || (!(func0.call(data, "destination_options")))) && (missing0 = "destination_options"))){ +validate175.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(((((key0 === "id") || (key0 === "title")) || (key0 === "description")) || (key0 === "destination")) || (key0 === "destination_options"))){ +validate175.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate171(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate171.errors : vErrors.concat(validate171.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.title !== undefined && func0.call(data, "title")){ +let data1 = data.title; +const _errs3 = errors; +if(errors === _errs3){ +if(typeof data1 === "string"){ +if(func68(data1) > 80){ +validate175.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/maxLength",keyword:"maxLength",params:{limit: 80}}]; +return false; +} +else { +if(func68(data1) < 1){ +validate175.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate175.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.description !== undefined && func0.call(data, "description")){ +let data2 = data.description; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data2 === "string"){ +if(func68(data2) > 320){ +validate175.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 320}}]; +return false; +} +else { +if(func68(data2) < 1){ +validate175.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate175.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.destination !== undefined && func0.call(data, "destination")){ +const _errs7 = errors; +if(!(validate177(data.destination, {instancePath:instancePath+"/destination",parentData:data,parentDataProperty:"destination",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate177.errors : vErrors.concat(validate177.errors); +errors = vErrors.length; +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.destination_options !== undefined && func0.call(data, "destination_options")){ +let data4 = data.destination_options; +const _errs8 = errors; +if(errors === _errs8){ +if(Array.isArray(data4)){ +if(data4.length > 8){ +validate175.errors = [{instancePath:instancePath+"/destination_options",schemaPath:"#/properties/destination_options/maxItems",keyword:"maxItems",params:{limit: 8}}]; +return false; +} +else { +var valid1 = true; +const len0 = data4.length; +for(let i0=0; i0 9999 || isNaN(data1)){ +validate173.errors = [{instancePath:instancePath+"/version",schemaPath:"#/properties/version/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9999}}]; +return false; +} +else { +if(data1 < 1 || isNaN(data1)){ +validate173.errors = [{instancePath:instancePath+"/version",schemaPath:"#/properties/version/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}]; +return false; +} +} +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.name !== undefined && func0.call(data, "name")){ +let data2 = data.name; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data2 === "string"){ +if(func68(data2) > 100){ +validate173.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/maxLength",keyword:"maxLength",params:{limit: 100}}]; +return false; +} +else { +if(func68(data2) < 1){ +validate173.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate173.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.description !== undefined && func0.call(data, "description")){ +let data3 = data.description; +const _errs7 = errors; +if(errors === _errs7){ +if(typeof data3 === "string"){ +if(func68(data3) > 400){ +validate173.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 400}}]; +return false; +} +else { +if(func68(data3) < 1){ +validate173.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate173.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.steps !== undefined && func0.call(data, "steps")){ +let data4 = data.steps; +const _errs9 = errors; +if(errors === _errs9){ +if(Array.isArray(data4)){ +if(data4.length > 16){ +validate173.errors = [{instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/maxItems",keyword:"maxItems",params:{limit: 16}}]; +return false; +} +else { +if(data4.length < 1){ +validate173.errors = [{instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid1 = true; +const len0 = data4.length; +for(let i0=0; i0 520){ +validate192.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 520}}]; +return false; +} +else { +if(!pattern6.test(data)){ +validate192.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^archive/(?!.*(?:^|/)\\.\\.?(?:/|$))[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$"}}]; +return false; +} +} +} +else { +validate192.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate192.errors = vErrors; +return errors === 0; +} +validate192.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate190(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate190.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.path === undefined) || (!(func0.call(data, "path")))) && (missing0 = "path"))) || (((data.chamber === undefined) || (!(func0.call(data, "chamber")))) && (missing0 = "chamber"))) || (((data.type === undefined) || (!(func0.call(data, "type")))) && (missing0 = "type"))) || (((data.summary === undefined) || (!(func0.call(data, "summary")))) && (missing0 = "summary"))) || (((data.created_at === undefined) || (!(func0.call(data, "created_at")))) && (missing0 = "created_at"))){ +validate190.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((((key0 === "id") || (key0 === "path")) || (key0 === "chamber")) || (key0 === "type")) || (key0 === "summary")) || (key0 === "created_at"))){ +validate190.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate162(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate162.errors : vErrors.concat(validate162.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.path !== undefined && func0.call(data, "path")){ +const _errs3 = errors; +if(!(validate192(data.path, {instancePath:instancePath+"/path",parentData:data,parentDataProperty:"path",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate192.errors : vErrors.concat(validate192.errors); +errors = vErrors.length; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.chamber !== undefined && func0.call(data, "chamber")){ +const _errs4 = errors; +if(!(validate171(data.chamber, {instancePath:instancePath+"/chamber",parentData:data,parentDataProperty:"chamber",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate171.errors : vErrors.concat(validate171.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.type !== undefined && func0.call(data, "type")){ +const _errs5 = errors; +if(!(validate171(data.type, {instancePath:instancePath+"/type",parentData:data,parentDataProperty:"type",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate171.errors : vErrors.concat(validate171.errors); +errors = vErrors.length; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.summary !== undefined && func0.call(data, "summary")){ +let data4 = data.summary; +const _errs6 = errors; +if(errors === _errs6){ +if(typeof data4 === "string"){ +if(func68(data4) > 2000){ +validate190.errors = [{instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/maxLength",keyword:"maxLength",params:{limit: 2000}}]; +return false; +} +else { +if(func68(data4) < 1){ +validate190.errors = [{instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate190.errors = [{instancePath:instancePath+"/summary",schemaPath:"#/properties/summary/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.created_at !== undefined && func0.call(data, "created_at")){ +const _errs8 = errors; +if(!(validate164(data.created_at, {instancePath:instancePath+"/created_at",parentData:data,parentDataProperty:"created_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate164.errors : vErrors.concat(validate164.errors); +errors = vErrors.length; +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +else { +validate190.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate190.errors = vErrors; +return errors === 0; +} +validate190.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate185(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate185.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((((((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.title === undefined) || (!(func0.call(data, "title")))) && (missing0 = "title"))) || (((data.description === undefined) || (!(func0.call(data, "description")))) && (missing0 = "description"))) || (((data.destination === undefined) || (!(func0.call(data, "destination")))) && (missing0 = "destination"))) || (((data.destination_options === undefined) || (!(func0.call(data, "destination_options")))) && (missing0 = "destination_options"))) || (((data.selected_destination === undefined) || (!(func0.call(data, "selected_destination")))) && (missing0 = "selected_destination"))) || (((data.attempt === undefined) || (!(func0.call(data, "attempt")))) && (missing0 = "attempt"))) || (((data.status === undefined) || (!(func0.call(data, "status")))) && (missing0 = "status"))) || (((data.note === undefined) || (!(func0.call(data, "note")))) && (missing0 = "note"))) || (((data.record_reference === undefined) || (!(func0.call(data, "record_reference")))) && (missing0 = "record_reference"))) || (((data.completed_at === undefined) || (!(func0.call(data, "completed_at")))) && (missing0 = "completed_at"))) || (((data.skipped_at === undefined) || (!(func0.call(data, "skipped_at")))) && (missing0 = "skipped_at"))){ +validate185.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"title":{"type":"string","minLength":1,"maxLength":80},"description":{"type":"string","minLength":1,"maxLength":320},"destination":{"$ref":"#/$defs/nullableDestination"},"destination_options":{"type":"array","maxItems":8,"items":{"$ref":"#/$defs/destinationOption"}},"selected_destination":{"$ref":"#/$defs/nullableDestination"},"attempt":{"type":"integer","minimum":1,"maximum":9999},"status":{"enum":["pending","current","completed","skipped"]},"note":{"type":"string","maxLength":5000},"record_reference":{"oneOf":[{"$ref":"#/$defs/projectRecordReference"},{"type":"null"}]},"completed_at":{"$ref":"#/$defs/nullableTimestamp"},"skipped_at":{"$ref":"#/$defs/nullableTimestamp"}}, key0))){ +validate185.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate171(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate171.errors : vErrors.concat(validate171.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.title !== undefined && func0.call(data, "title")){ +let data1 = data.title; +const _errs3 = errors; +if(errors === _errs3){ +if(typeof data1 === "string"){ +if(func68(data1) > 80){ +validate185.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/maxLength",keyword:"maxLength",params:{limit: 80}}]; +return false; +} +else { +if(func68(data1) < 1){ +validate185.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate185.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.description !== undefined && func0.call(data, "description")){ +let data2 = data.description; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data2 === "string"){ +if(func68(data2) > 320){ +validate185.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/maxLength",keyword:"maxLength",params:{limit: 320}}]; +return false; +} +else { +if(func68(data2) < 1){ +validate185.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate185.errors = [{instancePath:instancePath+"/description",schemaPath:"#/properties/description/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.destination !== undefined && func0.call(data, "destination")){ +const _errs7 = errors; +if(!(validate177(data.destination, {instancePath:instancePath+"/destination",parentData:data,parentDataProperty:"destination",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate177.errors : vErrors.concat(validate177.errors); +errors = vErrors.length; +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.destination_options !== undefined && func0.call(data, "destination_options")){ +let data4 = data.destination_options; +const _errs8 = errors; +if(errors === _errs8){ +if(Array.isArray(data4)){ +if(data4.length > 8){ +validate185.errors = [{instancePath:instancePath+"/destination_options",schemaPath:"#/properties/destination_options/maxItems",keyword:"maxItems",params:{limit: 8}}]; +return false; +} +else { +var valid1 = true; +const len0 = data4.length; +for(let i0=0; i0 9999 || isNaN(data7)){ +validate185.errors = [{instancePath:instancePath+"/attempt",schemaPath:"#/properties/attempt/maximum",keyword:"maximum",params:{comparison: "<=", limit: 9999}}]; +return false; +} +else { +if(data7 < 1 || isNaN(data7)){ +validate185.errors = [{instancePath:instancePath+"/attempt",schemaPath:"#/properties/attempt/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}]; +return false; +} +} +} +} +var valid0 = _errs12 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.status !== undefined && func0.call(data, "status")){ +let data8 = data.status; +const _errs14 = errors; +if(!((((data8 === "pending") || (data8 === "current")) || (data8 === "completed")) || (data8 === "skipped"))){ +validate185.errors = [{instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: ["pending","current","completed","skipped"]}}]; +return false; +} +var valid0 = _errs14 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.note !== undefined && func0.call(data, "note")){ +let data9 = data.note; +const _errs15 = errors; +if(errors === _errs15){ +if(typeof data9 === "string"){ +if(func68(data9) > 5000){ +validate185.errors = [{instancePath:instancePath+"/note",schemaPath:"#/properties/note/maxLength",keyword:"maxLength",params:{limit: 5000}}]; +return false; +} +} +else { +validate185.errors = [{instancePath:instancePath+"/note",schemaPath:"#/properties/note/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs15 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.record_reference !== undefined && func0.call(data, "record_reference")){ +let data10 = data.record_reference; +const _errs17 = errors; +const _errs18 = errors; +let valid2 = false; +let passing0 = null; +const _errs19 = errors; +if(!(validate190(data10, {instancePath:instancePath+"/record_reference",parentData:data,parentDataProperty:"record_reference",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate190.errors : vErrors.concat(validate190.errors); +errors = vErrors.length; +} +var _valid0 = _errs19 === errors; +if(_valid0){ +valid2 = true; +passing0 = 0; +} +const _errs20 = errors; +if(data10 !== null){ +const err0 = {instancePath:instancePath+"/record_reference",schemaPath:"#/properties/record_reference/oneOf/1/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs20 === errors; +if(_valid0 && valid2){ +valid2 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid2 = true; +passing0 = 1; +} +} +if(!valid2){ +const err1 = {instancePath:instancePath+"/record_reference",schemaPath:"#/properties/record_reference/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +validate185.errors = vErrors; +return false; +} +else { +errors = _errs18; +if(vErrors !== null){ +if(_errs18){ +vErrors.length = _errs18; +} +else { +vErrors = null; +} +} +} +var valid0 = _errs17 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.completed_at !== undefined && func0.call(data, "completed_at")){ +const _errs22 = errors; +if(!(validate167(data.completed_at, {instancePath:instancePath+"/completed_at",parentData:data,parentDataProperty:"completed_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate167.errors : vErrors.concat(validate167.errors); +errors = vErrors.length; +} +var valid0 = _errs22 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.skipped_at !== undefined && func0.call(data, "skipped_at")){ +const _errs23 = errors; +if(!(validate167(data.skipped_at, {instancePath:instancePath+"/skipped_at",parentData:data,parentDataProperty:"skipped_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate167.errors : vErrors.concat(validate167.errors); +errors = vErrors.length; +} +var valid0 = _errs23 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +} +} +} +} +} +else { +validate185.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate185.errors = vErrors; +return errors === 0; +} +validate185.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate161(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/project/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate161.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((((((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing0 = "schema_version"))) || (((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id"))) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing0 = "name"))) || (((data.status === undefined) || (!(func0.call(data, "status")))) && (missing0 = "status"))) || (((data.created_at === undefined) || (!(func0.call(data, "created_at")))) && (missing0 = "created_at"))) || (((data.updated_at === undefined) || (!(func0.call(data, "updated_at")))) && (missing0 = "updated_at"))) || (((data.completed_at === undefined) || (!(func0.call(data, "completed_at")))) && (missing0 = "completed_at"))) || (((data.closed_at === undefined) || (!(func0.call(data, "closed_at")))) && (missing0 = "closed_at"))) || (((data.current_step_id === undefined) || (!(func0.call(data, "current_step_id")))) && (missing0 = "current_step_id"))) || (((data.notes === undefined) || (!(func0.call(data, "notes")))) && (missing0 = "notes"))) || (((data.template === undefined) || (!(func0.call(data, "template")))) && (missing0 = "template"))) || (((data.steps === undefined) || (!(func0.call(data, "steps")))) && (missing0 = "steps"))){ +validate161.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"schema":{"const":"sortilune.project"},"schema_version":{"const":1},"id":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/stableId"},"name":{"type":"string","minLength":1,"maxLength":120},"status":{"enum":["active","completed","closed"]},"created_at":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp"},"updated_at":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp"},"completed_at":{"$ref":"#/$defs/nullableTimestamp"},"closed_at":{"$ref":"#/$defs/nullableTimestamp"},"current_step_id":{"oneOf":[{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},{"type":"null"}]},"notes":{"type":"string","maxLength":20000},"template":{"$ref":"#/$defs/projectTemplate"},"steps":{"type":"array","minItems":1,"maxItems":16,"items":{"$ref":"#/$defs/projectStep"}}}, key0))){ +validate161.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if("sortilune.project" !== data.schema){ +validate161.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.project"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs3 = errors; +if(1 !== data.schema_version){ +validate161.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs4 = errors; +if(!(validate162(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate162.errors : vErrors.concat(validate162.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.name !== undefined && func0.call(data, "name")){ +let data3 = data.name; +const _errs5 = errors; +if(errors === _errs5){ +if(typeof data3 === "string"){ +if(func68(data3) > 120){ +validate161.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/maxLength",keyword:"maxLength",params:{limit: 120}}]; +return false; +} +else { +if(func68(data3) < 1){ +validate161.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate161.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.status !== undefined && func0.call(data, "status")){ +let data4 = data.status; +const _errs7 = errors; +if(!(((data4 === "active") || (data4 === "completed")) || (data4 === "closed"))){ +validate161.errors = [{instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: ["active","completed","closed"]}}]; +return false; +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.created_at !== undefined && func0.call(data, "created_at")){ +const _errs8 = errors; +if(!(validate164(data.created_at, {instancePath:instancePath+"/created_at",parentData:data,parentDataProperty:"created_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate164.errors : vErrors.concat(validate164.errors); +errors = vErrors.length; +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.updated_at !== undefined && func0.call(data, "updated_at")){ +const _errs9 = errors; +if(!(validate164(data.updated_at, {instancePath:instancePath+"/updated_at",parentData:data,parentDataProperty:"updated_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate164.errors : vErrors.concat(validate164.errors); +errors = vErrors.length; +} +var valid0 = _errs9 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.completed_at !== undefined && func0.call(data, "completed_at")){ +const _errs10 = errors; +if(!(validate167(data.completed_at, {instancePath:instancePath+"/completed_at",parentData:data,parentDataProperty:"completed_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate167.errors : vErrors.concat(validate167.errors); +errors = vErrors.length; +} +var valid0 = _errs10 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.closed_at !== undefined && func0.call(data, "closed_at")){ +const _errs11 = errors; +if(!(validate167(data.closed_at, {instancePath:instancePath+"/closed_at",parentData:data,parentDataProperty:"closed_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate167.errors : vErrors.concat(validate167.errors); +errors = vErrors.length; +} +var valid0 = _errs11 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.current_step_id !== undefined && func0.call(data, "current_step_id")){ +let data9 = data.current_step_id; +const _errs12 = errors; +const _errs13 = errors; +let valid1 = false; +let passing0 = null; +const _errs14 = errors; +if(!(validate171(data9, {instancePath:instancePath+"/current_step_id",parentData:data,parentDataProperty:"current_step_id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate171.errors : vErrors.concat(validate171.errors); +errors = vErrors.length; +} +var _valid0 = _errs14 === errors; +if(_valid0){ +valid1 = true; +passing0 = 0; +} +const _errs15 = errors; +if(data9 !== null){ +const err0 = {instancePath:instancePath+"/current_step_id",schemaPath:"#/properties/current_step_id/oneOf/1/type",keyword:"type",params:{type: "null"}}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +var _valid0 = _errs15 === errors; +if(_valid0 && valid1){ +valid1 = false; +passing0 = [passing0, 1]; +} +else { +if(_valid0){ +valid1 = true; +passing0 = 1; +} +} +if(!valid1){ +const err1 = {instancePath:instancePath+"/current_step_id",schemaPath:"#/properties/current_step_id/oneOf",keyword:"oneOf",params:{passingSchemas: passing0}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +validate161.errors = vErrors; +return false; +} +else { +errors = _errs13; +if(vErrors !== null){ +if(_errs13){ +vErrors.length = _errs13; +} +else { +vErrors = null; +} +} +} +var valid0 = _errs12 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.notes !== undefined && func0.call(data, "notes")){ +let data10 = data.notes; +const _errs17 = errors; +if(errors === _errs17){ +if(typeof data10 === "string"){ +if(func68(data10) > 20000){ +validate161.errors = [{instancePath:instancePath+"/notes",schemaPath:"#/properties/notes/maxLength",keyword:"maxLength",params:{limit: 20000}}]; +return false; +} +} +else { +validate161.errors = [{instancePath:instancePath+"/notes",schemaPath:"#/properties/notes/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs17 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.template !== undefined && func0.call(data, "template")){ +const _errs19 = errors; +if(!(validate173(data.template, {instancePath:instancePath+"/template",parentData:data,parentDataProperty:"template",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate173.errors : vErrors.concat(validate173.errors); +errors = vErrors.length; +} +var valid0 = _errs19 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.steps !== undefined && func0.call(data, "steps")){ +let data12 = data.steps; +const _errs20 = errors; +if(errors === _errs20){ +if(Array.isArray(data12)){ +if(data12.length > 16){ +validate161.errors = [{instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/maxItems",keyword:"maxItems",params:{limit: 16}}]; +return false; +} +else { +if(data12.length < 1){ +validate161.errors = [{instancePath:instancePath+"/steps",schemaPath:"#/properties/steps/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid2 = true; +const len0 = data12.length; +for(let i0=0; i0 64){ +validate202.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(!(formats2.validate(data))){ +validate202.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "date-time"}}]; +return false; +} +} +} +else { +validate202.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate202.errors = vErrors; +return errors === 0; +} +validate202.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate205(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate205.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(errors === 0){ +if(typeof data === "string"){ +if(!(formats4.test(data))){ +validate205.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "uuid"}}]; +return false; +} +} +else { +validate205.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate205.errors = vErrors; +return errors === 0; +} +validate205.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate207(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate207.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.title === undefined) || (!(func0.call(data, "title")))) && (missing0 = "title"))){ +validate207.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((key0 === "id") || (key0 === "title"))){ +validate207.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate205(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate205.errors : vErrors.concat(validate205.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.title !== undefined && func0.call(data, "title")){ +let data1 = data.title; +const _errs3 = errors; +if(errors === _errs3){ +if(typeof data1 === "string"){ +if(func68(data1) > 240){ +validate207.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/maxLength",keyword:"maxLength",params:{limit: 240}}]; +return false; +} +else { +if(func68(data1) < 1){ +validate207.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate207.errors = [{instancePath:instancePath+"/title",schemaPath:"#/properties/title/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +else { +validate207.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate207.errors = vErrors; +return errors === 0; +} +validate207.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate204(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate204.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.name === undefined) || (!(func0.call(data, "name")))) && (missing0 = "name"))) || (((data.status === undefined) || (!(func0.call(data, "status")))) && (missing0 = "status"))) || (((data.activities === undefined) || (!(func0.call(data, "activities")))) && (missing0 = "activities"))) || (((data.eligible_weekdays === undefined) || (!(func0.call(data, "eligible_weekdays")))) && (missing0 = "eligible_weekdays"))) || (((data.created_at === undefined) || (!(func0.call(data, "created_at")))) && (missing0 = "created_at"))) || (((data.updated_at === undefined) || (!(func0.call(data, "updated_at")))) && (missing0 = "updated_at"))){ +validate204.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(((((((key0 === "id") || (key0 === "name")) || (key0 === "status")) || (key0 === "activities")) || (key0 === "eligible_weekdays")) || (key0 === "created_at")) || (key0 === "updated_at"))){ +validate204.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +const _errs2 = errors; +if(!(validate205(data.id, {instancePath:instancePath+"/id",parentData:data,parentDataProperty:"id",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate205.errors : vErrors.concat(validate205.errors); +errors = vErrors.length; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.name !== undefined && func0.call(data, "name")){ +let data1 = data.name; +const _errs3 = errors; +if(errors === _errs3){ +if(typeof data1 === "string"){ +if(func68(data1) > 120){ +validate204.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/maxLength",keyword:"maxLength",params:{limit: 120}}]; +return false; +} +else { +if(func68(data1) < 1){ +validate204.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate204.errors = [{instancePath:instancePath+"/name",schemaPath:"#/properties/name/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.status !== undefined && func0.call(data, "status")){ +let data2 = data.status; +const _errs5 = errors; +if(!(((data2 === "active") || (data2 === "paused")) || (data2 === "stopped"))){ +validate204.errors = [{instancePath:instancePath+"/status",schemaPath:"#/properties/status/enum",keyword:"enum",params:{allowedValues: ["active","paused","stopped"]}}]; +return false; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.activities !== undefined && func0.call(data, "activities")){ +let data3 = data.activities; +const _errs6 = errors; +if(errors === _errs6){ +if(Array.isArray(data3)){ +if(data3.length > 24){ +validate204.errors = [{instancePath:instancePath+"/activities",schemaPath:"#/properties/activities/maxItems",keyword:"maxItems",params:{limit: 24}}]; +return false; +} +else { +if(data3.length < 1){ +validate204.errors = [{instancePath:instancePath+"/activities",schemaPath:"#/properties/activities/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid1 = true; +const len0 = data3.length; +for(let i0=0; i0 7){ +validate204.errors = [{instancePath:instancePath+"/eligible_weekdays",schemaPath:"#/properties/eligible_weekdays/maxItems",keyword:"maxItems",params:{limit: 7}}]; +return false; +} +else { +if(data5.length < 1){ +validate204.errors = [{instancePath:instancePath+"/eligible_weekdays",schemaPath:"#/properties/eligible_weekdays/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid2 = true; +const len1 = data5.length; +for(let i1=0; i1 6 || isNaN(data6)){ +validate204.errors = [{instancePath:instancePath+"/eligible_weekdays/" + i1,schemaPath:"#/properties/eligible_weekdays/items/maximum",keyword:"maximum",params:{comparison: "<=", limit: 6}}]; +return false; +} +else { +if(data6 < 0 || isNaN(data6)){ +validate204.errors = [{instancePath:instancePath+"/eligible_weekdays/" + i1,schemaPath:"#/properties/eligible_weekdays/items/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +} +var valid2 = _errs11 === errors; +if(!valid2){ +break; +} +} +if(valid2){ +let i2 = data5.length; +let j0; +if(i2 > 1){ +const indices0 = {}; +for(;i2--;){ +let item0 = data5[i2]; +if(!(((typeof item0 == "number") && (!(item0 % 1) && !isNaN(item0))) && (isFinite(item0)))){ +continue; +} +if(typeof indices0[item0] == "number"){ +j0 = indices0[item0]; +validate204.errors = [{instancePath:instancePath+"/eligible_weekdays",schemaPath:"#/properties/eligible_weekdays/uniqueItems",keyword:"uniqueItems",params:{i: i2, j: j0}}]; +return false; +break; +} +indices0[item0] = i2; +} +} +} +} +} +} +else { +validate204.errors = [{instancePath:instancePath+"/eligible_weekdays",schemaPath:"#/properties/eligible_weekdays/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +var valid0 = _errs9 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.created_at !== undefined && func0.call(data, "created_at")){ +const _errs13 = errors; +if(!(validate202(data.created_at, {instancePath:instancePath+"/created_at",parentData:data,parentDataProperty:"created_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate202.errors : vErrors.concat(validate202.errors); +errors = vErrors.length; +} +var valid0 = _errs13 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.updated_at !== undefined && func0.call(data, "updated_at")){ +const _errs14 = errors; +if(!(validate202(data.updated_at, {instancePath:instancePath+"/updated_at",parentData:data,parentDataProperty:"updated_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate202.errors : vErrors.concat(validate202.errors); +errors = vErrors.length; +} +var valid0 = _errs14 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +else { +validate204.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate204.errors = vErrors; +return errors === 0; +} +validate204.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate201(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/practice-store/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate201.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing0 = "schema_version"))) || (((data.updated_at === undefined) || (!(func0.call(data, "updated_at")))) && (missing0 = "updated_at"))) || (((data.plans === undefined) || (!(func0.call(data, "plans")))) && (missing0 = "plans"))){ +validate201.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((key0 === "schema") || (key0 === "schema_version")) || (key0 === "updated_at")) || (key0 === "plans"))){ +validate201.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if("sortilune.practice-store" !== data.schema){ +validate201.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.practice-store"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs3 = errors; +if(1 !== data.schema_version){ +validate201.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.updated_at !== undefined && func0.call(data, "updated_at")){ +const _errs4 = errors; +if(!(validate202(data.updated_at, {instancePath:instancePath+"/updated_at",parentData:data,parentDataProperty:"updated_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate202.errors : vErrors.concat(validate202.errors); +errors = vErrors.length; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.plans !== undefined && func0.call(data, "plans")){ +let data3 = data.plans; +const _errs5 = errors; +if(errors === _errs5){ +if(Array.isArray(data3)){ +if(data3.length > 64){ +validate201.errors = [{instancePath:instancePath+"/plans",schemaPath:"#/properties/plans/maxItems",keyword:"maxItems",params:{limit: 64}}]; +return false; +} +else { +var valid1 = true; +const len0 = data3.length; +for(let i0=0; i0 64){ +validate214.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 64}}]; +return false; +} +else { +if(!(formats2.validate(data))){ +validate214.errors = [{instancePath,schemaPath:"#/format",keyword:"format",params:{format: "date-time"}}]; +return false; +} +} +} +else { +validate214.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +} +validate214.errors = vErrors; +return errors === 0; +} +validate214.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate216(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate216.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((data.fundamental_hz === undefined) || (!(func0.call(data, "fundamental_hz")))) && (missing0 = "fundamental_hz")) || (((data.upper_hz === undefined) || (!(func0.call(data, "upper_hz")))) && (missing0 = "upper_hz"))) || (((data.gain === undefined) || (!(func0.call(data, "gain")))) && (missing0 = "gain"))) || (((data.filter_hz === undefined) || (!(func0.call(data, "filter_hz")))) && (missing0 = "filter_hz"))){ +validate216.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((key0 === "fundamental_hz") || (key0 === "upper_hz")) || (key0 === "gain")) || (key0 === "filter_hz"))){ +validate216.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.fundamental_hz !== undefined && func0.call(data, "fundamental_hz")){ +let data0 = data.fundamental_hz; +const _errs2 = errors; +if(errors === _errs2){ +if((typeof data0 == "number") && (isFinite(data0))){ +if(data0 > 24000 || isNaN(data0)){ +validate216.errors = [{instancePath:instancePath+"/fundamental_hz",schemaPath:"#/properties/fundamental_hz/maximum",keyword:"maximum",params:{comparison: "<=", limit: 24000}}]; +return false; +} +else { +if(data0 <= 0 || isNaN(data0)){ +validate216.errors = [{instancePath:instancePath+"/fundamental_hz",schemaPath:"#/properties/fundamental_hz/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0}}]; +return false; +} +} +} +else { +validate216.errors = [{instancePath:instancePath+"/fundamental_hz",schemaPath:"#/properties/fundamental_hz/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.upper_hz !== undefined && func0.call(data, "upper_hz")){ +let data1 = data.upper_hz; +const _errs4 = errors; +if(errors === _errs4){ +if((typeof data1 == "number") && (isFinite(data1))){ +if(data1 > 24000 || isNaN(data1)){ +validate216.errors = [{instancePath:instancePath+"/upper_hz",schemaPath:"#/properties/upper_hz/maximum",keyword:"maximum",params:{comparison: "<=", limit: 24000}}]; +return false; +} +else { +if(data1 <= 0 || isNaN(data1)){ +validate216.errors = [{instancePath:instancePath+"/upper_hz",schemaPath:"#/properties/upper_hz/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0}}]; +return false; +} +} +} +else { +validate216.errors = [{instancePath:instancePath+"/upper_hz",schemaPath:"#/properties/upper_hz/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.gain !== undefined && func0.call(data, "gain")){ +let data2 = data.gain; +const _errs6 = errors; +if(errors === _errs6){ +if((typeof data2 == "number") && (isFinite(data2))){ +if(data2 > 1 || isNaN(data2)){ +validate216.errors = [{instancePath:instancePath+"/gain",schemaPath:"#/properties/gain/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1}}]; +return false; +} +else { +if(data2 < 0 || isNaN(data2)){ +validate216.errors = [{instancePath:instancePath+"/gain",schemaPath:"#/properties/gain/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +else { +validate216.errors = [{instancePath:instancePath+"/gain",schemaPath:"#/properties/gain/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.filter_hz !== undefined && func0.call(data, "filter_hz")){ +let data3 = data.filter_hz; +const _errs8 = errors; +if(errors === _errs8){ +if((typeof data3 == "number") && (isFinite(data3))){ +if(data3 > 24000 || isNaN(data3)){ +validate216.errors = [{instancePath:instancePath+"/filter_hz",schemaPath:"#/properties/filter_hz/maximum",keyword:"maximum",params:{comparison: "<=", limit: 24000}}]; +return false; +} +else { +if(data3 < 20 || isNaN(data3)){ +validate216.errors = [{instancePath:instancePath+"/filter_hz",schemaPath:"#/properties/filter_hz/minimum",keyword:"minimum",params:{comparison: ">=", limit: 20}}]; +return false; +} +} +} +else { +validate216.errors = [{instancePath:instancePath+"/filter_hz",schemaPath:"#/properties/filter_hz/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +else { +validate216.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate216.errors = vErrors; +return errors === 0; +} +validate216.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate219(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate219.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((((((data.frequency_hz === undefined) || (!(func0.call(data, "frequency_hz")))) && (missing0 = "frequency_hz")) || (((data.midi_note === undefined) || (!(func0.call(data, "midi_note")))) && (missing0 = "midi_note"))) || (((data.offset_ms === undefined) || (!(func0.call(data, "offset_ms")))) && (missing0 = "offset_ms"))) || (((data.duration_ms === undefined) || (!(func0.call(data, "duration_ms")))) && (missing0 = "duration_ms"))) || (((data.attack_ms === undefined) || (!(func0.call(data, "attack_ms")))) && (missing0 = "attack_ms"))) || (((data.gain === undefined) || (!(func0.call(data, "gain")))) && (missing0 = "gain"))) || (((data.pan === undefined) || (!(func0.call(data, "pan")))) && (missing0 = "pan"))) || (((data.detune_cents === undefined) || (!(func0.call(data, "detune_cents")))) && (missing0 = "detune_cents"))) || (((data.waveform === undefined) || (!(func0.call(data, "waveform")))) && (missing0 = "waveform"))){ +validate219.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"frequency_hz":{"type":"number","exclusiveMinimum":0,"maximum":24000},"midi_note":{"type":"integer","minimum":0,"maximum":127},"offset_ms":{"type":"integer","minimum":0,"maximum":1800000},"duration_ms":{"type":"integer","minimum":1,"maximum":60000},"attack_ms":{"type":"integer","minimum":0,"maximum":60000},"gain":{"type":"number","minimum":0,"maximum":1},"pan":{"type":"number","minimum":-1,"maximum":1},"detune_cents":{"type":"number","minimum":-1200,"maximum":1200},"waveform":{"const":"sine"}}, key0))){ +validate219.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.frequency_hz !== undefined && func0.call(data, "frequency_hz")){ +let data0 = data.frequency_hz; +const _errs2 = errors; +if(errors === _errs2){ +if((typeof data0 == "number") && (isFinite(data0))){ +if(data0 > 24000 || isNaN(data0)){ +validate219.errors = [{instancePath:instancePath+"/frequency_hz",schemaPath:"#/properties/frequency_hz/maximum",keyword:"maximum",params:{comparison: "<=", limit: 24000}}]; +return false; +} +else { +if(data0 <= 0 || isNaN(data0)){ +validate219.errors = [{instancePath:instancePath+"/frequency_hz",schemaPath:"#/properties/frequency_hz/exclusiveMinimum",keyword:"exclusiveMinimum",params:{comparison: ">", limit: 0}}]; +return false; +} +} +} +else { +validate219.errors = [{instancePath:instancePath+"/frequency_hz",schemaPath:"#/properties/frequency_hz/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.midi_note !== undefined && func0.call(data, "midi_note")){ +let data1 = data.midi_note; +const _errs4 = errors; +if(!(((typeof data1 == "number") && (!(data1 % 1) && !isNaN(data1))) && (isFinite(data1)))){ +validate219.errors = [{instancePath:instancePath+"/midi_note",schemaPath:"#/properties/midi_note/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs4){ +if((typeof data1 == "number") && (isFinite(data1))){ +if(data1 > 127 || isNaN(data1)){ +validate219.errors = [{instancePath:instancePath+"/midi_note",schemaPath:"#/properties/midi_note/maximum",keyword:"maximum",params:{comparison: "<=", limit: 127}}]; +return false; +} +else { +if(data1 < 0 || isNaN(data1)){ +validate219.errors = [{instancePath:instancePath+"/midi_note",schemaPath:"#/properties/midi_note/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.offset_ms !== undefined && func0.call(data, "offset_ms")){ +let data2 = data.offset_ms; +const _errs6 = errors; +if(!(((typeof data2 == "number") && (!(data2 % 1) && !isNaN(data2))) && (isFinite(data2)))){ +validate219.errors = [{instancePath:instancePath+"/offset_ms",schemaPath:"#/properties/offset_ms/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs6){ +if((typeof data2 == "number") && (isFinite(data2))){ +if(data2 > 1800000 || isNaN(data2)){ +validate219.errors = [{instancePath:instancePath+"/offset_ms",schemaPath:"#/properties/offset_ms/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1800000}}]; +return false; +} +else { +if(data2 < 0 || isNaN(data2)){ +validate219.errors = [{instancePath:instancePath+"/offset_ms",schemaPath:"#/properties/offset_ms/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.duration_ms !== undefined && func0.call(data, "duration_ms")){ +let data3 = data.duration_ms; +const _errs8 = errors; +if(!(((typeof data3 == "number") && (!(data3 % 1) && !isNaN(data3))) && (isFinite(data3)))){ +validate219.errors = [{instancePath:instancePath+"/duration_ms",schemaPath:"#/properties/duration_ms/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs8){ +if((typeof data3 == "number") && (isFinite(data3))){ +if(data3 > 60000 || isNaN(data3)){ +validate219.errors = [{instancePath:instancePath+"/duration_ms",schemaPath:"#/properties/duration_ms/maximum",keyword:"maximum",params:{comparison: "<=", limit: 60000}}]; +return false; +} +else { +if(data3 < 1 || isNaN(data3)){ +validate219.errors = [{instancePath:instancePath+"/duration_ms",schemaPath:"#/properties/duration_ms/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1}}]; +return false; +} +} +} +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.attack_ms !== undefined && func0.call(data, "attack_ms")){ +let data4 = data.attack_ms; +const _errs10 = errors; +if(!(((typeof data4 == "number") && (!(data4 % 1) && !isNaN(data4))) && (isFinite(data4)))){ +validate219.errors = [{instancePath:instancePath+"/attack_ms",schemaPath:"#/properties/attack_ms/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs10){ +if((typeof data4 == "number") && (isFinite(data4))){ +if(data4 > 60000 || isNaN(data4)){ +validate219.errors = [{instancePath:instancePath+"/attack_ms",schemaPath:"#/properties/attack_ms/maximum",keyword:"maximum",params:{comparison: "<=", limit: 60000}}]; +return false; +} +else { +if(data4 < 0 || isNaN(data4)){ +validate219.errors = [{instancePath:instancePath+"/attack_ms",schemaPath:"#/properties/attack_ms/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +} +var valid0 = _errs10 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.gain !== undefined && func0.call(data, "gain")){ +let data5 = data.gain; +const _errs12 = errors; +if(errors === _errs12){ +if((typeof data5 == "number") && (isFinite(data5))){ +if(data5 > 1 || isNaN(data5)){ +validate219.errors = [{instancePath:instancePath+"/gain",schemaPath:"#/properties/gain/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1}}]; +return false; +} +else { +if(data5 < 0 || isNaN(data5)){ +validate219.errors = [{instancePath:instancePath+"/gain",schemaPath:"#/properties/gain/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +else { +validate219.errors = [{instancePath:instancePath+"/gain",schemaPath:"#/properties/gain/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs12 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.pan !== undefined && func0.call(data, "pan")){ +let data6 = data.pan; +const _errs14 = errors; +if(errors === _errs14){ +if((typeof data6 == "number") && (isFinite(data6))){ +if(data6 > 1 || isNaN(data6)){ +validate219.errors = [{instancePath:instancePath+"/pan",schemaPath:"#/properties/pan/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1}}]; +return false; +} +else { +if(data6 < -1 || isNaN(data6)){ +validate219.errors = [{instancePath:instancePath+"/pan",schemaPath:"#/properties/pan/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1}}]; +return false; +} +} +} +else { +validate219.errors = [{instancePath:instancePath+"/pan",schemaPath:"#/properties/pan/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs14 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.detune_cents !== undefined && func0.call(data, "detune_cents")){ +let data7 = data.detune_cents; +const _errs16 = errors; +if(errors === _errs16){ +if((typeof data7 == "number") && (isFinite(data7))){ +if(data7 > 1200 || isNaN(data7)){ +validate219.errors = [{instancePath:instancePath+"/detune_cents",schemaPath:"#/properties/detune_cents/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1200}}]; +return false; +} +else { +if(data7 < -1200 || isNaN(data7)){ +validate219.errors = [{instancePath:instancePath+"/detune_cents",schemaPath:"#/properties/detune_cents/minimum",keyword:"minimum",params:{comparison: ">=", limit: -1200}}]; +return false; +} +} +} +else { +validate219.errors = [{instancePath:instancePath+"/detune_cents",schemaPath:"#/properties/detune_cents/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid0 = _errs16 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.waveform !== undefined && func0.call(data, "waveform")){ +const _errs18 = errors; +if("sine" !== data.waveform){ +validate219.errors = [{instancePath:instancePath+"/waveform",schemaPath:"#/properties/waveform/const",keyword:"const",params:{allowedValue: "sine"}}]; +return false; +} +var valid0 = _errs18 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +} +} +else { +validate219.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate219.errors = vErrors; +return errors === 0; +} +validate219.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate218(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate218.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((((((data.id === undefined) || (!(func0.call(data, "id")))) && (missing0 = "id")) || (((data.at_ms === undefined) || (!(func0.call(data, "at_ms")))) && (missing0 = "at_ms"))) || (((data.kind === undefined) || (!(func0.call(data, "kind")))) && (missing0 = "kind"))) || (((data.source === undefined) || (!(func0.call(data, "source")))) && (missing0 = "source"))) || (((data.source_id === undefined) || (!(func0.call(data, "source_id")))) && (missing0 = "source_id"))) || (((data.label === undefined) || (!(func0.call(data, "label")))) && (missing0 = "label"))) || (((data.mapping === undefined) || (!(func0.call(data, "mapping")))) && (missing0 = "mapping"))) || (((data.voices === undefined) || (!(func0.call(data, "voices")))) && (missing0 = "voices"))){ +validate218.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"id":{"type":"string","minLength":1,"maxLength":260},"at_ms":{"type":"integer","minimum":0,"maximum":1800000},"kind":{"enum":["marker","quake","beacon","wind","motif"]},"source":{"enum":["session","usgs","nist","weather","today","legacy"]},"source_id":{"type":"string","minLength":1,"maxLength":200},"label":{"type":"string","minLength":1,"maxLength":500},"mapping":{"enum":["sortilune.symphony-mapping/v1","legacy-approximate"]},"voices":{"type":"array","maxItems":32,"items":{"$ref":"#/$defs/voice"}},"modulation":{"type":"object","additionalProperties":false,"required":["wind_mps","filter_hz"],"properties":{"wind_mps":{"type":"number","minimum":0,"maximum":100},"filter_hz":{"type":"number","minimum":20,"maximum":24000}}},"location":{"type":"object","additionalProperties":false,"required":["lat","lon","magnitude"],"properties":{"lat":{"type":"number","minimum":-90,"maximum":90},"lon":{"type":"number","minimum":-180,"maximum":180},"magnitude":{"type":"number","minimum":-10,"maximum":20}}}}, key0))){ +validate218.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.id !== undefined && func0.call(data, "id")){ +let data0 = data.id; +const _errs2 = errors; +if(errors === _errs2){ +if(typeof data0 === "string"){ +if(func68(data0) > 260){ +validate218.errors = [{instancePath:instancePath+"/id",schemaPath:"#/properties/id/maxLength",keyword:"maxLength",params:{limit: 260}}]; +return false; +} +else { +if(func68(data0) < 1){ +validate218.errors = [{instancePath:instancePath+"/id",schemaPath:"#/properties/id/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/id",schemaPath:"#/properties/id/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.at_ms !== undefined && func0.call(data, "at_ms")){ +let data1 = data.at_ms; +const _errs4 = errors; +if(!(((typeof data1 == "number") && (!(data1 % 1) && !isNaN(data1))) && (isFinite(data1)))){ +validate218.errors = [{instancePath:instancePath+"/at_ms",schemaPath:"#/properties/at_ms/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs4){ +if((typeof data1 == "number") && (isFinite(data1))){ +if(data1 > 1800000 || isNaN(data1)){ +validate218.errors = [{instancePath:instancePath+"/at_ms",schemaPath:"#/properties/at_ms/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1800000}}]; +return false; +} +else { +if(data1 < 0 || isNaN(data1)){ +validate218.errors = [{instancePath:instancePath+"/at_ms",schemaPath:"#/properties/at_ms/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.kind !== undefined && func0.call(data, "kind")){ +let data2 = data.kind; +const _errs6 = errors; +if(!(((((data2 === "marker") || (data2 === "quake")) || (data2 === "beacon")) || (data2 === "wind")) || (data2 === "motif"))){ +validate218.errors = [{instancePath:instancePath+"/kind",schemaPath:"#/properties/kind/enum",keyword:"enum",params:{allowedValues: ["marker","quake","beacon","wind","motif"]}}]; +return false; +} +var valid0 = _errs6 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.source !== undefined && func0.call(data, "source")){ +let data3 = data.source; +const _errs7 = errors; +if(!((((((data3 === "session") || (data3 === "usgs")) || (data3 === "nist")) || (data3 === "weather")) || (data3 === "today")) || (data3 === "legacy"))){ +validate218.errors = [{instancePath:instancePath+"/source",schemaPath:"#/properties/source/enum",keyword:"enum",params:{allowedValues: ["session","usgs","nist","weather","today","legacy"]}}]; +return false; +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.source_id !== undefined && func0.call(data, "source_id")){ +let data4 = data.source_id; +const _errs8 = errors; +if(errors === _errs8){ +if(typeof data4 === "string"){ +if(func68(data4) > 200){ +validate218.errors = [{instancePath:instancePath+"/source_id",schemaPath:"#/properties/source_id/maxLength",keyword:"maxLength",params:{limit: 200}}]; +return false; +} +else { +if(func68(data4) < 1){ +validate218.errors = [{instancePath:instancePath+"/source_id",schemaPath:"#/properties/source_id/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/source_id",schemaPath:"#/properties/source_id/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.label !== undefined && func0.call(data, "label")){ +let data5 = data.label; +const _errs10 = errors; +if(errors === _errs10){ +if(typeof data5 === "string"){ +if(func68(data5) > 500){ +validate218.errors = [{instancePath:instancePath+"/label",schemaPath:"#/properties/label/maxLength",keyword:"maxLength",params:{limit: 500}}]; +return false; +} +else { +if(func68(data5) < 1){ +validate218.errors = [{instancePath:instancePath+"/label",schemaPath:"#/properties/label/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/label",schemaPath:"#/properties/label/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid0 = _errs10 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.mapping !== undefined && func0.call(data, "mapping")){ +let data6 = data.mapping; +const _errs12 = errors; +if(!((data6 === "sortilune.symphony-mapping/v1") || (data6 === "legacy-approximate"))){ +validate218.errors = [{instancePath:instancePath+"/mapping",schemaPath:"#/properties/mapping/enum",keyword:"enum",params:{allowedValues: ["sortilune.symphony-mapping/v1","legacy-approximate"]}}]; +return false; +} +var valid0 = _errs12 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.voices !== undefined && func0.call(data, "voices")){ +let data7 = data.voices; +const _errs13 = errors; +if(errors === _errs13){ +if(Array.isArray(data7)){ +if(data7.length > 32){ +validate218.errors = [{instancePath:instancePath+"/voices",schemaPath:"#/properties/voices/maxItems",keyword:"maxItems",params:{limit: 32}}]; +return false; +} +else { +var valid1 = true; +const len0 = data7.length; +for(let i0=0; i0 100 || isNaN(data10)){ +validate218.errors = [{instancePath:instancePath+"/modulation/wind_mps",schemaPath:"#/properties/modulation/properties/wind_mps/maximum",keyword:"maximum",params:{comparison: "<=", limit: 100}}]; +return false; +} +else { +if(data10 < 0 || isNaN(data10)){ +validate218.errors = [{instancePath:instancePath+"/modulation/wind_mps",schemaPath:"#/properties/modulation/properties/wind_mps/minimum",keyword:"minimum",params:{comparison: ">=", limit: 0}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/modulation/wind_mps",schemaPath:"#/properties/modulation/properties/wind_mps/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid2 = _errs19 === errors; +} +else { +var valid2 = true; +} +if(valid2){ +if(data9.filter_hz !== undefined && func0.call(data9, "filter_hz")){ +let data11 = data9.filter_hz; +const _errs21 = errors; +if(errors === _errs21){ +if((typeof data11 == "number") && (isFinite(data11))){ +if(data11 > 24000 || isNaN(data11)){ +validate218.errors = [{instancePath:instancePath+"/modulation/filter_hz",schemaPath:"#/properties/modulation/properties/filter_hz/maximum",keyword:"maximum",params:{comparison: "<=", limit: 24000}}]; +return false; +} +else { +if(data11 < 20 || isNaN(data11)){ +validate218.errors = [{instancePath:instancePath+"/modulation/filter_hz",schemaPath:"#/properties/modulation/properties/filter_hz/minimum",keyword:"minimum",params:{comparison: ">=", limit: 20}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/modulation/filter_hz",schemaPath:"#/properties/modulation/properties/filter_hz/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid2 = _errs21 === errors; +} +else { +var valid2 = true; +} +} +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/modulation",schemaPath:"#/properties/modulation/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs16 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.location !== undefined && func0.call(data, "location")){ +let data12 = data.location; +const _errs23 = errors; +if(errors === _errs23){ +if(data12 && typeof data12 == "object" && !Array.isArray(data12)){ +let missing2; +if(((((data12.lat === undefined) || (!(func0.call(data12, "lat")))) && (missing2 = "lat")) || (((data12.lon === undefined) || (!(func0.call(data12, "lon")))) && (missing2 = "lon"))) || (((data12.magnitude === undefined) || (!(func0.call(data12, "magnitude")))) && (missing2 = "magnitude"))){ +validate218.errors = [{instancePath:instancePath+"/location",schemaPath:"#/properties/location/required",keyword:"required",params:{missingProperty: missing2}}]; +return false; +} +else { +const _errs25 = errors; +for(const key2 of Object.keys(data12)){ +if(!(((key2 === "lat") || (key2 === "lon")) || (key2 === "magnitude"))){ +validate218.errors = [{instancePath:instancePath+"/location",schemaPath:"#/properties/location/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2}}]; +return false; +break; +} +} +if(_errs25 === errors){ +if(data12.lat !== undefined && func0.call(data12, "lat")){ +let data13 = data12.lat; +const _errs26 = errors; +if(errors === _errs26){ +if((typeof data13 == "number") && (isFinite(data13))){ +if(data13 > 90 || isNaN(data13)){ +validate218.errors = [{instancePath:instancePath+"/location/lat",schemaPath:"#/properties/location/properties/lat/maximum",keyword:"maximum",params:{comparison: "<=", limit: 90}}]; +return false; +} +else { +if(data13 < -90 || isNaN(data13)){ +validate218.errors = [{instancePath:instancePath+"/location/lat",schemaPath:"#/properties/location/properties/lat/minimum",keyword:"minimum",params:{comparison: ">=", limit: -90}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/location/lat",schemaPath:"#/properties/location/properties/lat/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid3 = _errs26 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data12.lon !== undefined && func0.call(data12, "lon")){ +let data14 = data12.lon; +const _errs28 = errors; +if(errors === _errs28){ +if((typeof data14 == "number") && (isFinite(data14))){ +if(data14 > 180 || isNaN(data14)){ +validate218.errors = [{instancePath:instancePath+"/location/lon",schemaPath:"#/properties/location/properties/lon/maximum",keyword:"maximum",params:{comparison: "<=", limit: 180}}]; +return false; +} +else { +if(data14 < -180 || isNaN(data14)){ +validate218.errors = [{instancePath:instancePath+"/location/lon",schemaPath:"#/properties/location/properties/lon/minimum",keyword:"minimum",params:{comparison: ">=", limit: -180}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/location/lon",schemaPath:"#/properties/location/properties/lon/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid3 = _errs28 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data12.magnitude !== undefined && func0.call(data12, "magnitude")){ +let data15 = data12.magnitude; +const _errs30 = errors; +if(errors === _errs30){ +if((typeof data15 == "number") && (isFinite(data15))){ +if(data15 > 20 || isNaN(data15)){ +validate218.errors = [{instancePath:instancePath+"/location/magnitude",schemaPath:"#/properties/location/properties/magnitude/maximum",keyword:"maximum",params:{comparison: "<=", limit: 20}}]; +return false; +} +else { +if(data15 < -10 || isNaN(data15)){ +validate218.errors = [{instancePath:instancePath+"/location/magnitude",schemaPath:"#/properties/location/properties/magnitude/minimum",keyword:"minimum",params:{comparison: ">=", limit: -10}}]; +return false; +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/location/magnitude",schemaPath:"#/properties/location/properties/magnitude/type",keyword:"type",params:{type: "number"}}]; +return false; +} +} +var valid3 = _errs30 === errors; +} +else { +var valid3 = true; +} +} +} +} +} +} +else { +validate218.errors = [{instancePath:instancePath+"/location",schemaPath:"#/properties/location/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs23 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +} +} +} +else { +validate218.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate218.errors = vErrors; +return errors === 0; +} +validate218.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + + +function validate213(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/symphony-score/v1" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate213.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if((((((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing0 = "schema_version"))) || (((data.synthesis === undefined) || (!(func0.call(data, "synthesis")))) && (missing0 = "synthesis"))) || (((data.exact === undefined) || (!(func0.call(data, "exact")))) && (missing0 = "exact"))) || (((data.started_at === undefined) || (!(func0.call(data, "started_at")))) && (missing0 = "started_at"))) || (((data.duration_ms === undefined) || (!(func0.call(data, "duration_ms")))) && (missing0 = "duration_ms"))) || (((data.atmosphere === undefined) || (!(func0.call(data, "atmosphere")))) && (missing0 = "atmosphere"))) || (((data.events === undefined) || (!(func0.call(data, "events")))) && (missing0 = "events"))){ +validate213.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!((((((((key0 === "schema") || (key0 === "schema_version")) || (key0 === "synthesis")) || (key0 === "exact")) || (key0 === "started_at")) || (key0 === "duration_ms")) || (key0 === "atmosphere")) || (key0 === "events"))){ +validate213.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if("sortilune.symphony-score" !== data.schema){ +validate213.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.symphony-score"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs3 = errors; +if(1 !== data.schema_version){ +validate213.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 1}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.synthesis !== undefined && func0.call(data, "synthesis")){ +let data2 = data.synthesis; +const _errs4 = errors; +if(!((data2 === "sortilune.symphony-synthesis/v1") || (data2 === "legacy-approximate"))){ +validate213.errors = [{instancePath:instancePath+"/synthesis",schemaPath:"#/properties/synthesis/enum",keyword:"enum",params:{allowedValues: ["sortilune.symphony-synthesis/v1","legacy-approximate"]}}]; +return false; +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.exact !== undefined && func0.call(data, "exact")){ +const _errs5 = errors; +if(typeof data.exact !== "boolean"){ +validate213.errors = [{instancePath:instancePath+"/exact",schemaPath:"#/properties/exact/type",keyword:"type",params:{type: "boolean"}}]; +return false; +} +var valid0 = _errs5 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.started_at !== undefined && func0.call(data, "started_at")){ +const _errs7 = errors; +if(!(validate214(data.started_at, {instancePath:instancePath+"/started_at",parentData:data,parentDataProperty:"started_at",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate214.errors : vErrors.concat(validate214.errors); +errors = vErrors.length; +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.duration_ms !== undefined && func0.call(data, "duration_ms")){ +let data5 = data.duration_ms; +const _errs8 = errors; +if(!(((typeof data5 == "number") && (!(data5 % 1) && !isNaN(data5))) && (isFinite(data5)))){ +validate213.errors = [{instancePath:instancePath+"/duration_ms",schemaPath:"#/properties/duration_ms/type",keyword:"type",params:{type: "integer"}}]; +return false; +} +if(errors === _errs8){ +if((typeof data5 == "number") && (isFinite(data5))){ +if(data5 > 1800000 || isNaN(data5)){ +validate213.errors = [{instancePath:instancePath+"/duration_ms",schemaPath:"#/properties/duration_ms/maximum",keyword:"maximum",params:{comparison: "<=", limit: 1800000}}]; +return false; +} +else { +if(data5 < 1000 || isNaN(data5)){ +validate213.errors = [{instancePath:instancePath+"/duration_ms",schemaPath:"#/properties/duration_ms/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1000}}]; +return false; +} +} +} +} +var valid0 = _errs8 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.atmosphere !== undefined && func0.call(data, "atmosphere")){ +const _errs10 = errors; +if(!(validate216(data.atmosphere, {instancePath:instancePath+"/atmosphere",parentData:data,parentDataProperty:"atmosphere",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate216.errors : vErrors.concat(validate216.errors); +errors = vErrors.length; +} +var valid0 = _errs10 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.events !== undefined && func0.call(data, "events")){ +let data7 = data.events; +const _errs11 = errors; +if(errors === _errs11){ +if(Array.isArray(data7)){ +if(data7.length > 500){ +validate213.errors = [{instancePath:instancePath+"/events",schemaPath:"#/properties/events/maxItems",keyword:"maxItems",params:{limit: 500}}]; +return false; +} +else { +var valid1 = true; +const len0 = data7.length; +for(let i0=0; i0 200){ +validate222.errors = [{instancePath:instancePath+"/source/id",schemaPath:"#/properties/source/properties/id/maxLength",keyword:"maxLength",params:{limit: 200}}]; +return false; +} +else { +if(func68(data6) < 1){ +validate222.errors = [{instancePath:instancePath+"/source/id",schemaPath:"#/properties/source/properties/id/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate222.errors = [{instancePath:instancePath+"/source/id",schemaPath:"#/properties/source/properties/id/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid1 = _errs11 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data4.title !== undefined && func0.call(data4, "title")){ +let data7 = data4.title; +const _errs13 = errors; +if(errors === _errs13){ +if(typeof data7 === "string"){ +if(func68(data7) > 320){ +validate222.errors = [{instancePath:instancePath+"/source/title",schemaPath:"#/properties/source/properties/title/maxLength",keyword:"maxLength",params:{limit: 320}}]; +return false; +} +else { +if(func68(data7) < 1){ +validate222.errors = [{instancePath:instancePath+"/source/title",schemaPath:"#/properties/source/properties/title/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate222.errors = [{instancePath:instancePath+"/source/title",schemaPath:"#/properties/source/properties/title/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid1 = _errs13 === errors; +} +else { +var valid1 = true; +} +} +} +} +} +} +else { +validate222.errors = [{instancePath:instancePath+"/source",schemaPath:"#/properties/source/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs7 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.limitations !== undefined && func0.call(data, "limitations")){ +let data8 = data.limitations; +const _errs15 = errors; +if(errors === _errs15){ +if(Array.isArray(data8)){ +if(data8.length > 8){ +validate222.errors = [{instancePath:instancePath+"/limitations",schemaPath:"#/properties/limitations/maxItems",keyword:"maxItems",params:{limit: 8}}]; +return false; +} +else { +if(data8.length < 1){ +validate222.errors = [{instancePath:instancePath+"/limitations",schemaPath:"#/properties/limitations/minItems",keyword:"minItems",params:{limit: 1}}]; +return false; +} +else { +var valid2 = true; +const len0 = data8.length; +for(let i0=0; i0 400){ +validate222.errors = [{instancePath:instancePath+"/limitations/" + i0,schemaPath:"#/properties/limitations/items/maxLength",keyword:"maxLength",params:{limit: 400}}]; +return false; +} +else { +if(func68(data9) < 1){ +validate222.errors = [{instancePath:instancePath+"/limitations/" + i0,schemaPath:"#/properties/limitations/items/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +} +} +else { +validate222.errors = [{instancePath:instancePath+"/limitations/" + i0,schemaPath:"#/properties/limitations/items/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid2 = _errs17 === errors; +if(!valid2){ +break; +} +} +} +} +} +else { +validate222.errors = [{instancePath:instancePath+"/limitations",schemaPath:"#/properties/limitations/type",keyword:"type",params:{type: "array"}}]; +return false; +} +} +var valid0 = _errs15 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.integrity !== undefined && func0.call(data, "integrity")){ +let data10 = data.integrity; +const _errs19 = errors; +if(errors === _errs19){ +if(data10 && typeof data10 == "object" && !Array.isArray(data10)){ +let missing2; +if(((((data10.algorithm === undefined) || (!(func0.call(data10, "algorithm")))) && (missing2 = "algorithm")) || (((data10.canonicalization === undefined) || (!(func0.call(data10, "canonicalization")))) && (missing2 = "canonicalization"))) || (((data10.value === undefined) || (!(func0.call(data10, "value")))) && (missing2 = "value"))){ +validate222.errors = [{instancePath:instancePath+"/integrity",schemaPath:"#/properties/integrity/required",keyword:"required",params:{missingProperty: missing2}}]; +return false; +} +else { +const _errs21 = errors; +for(const key2 of Object.keys(data10)){ +if(!(((key2 === "algorithm") || (key2 === "canonicalization")) || (key2 === "value"))){ +validate222.errors = [{instancePath:instancePath+"/integrity",schemaPath:"#/properties/integrity/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key2}}]; +return false; +break; +} +} +if(_errs21 === errors){ +if(data10.algorithm !== undefined && func0.call(data10, "algorithm")){ +const _errs22 = errors; +if("SHA-256" !== data10.algorithm){ +validate222.errors = [{instancePath:instancePath+"/integrity/algorithm",schemaPath:"#/properties/integrity/properties/algorithm/const",keyword:"const",params:{allowedValue: "SHA-256"}}]; +return false; +} +var valid3 = _errs22 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data10.canonicalization !== undefined && func0.call(data10, "canonicalization")){ +const _errs23 = errors; +if("sortilune.canonical-json/v1" !== data10.canonicalization){ +validate222.errors = [{instancePath:instancePath+"/integrity/canonicalization",schemaPath:"#/properties/integrity/properties/canonicalization/const",keyword:"const",params:{allowedValue: "sortilune.canonical-json/v1"}}]; +return false; +} +var valid3 = _errs23 === errors; +} +else { +var valid3 = true; +} +if(valid3){ +if(data10.value !== undefined && func0.call(data10, "value")){ +let data13 = data10.value; +const _errs24 = errors; +if(errors === _errs24){ +if(typeof data13 === "string"){ +if(!pattern7.test(data13)){ +validate222.errors = [{instancePath:instancePath+"/integrity/value",schemaPath:"#/properties/integrity/properties/value/pattern",keyword:"pattern",params:{pattern: "^[0-9a-f]{64}$"}}]; +return false; +} +} +else { +validate222.errors = [{instancePath:instancePath+"/integrity/value",schemaPath:"#/properties/integrity/properties/value/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid3 = _errs24 === errors; +} +else { +var valid3 = true; +} +} +} +} +} +} +else { +validate222.errors = [{instancePath:instancePath+"/integrity",schemaPath:"#/properties/integrity/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs19 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +else { +validate222.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate222.errors = vErrors; +return errors === 0; +} +validate222.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; + +export const validateSettingsV2 = validate223; + +function validate224(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +let vErrors = null; +let errors = 0; +const evaluated0 = validate224.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(typeof data === "string"){ +if(func68(data) > 96){ +validate224.errors = [{instancePath,schemaPath:"#/maxLength",keyword:"maxLength",params:{limit: 96}}]; +return false; +} +else { +if(func68(data) < 1){ +validate224.errors = [{instancePath,schemaPath:"#/minLength",keyword:"minLength",params:{limit: 1}}]; +return false; +} +else { +if(!pattern3.test(data)){ +validate224.errors = [{instancePath,schemaPath:"#/pattern",keyword:"pattern",params:{pattern: "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$"}}]; +return false; +} +} +} +} +else { +validate224.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +validate224.errors = vErrors; +return errors === 0; +} +validate224.evaluated = {"dynamicProps":false,"dynamicItems":false}; + + +function validate223(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){ +/*# sourceURL="https://sortilune.app/schemas/settings/v2" */; +let vErrors = null; +let errors = 0; +const evaluated0 = validate223.evaluated; +if(evaluated0.dynamicProps){ +evaluated0.props = undefined; +} +if(evaluated0.dynamicItems){ +evaluated0.items = undefined; +} +if(errors === 0){ +if(data && typeof data == "object" && !Array.isArray(data)){ +let missing0; +if(((((((((data.schema === undefined) || (!(func0.call(data, "schema")))) && (missing0 = "schema")) || (((data.schema_version === undefined) || (!(func0.call(data, "schema_version")))) && (missing0 = "schema_version"))) || (((data.route === undefined) || (!(func0.call(data, "route")))) && (missing0 = "route"))) || (((data.theme === undefined) || (!(func0.call(data, "theme")))) && (missing0 = "theme"))) || (((data.entropy === undefined) || (!(func0.call(data, "entropy")))) && (missing0 = "entropy"))) || (((data.visual === undefined) || (!(func0.call(data, "visual")))) && (missing0 = "visual"))) || (((data.chambers === undefined) || (!(func0.call(data, "chambers")))) && (missing0 = "chambers"))){ +validate223.errors = [{instancePath,schemaPath:"#/required",keyword:"required",params:{missingProperty: missing0}}]; +return false; +} +else { +const _errs1 = errors; +for(const key0 of Object.keys(data)){ +if(!(func0.call({"schema":{"const":"sortilune.settings"},"schema_version":{"const":2},"route":{"type":"object","required":["destination","params"],"properties":{"destination":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"params":{"type":"object","maxProperties":16,"propertyNames":{"maxLength":64},"additionalProperties":{"type":"string","maxLength":256}}},"additionalProperties":false},"navigation":{"type":"object","required":["rail_mode"],"properties":{"rail_mode":{"enum":["auto","expanded","compact"]}},"additionalProperties":false},"theme":{"enum":["cosmic-dark","cosmic-light","high-contrast"]},"entropy":{"type":"object","required":["preferred_source","enabled_sources"],"properties":{"preferred_source":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"enabled_sources":{"type":"object","minProperties":1,"maxProperties":32,"propertyNames":{"$ref":"https://sortilune.app/schemas/common/v1#/$defs/identifier"},"additionalProperties":{"type":"boolean"}}},"additionalProperties":false},"visual":{"type":"object","required":["starfield","reduce_motion"],"properties":{"starfield":{"type":"boolean"},"reduce_motion":{"enum":["system","reduce","allow"]}},"additionalProperties":false},"archive":{"type":"object","required":["search_diary_body","on_this_day"],"properties":{"search_diary_body":{"type":"boolean"},"on_this_day":{"type":"boolean"}},"additionalProperties":false},"chambers":{"type":"object","required":["last_used_deck"],"properties":{"last_used_deck":{"enum":["tarot","i-ching","runes","cosmic"]}},"additionalProperties":false}}, key0))){ +validate223.errors = [{instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0}}]; +return false; +break; +} +} +if(_errs1 === errors){ +if(data.schema !== undefined && func0.call(data, "schema")){ +const _errs2 = errors; +if("sortilune.settings" !== data.schema){ +validate223.errors = [{instancePath:instancePath+"/schema",schemaPath:"#/properties/schema/const",keyword:"const",params:{allowedValue: "sortilune.settings"}}]; +return false; +} +var valid0 = _errs2 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.schema_version !== undefined && func0.call(data, "schema_version")){ +const _errs3 = errors; +if(2 !== data.schema_version){ +validate223.errors = [{instancePath:instancePath+"/schema_version",schemaPath:"#/properties/schema_version/const",keyword:"const",params:{allowedValue: 2}}]; +return false; +} +var valid0 = _errs3 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.route !== undefined && func0.call(data, "route")){ +let data2 = data.route; +const _errs4 = errors; +if(errors === _errs4){ +if(data2 && typeof data2 == "object" && !Array.isArray(data2)){ +let missing1; +if((((data2.destination === undefined) || (!(func0.call(data2, "destination")))) && (missing1 = "destination")) || (((data2.params === undefined) || (!(func0.call(data2, "params")))) && (missing1 = "params"))){ +validate223.errors = [{instancePath:instancePath+"/route",schemaPath:"#/properties/route/required",keyword:"required",params:{missingProperty: missing1}}]; +return false; +} +else { +const _errs6 = errors; +for(const key1 of Object.keys(data2)){ +if(!((key1 === "destination") || (key1 === "params"))){ +validate223.errors = [{instancePath:instancePath+"/route",schemaPath:"#/properties/route/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key1}}]; +return false; +break; +} +} +if(_errs6 === errors){ +if(data2.destination !== undefined && func0.call(data2, "destination")){ +const _errs7 = errors; +if(!(validate224(data2.destination, {instancePath:instancePath+"/route/destination",parentData:data2,parentDataProperty:"destination",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate224.errors : vErrors.concat(validate224.errors); +errors = vErrors.length; +} +var valid1 = _errs7 === errors; +} +else { +var valid1 = true; +} +if(valid1){ +if(data2.params !== undefined && func0.call(data2, "params")){ +let data4 = data2.params; +const _errs8 = errors; +if(errors === _errs8){ +if(data4 && typeof data4 == "object" && !Array.isArray(data4)){ +if(Object.keys(data4).length > 16){ +validate223.errors = [{instancePath:instancePath+"/route/params",schemaPath:"#/properties/route/properties/params/maxProperties",keyword:"maxProperties",params:{limit: 16}}]; +return false; +} +else { +for(const key2 of Object.keys(data4)){ +const _errs10 = errors; +if(typeof key2 === "string"){ +if(func68(key2) > 64){ +const err0 = {instancePath:instancePath+"/route/params",schemaPath:"#/properties/route/properties/params/propertyNames/maxLength",keyword:"maxLength",params:{limit: 64},propertyName:key2}; +if(vErrors === null){ +vErrors = [err0]; +} +else { +vErrors.push(err0); +} +errors++; +} +} +var valid2 = _errs10 === errors; +if(!valid2){ +const err1 = {instancePath:instancePath+"/route/params",schemaPath:"#/properties/route/properties/params/propertyNames",keyword:"propertyNames",params:{propertyName: key2}}; +if(vErrors === null){ +vErrors = [err1]; +} +else { +vErrors.push(err1); +} +errors++; +validate223.errors = vErrors; +return false; +break; +} +} +if(valid2){ +for(const key3 of Object.keys(data4)){ +let data5 = data4[key3]; +const _errs12 = errors; +if(errors === _errs12){ +if(typeof data5 === "string"){ +if(func68(data5) > 256){ +validate223.errors = [{instancePath:instancePath+"/route/params/" + key3.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/route/properties/params/additionalProperties/maxLength",keyword:"maxLength",params:{limit: 256}}]; +return false; +} +} +else { +validate223.errors = [{instancePath:instancePath+"/route/params/" + key3.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/route/properties/params/additionalProperties/type",keyword:"type",params:{type: "string"}}]; +return false; +} +} +var valid3 = _errs12 === errors; +if(!valid3){ +break; +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/route/params",schemaPath:"#/properties/route/properties/params/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid1 = _errs8 === errors; +} +else { +var valid1 = true; +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/route",schemaPath:"#/properties/route/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs4 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.navigation !== undefined && func0.call(data, "navigation")){ +let data6 = data.navigation; +const _errs14 = errors; +if(errors === _errs14){ +if(data6 && typeof data6 == "object" && !Array.isArray(data6)){ +let missing2; +if(((data6.rail_mode === undefined) || (!(func0.call(data6, "rail_mode")))) && (missing2 = "rail_mode")){ +validate223.errors = [{instancePath:instancePath+"/navigation",schemaPath:"#/properties/navigation/required",keyword:"required",params:{missingProperty: missing2}}]; +return false; +} +else { +const _errs16 = errors; +for(const key4 of Object.keys(data6)){ +if(!(key4 === "rail_mode")){ +validate223.errors = [{instancePath:instancePath+"/navigation",schemaPath:"#/properties/navigation/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key4}}]; +return false; +break; +} +} +if(_errs16 === errors){ +if(data6.rail_mode !== undefined && func0.call(data6, "rail_mode")){ +let data7 = data6.rail_mode; +if(!(((data7 === "auto") || (data7 === "expanded")) || (data7 === "compact"))){ +validate223.errors = [{instancePath:instancePath+"/navigation/rail_mode",schemaPath:"#/properties/navigation/properties/rail_mode/enum",keyword:"enum",params:{allowedValues: ["auto","expanded","compact"]}}]; +return false; +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/navigation",schemaPath:"#/properties/navigation/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs14 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.theme !== undefined && func0.call(data, "theme")){ +let data8 = data.theme; +const _errs18 = errors; +if(!(((data8 === "cosmic-dark") || (data8 === "cosmic-light")) || (data8 === "high-contrast"))){ +validate223.errors = [{instancePath:instancePath+"/theme",schemaPath:"#/properties/theme/enum",keyword:"enum",params:{allowedValues: ["cosmic-dark","cosmic-light","high-contrast"]}}]; +return false; +} +var valid0 = _errs18 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.entropy !== undefined && func0.call(data, "entropy")){ +let data9 = data.entropy; +const _errs19 = errors; +if(errors === _errs19){ +if(data9 && typeof data9 == "object" && !Array.isArray(data9)){ +let missing3; +if((((data9.preferred_source === undefined) || (!(func0.call(data9, "preferred_source")))) && (missing3 = "preferred_source")) || (((data9.enabled_sources === undefined) || (!(func0.call(data9, "enabled_sources")))) && (missing3 = "enabled_sources"))){ +validate223.errors = [{instancePath:instancePath+"/entropy",schemaPath:"#/properties/entropy/required",keyword:"required",params:{missingProperty: missing3}}]; +return false; +} +else { +const _errs21 = errors; +for(const key5 of Object.keys(data9)){ +if(!((key5 === "preferred_source") || (key5 === "enabled_sources"))){ +validate223.errors = [{instancePath:instancePath+"/entropy",schemaPath:"#/properties/entropy/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key5}}]; +return false; +break; +} +} +if(_errs21 === errors){ +if(data9.preferred_source !== undefined && func0.call(data9, "preferred_source")){ +const _errs22 = errors; +if(!(validate224(data9.preferred_source, {instancePath:instancePath+"/entropy/preferred_source",parentData:data9,parentDataProperty:"preferred_source",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate224.errors : vErrors.concat(validate224.errors); +errors = vErrors.length; +} +var valid5 = _errs22 === errors; +} +else { +var valid5 = true; +} +if(valid5){ +if(data9.enabled_sources !== undefined && func0.call(data9, "enabled_sources")){ +let data11 = data9.enabled_sources; +const _errs23 = errors; +if(errors === _errs23){ +if(data11 && typeof data11 == "object" && !Array.isArray(data11)){ +if(Object.keys(data11).length > 32){ +validate223.errors = [{instancePath:instancePath+"/entropy/enabled_sources",schemaPath:"#/properties/entropy/properties/enabled_sources/maxProperties",keyword:"maxProperties",params:{limit: 32}}]; +return false; +} +else { +if(Object.keys(data11).length < 1){ +validate223.errors = [{instancePath:instancePath+"/entropy/enabled_sources",schemaPath:"#/properties/entropy/properties/enabled_sources/minProperties",keyword:"minProperties",params:{limit: 1}}]; +return false; +} +else { +for(const key6 of Object.keys(data11)){ +const _errs25 = errors; +if(!(validate224(key6, {instancePath:instancePath+"/entropy/enabled_sources",parentData:data11,parentDataProperty:"enabled_sources",rootData,dynamicAnchors}))){ +vErrors = vErrors === null ? validate224.errors : vErrors.concat(validate224.errors); +errors = vErrors.length; +} +var valid6 = _errs25 === errors; +if(!valid6){ +const err2 = {instancePath:instancePath+"/entropy/enabled_sources",schemaPath:"#/properties/entropy/properties/enabled_sources/propertyNames",keyword:"propertyNames",params:{propertyName: key6}}; +if(vErrors === null){ +vErrors = [err2]; +} +else { +vErrors.push(err2); +} +errors++; +validate223.errors = vErrors; +return false; +break; +} +} +if(valid6){ +for(const key7 of Object.keys(data11)){ +const _errs27 = errors; +if(typeof data11[key7] !== "boolean"){ +validate223.errors = [{instancePath:instancePath+"/entropy/enabled_sources/" + key7.replace(/~/g, "~0").replace(/\//g, "~1"),schemaPath:"#/properties/entropy/properties/enabled_sources/additionalProperties/type",keyword:"type",params:{type: "boolean"}}]; +return false; +} +var valid7 = _errs27 === errors; +if(!valid7){ +break; +} +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/entropy/enabled_sources",schemaPath:"#/properties/entropy/properties/enabled_sources/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid5 = _errs23 === errors; +} +else { +var valid5 = true; +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/entropy",schemaPath:"#/properties/entropy/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs19 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.visual !== undefined && func0.call(data, "visual")){ +let data13 = data.visual; +const _errs29 = errors; +if(errors === _errs29){ +if(data13 && typeof data13 == "object" && !Array.isArray(data13)){ +let missing4; +if((((data13.starfield === undefined) || (!(func0.call(data13, "starfield")))) && (missing4 = "starfield")) || (((data13.reduce_motion === undefined) || (!(func0.call(data13, "reduce_motion")))) && (missing4 = "reduce_motion"))){ +validate223.errors = [{instancePath:instancePath+"/visual",schemaPath:"#/properties/visual/required",keyword:"required",params:{missingProperty: missing4}}]; +return false; +} +else { +const _errs31 = errors; +for(const key8 of Object.keys(data13)){ +if(!((key8 === "starfield") || (key8 === "reduce_motion"))){ +validate223.errors = [{instancePath:instancePath+"/visual",schemaPath:"#/properties/visual/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key8}}]; +return false; +break; +} +} +if(_errs31 === errors){ +if(data13.starfield !== undefined && func0.call(data13, "starfield")){ +const _errs32 = errors; +if(typeof data13.starfield !== "boolean"){ +validate223.errors = [{instancePath:instancePath+"/visual/starfield",schemaPath:"#/properties/visual/properties/starfield/type",keyword:"type",params:{type: "boolean"}}]; +return false; +} +var valid8 = _errs32 === errors; +} +else { +var valid8 = true; +} +if(valid8){ +if(data13.reduce_motion !== undefined && func0.call(data13, "reduce_motion")){ +let data15 = data13.reduce_motion; +const _errs34 = errors; +if(!(((data15 === "system") || (data15 === "reduce")) || (data15 === "allow"))){ +validate223.errors = [{instancePath:instancePath+"/visual/reduce_motion",schemaPath:"#/properties/visual/properties/reduce_motion/enum",keyword:"enum",params:{allowedValues: ["system","reduce","allow"]}}]; +return false; +} +var valid8 = _errs34 === errors; +} +else { +var valid8 = true; +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/visual",schemaPath:"#/properties/visual/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs29 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.archive !== undefined && func0.call(data, "archive")){ +let data16 = data.archive; +const _errs35 = errors; +if(errors === _errs35){ +if(data16 && typeof data16 == "object" && !Array.isArray(data16)){ +let missing5; +if((((data16.search_diary_body === undefined) || (!(func0.call(data16, "search_diary_body")))) && (missing5 = "search_diary_body")) || (((data16.on_this_day === undefined) || (!(func0.call(data16, "on_this_day")))) && (missing5 = "on_this_day"))){ +validate223.errors = [{instancePath:instancePath+"/archive",schemaPath:"#/properties/archive/required",keyword:"required",params:{missingProperty: missing5}}]; +return false; +} +else { +const _errs37 = errors; +for(const key9 of Object.keys(data16)){ +if(!((key9 === "search_diary_body") || (key9 === "on_this_day"))){ +validate223.errors = [{instancePath:instancePath+"/archive",schemaPath:"#/properties/archive/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key9}}]; +return false; +break; +} +} +if(_errs37 === errors){ +if(data16.search_diary_body !== undefined && func0.call(data16, "search_diary_body")){ +const _errs38 = errors; +if(typeof data16.search_diary_body !== "boolean"){ +validate223.errors = [{instancePath:instancePath+"/archive/search_diary_body",schemaPath:"#/properties/archive/properties/search_diary_body/type",keyword:"type",params:{type: "boolean"}}]; +return false; +} +var valid9 = _errs38 === errors; +} +else { +var valid9 = true; +} +if(valid9){ +if(data16.on_this_day !== undefined && func0.call(data16, "on_this_day")){ +const _errs40 = errors; +if(typeof data16.on_this_day !== "boolean"){ +validate223.errors = [{instancePath:instancePath+"/archive/on_this_day",schemaPath:"#/properties/archive/properties/on_this_day/type",keyword:"type",params:{type: "boolean"}}]; +return false; +} +var valid9 = _errs40 === errors; +} +else { +var valid9 = true; +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/archive",schemaPath:"#/properties/archive/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs35 === errors; +} +else { +var valid0 = true; +} +if(valid0){ +if(data.chambers !== undefined && func0.call(data, "chambers")){ +let data19 = data.chambers; +const _errs42 = errors; +if(errors === _errs42){ +if(data19 && typeof data19 == "object" && !Array.isArray(data19)){ +let missing6; +if(((data19.last_used_deck === undefined) || (!(func0.call(data19, "last_used_deck")))) && (missing6 = "last_used_deck")){ +validate223.errors = [{instancePath:instancePath+"/chambers",schemaPath:"#/properties/chambers/required",keyword:"required",params:{missingProperty: missing6}}]; +return false; +} +else { +const _errs44 = errors; +for(const key10 of Object.keys(data19)){ +if(!(key10 === "last_used_deck")){ +validate223.errors = [{instancePath:instancePath+"/chambers",schemaPath:"#/properties/chambers/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key10}}]; +return false; +break; +} +} +if(_errs44 === errors){ +if(data19.last_used_deck !== undefined && func0.call(data19, "last_used_deck")){ +let data20 = data19.last_used_deck; +if(!((((data20 === "tarot") || (data20 === "i-ching")) || (data20 === "runes")) || (data20 === "cosmic"))){ +validate223.errors = [{instancePath:instancePath+"/chambers/last_used_deck",schemaPath:"#/properties/chambers/properties/last_used_deck/enum",keyword:"enum",params:{allowedValues: ["tarot","i-ching","runes","cosmic"]}}]; +return false; +} +} +} +} +} +else { +validate223.errors = [{instancePath:instancePath+"/chambers",schemaPath:"#/properties/chambers/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +var valid0 = _errs42 === errors; +} +else { +var valid0 = true; +} +} +} +} +} +} +} +} +} +} +} +} +else { +validate223.errors = [{instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"}}]; +return false; +} +} +validate223.errors = vErrors; +return errors === 0; +} +validate223.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false}; diff --git a/src/schemas/v1/archive-annotations.schema.json b/src/schemas/v1/archive-annotations.schema.json new file mode 100644 index 0000000..6dc2fa0 --- /dev/null +++ b/src/schemas/v1/archive-annotations.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/archive-annotations/v1", + "title": "Sortilune archive annotations v1", + "type": "object", + "required": ["schema", "schema_version", "updated_at", "records", "collections"], + "properties": { + "schema": { "const": "sortilune.archive-annotations" }, + "schema_version": { "const": 1 }, + "updated_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "records": { + "type": "object", + "maxProperties": 25000, + "propertyNames": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "additionalProperties": { "$ref": "#/$defs/annotation" } + }, + "collections": { + "type": "object", + "maxProperties": 1000, + "propertyNames": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "additionalProperties": { "$ref": "#/$defs/collection" } + } + }, + "$defs": { + "annotation": { + "type": "object", + "required": ["tags", "favorite", "hidden", "collections", "updated_at"], + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 240 }, + "tags": { + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 64 } + }, + "favorite": { "type": "boolean" }, + "hidden": { "type": "boolean" }, + "collections": { + "type": "array", + "maxItems": 32, + "uniqueItems": true, + "items": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" } + }, + "updated_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" } + }, + "additionalProperties": false + }, + "collection": { + "type": "object", + "required": ["id", "name", "created_at", "updated_at"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 }, + "created_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "updated_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/src/schemas/v1/archive-record.schema.json b/src/schemas/v1/archive-record.schema.json new file mode 100644 index 0000000..dcb83f6 --- /dev/null +++ b/src/schemas/v1/archive-record.schema.json @@ -0,0 +1,74 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/archive-record/v1", + "title": "ArchiveRecord v1", + "type": "object", + "required": ["schema", "schema_version", "id", "chamber", "type", "created_at", "summary", "payload", "provenance", "relations", "assets"], + "properties": { + "schema": { "const": "sortilune.archive-record" }, + "schema_version": { "const": 1 }, + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "chamber": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "type": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "created_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "summary": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "payload": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" }, + "provenance": { + "type": "array", + "maxItems": 64, + "items": { "$ref": "https://sortilune.app/schemas/provenance/v1" } + }, + "relations": { + "type": "array", + "maxItems": 256, + "items": { "$ref": "https://sortilune.app/schemas/relation/v1" } + }, + "assets": { + "type": "array", + "maxItems": 256, + "items": { "$ref": "https://sortilune.app/schemas/asset-reference/v1" } + }, + "pack": { + "oneOf": [ + { + "type": "object", + "required": ["id", "version"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "version": { "type": "integer", "minimum": 1, "maximum": 2147483647 }, + "content_hash": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/sha256" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["id", "version", "digest", "item_id", "content_snapshot"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "version": { + "type": "string", + "minLength": 5, + "maxLength": 64, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" + }, + "digest": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/sha256" }, + "item_id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "content_snapshot": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" } + }, + "additionalProperties": false + } + ] + }, + "algorithm": { + "type": "object", + "required": ["id", "version"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "version": { "type": "integer", "minimum": 1, "maximum": 2147483647 }, + "parameters": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/src/schemas/v1/asset-reference.schema.json b/src/schemas/v1/asset-reference.schema.json new file mode 100644 index 0000000..36bcb32 --- /dev/null +++ b/src/schemas/v1/asset-reference.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/asset-reference/v1", + "title": "Asset reference v1", + "type": "object", + "required": ["id", "role", "media_type", "path", "sha256", "bytes"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "role": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "media_type": { "enum": ["image/svg+xml", "image/png", "application/json", "text/markdown", "audio/wav"] }, + "path": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/archiveRelativePath" }, + "sha256": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/sha256" }, + "bytes": { "type": "integer", "minimum": 0, "maximum": 26214400 }, + "width": { "type": "integer", "minimum": 1, "maximum": 16384 }, + "height": { "type": "integer", "minimum": 1, "maximum": 16384 } + }, + "additionalProperties": false +} diff --git a/src/schemas/v1/common.schema.json b/src/schemas/v1/common.schema.json new file mode 100644 index 0000000..55a13d6 --- /dev/null +++ b/src/schemas/v1/common.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/common/v1", + "title": "Sortilune common schema definitions", + "$defs": { + "stableId": { + "type": "string", + "format": "uuid" + }, + "rfc3339Timestamp": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "identifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$", + "minLength": 1, + "maxLength": 96 + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "archiveRelativePath": { + "type": "string", + "pattern": "^archive/(?!.*(?:^|/)\\.\\.?(?:/|$))[A-Za-z0-9][A-Za-z0-9._/-]{0,511}$", + "maxLength": 520 + }, + "boundedJson": { + "oneOf": [ + { "type": "null" }, + { "type": "boolean" }, + { "type": "string", "maxLength": 1000000 }, + { "type": "number" }, + { + "type": "array", + "maxItems": 10000, + "items": { "$ref": "#/$defs/boundedJson" } + }, + { + "type": "object", + "maxProperties": 1000, + "propertyNames": { "maxLength": 128 }, + "additionalProperties": { "$ref": "#/$defs/boundedJson" } + } + ] + } + } +} diff --git a/src/schemas/v1/daily-record.schema.json b/src/schemas/v1/daily-record.schema.json new file mode 100644 index 0000000..593f20f --- /dev/null +++ b/src/schemas/v1/daily-record.schema.json @@ -0,0 +1,130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/daily-record/v1", + "title": "DailyRecord v1", + "type": "object", + "required": ["schema", "schema_version", "id", "local_date", "time_zone", "utc_offset_minutes", "edition", "created_at", "source", "algorithm", "derivation", "outputs", "verification"], + "properties": { + "schema": { "const": "sortilune.daily-record" }, + "schema_version": { "const": 1 }, + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "local_date": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$" }, + "time_zone": { "type": "string", "minLength": 1, "maxLength": 128 }, + "utc_offset_minutes": { "type": "integer", "minimum": -1440, "maximum": 1440 }, + "edition": { "type": "integer", "minimum": 1, "maximum": 9999 }, + "created_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "source": { "$ref": "#/$defs/source" }, + "algorithm": { "$ref": "#/$defs/algorithm" }, + "derivation": { "$ref": "#/$defs/derivation" }, + "outputs": { "$ref": "#/$defs/outputs" }, + "verification": { "$ref": "#/$defs/verification" } + }, + "$defs": { + "sha512": { "type": "string", "pattern": "^[0-9a-f]{128}$" }, + "hexBytes": { "type": "string", "pattern": "^(?:[0-9a-f]{2})+$", "maxLength": 16320 }, + "fact": { + "type": "object", + "required": ["status", "code", "detail"], + "properties": { + "status": { "enum": ["verified", "failed", "unknown", "unsupported"] }, + "code": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "detail": { "type": "string", "minLength": 1, "maxLength": 512 } + }, + "additionalProperties": false + }, + "verification": { + "type": "object", + "required": ["schema", "output_hash", "certificate_digest", "signature", "live_refetch", "adjacent_links"], + "properties": { + "schema": { "$ref": "#/$defs/fact" }, + "output_hash": { "$ref": "#/$defs/fact" }, + "certificate_digest": { "$ref": "#/$defs/fact" }, + "signature": { "$ref": "#/$defs/fact" }, + "live_refetch": { "$ref": "#/$defs/fact" }, + "adjacent_links": { "$ref": "#/$defs/fact" } + }, + "additionalProperties": false + }, + "source": { + "type": "object", + "required": ["kind", "root_value", "public", "day_start", "day_end"], + "properties": { + "kind": { "enum": ["nist", "local"] }, + "root_value": { "$ref": "#/$defs/sha512" }, + "public": { "const": true }, + "day_start": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "day_end": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "nist": { + "type": "object", + "required": ["request_url", "requested_at", "requested_epoch_ms", "verifier_profile", "pulse", "certificate_pem"], + "properties": { + "request_url": { "type": "string", "format": "uri", "maxLength": 2048 }, + "requested_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "requested_epoch_ms": { "type": "integer", "minimum": 0, "maximum": 8640000000000000 }, + "verifier_profile": { "const": "nist-beacon-v2-api-2019" }, + "pulse": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" }, + "certificate_pem": { "type": "string", "minLength": 256, "maxLength": 32768 } + }, + "additionalProperties": false + }, + "local": { + "type": "object", + "required": ["generated_at", "generator"], + "properties": { + "generated_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "generator": { "const": "webcrypto.getrandomvalues" } + }, + "additionalProperties": false + } + }, + "oneOf": [ + { "required": ["nist"], "properties": { "kind": { "const": "nist" }, "nist": {}, "local": false } }, + { "required": ["local"], "properties": { "kind": { "const": "local" }, "local": {}, "nist": false } } + ], + "additionalProperties": false + }, + "algorithm": { + "type": "object", + "required": ["id", "version", "hkdf", "context"], + "properties": { + "id": { "const": "sortilune.today" }, + "version": { "const": 1 }, + "hkdf": { "const": "hkdf-sha-256" }, + "context": { "type": "string", "minLength": 1, "maxLength": 512 } + }, + "additionalProperties": false + }, + "stream": { + "type": "object", + "required": ["id", "info", "bytes"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "info": { "type": "string", "pattern": "^sortilune/today/v1/", "maxLength": 96 }, + "bytes": { "$ref": "#/$defs/hexBytes" } + }, + "additionalProperties": false + }, + "derivation": { + "type": "object", + "required": ["salt", "streams"], + "properties": { + "salt": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "streams": { "type": "array", "minItems": 9, "maxItems": 9, "items": { "$ref": "#/$defs/stream" } } + }, + "additionalProperties": false + }, + "outputs": { + "type": "object", + "required": ["oracle", "constraint", "diary", "canvas", "symphony"], + "properties": { + "oracle": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" }, + "constraint": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" }, + "diary": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" }, + "canvas": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" }, + "symphony": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/src/schemas/v1/pack.schema.json b/src/schemas/v1/pack.schema.json new file mode 100644 index 0000000..ebc6df6 --- /dev/null +++ b/src/schemas/v1/pack.schema.json @@ -0,0 +1,296 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/pack/v1", + "title": "Sortilune Pack v1", + "type": "object", + "required": ["schema", "schema_version", "pack_id", "version", "kind", "name", "author", "attribution", "license", "content"], + "properties": { + "schema": { "const": "sortilune.pack" }, + "schema_version": { "const": 1 }, + "pack_id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "version": { "$ref": "#/$defs/semver" }, + "kind": { + "enum": ["oracle-deck", "constraints", "diary-prompts", "lottery-presets", "canvas-palettes"] + }, + "name": { "$ref": "#/$defs/name" }, + "description": { "type": "string", "maxLength": 1000 }, + "author": { + "type": "object", + "required": ["name"], + "properties": { "name": { "$ref": "#/$defs/name" } }, + "additionalProperties": false + }, + "attribution": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "license": { "$ref": "#/$defs/license" }, + "dependencies": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "required": ["pack_id", "version"], + "properties": { + "pack_id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "version": { "$ref": "#/$defs/semver" } + }, + "additionalProperties": false + } + }, + "content": {} + }, + "allOf": [ + { + "if": { "properties": { "kind": { "const": "oracle-deck" } }, "required": ["kind"] }, + "then": { "properties": { "content": { "$ref": "#/$defs/oracleDeck" } } } + }, + { + "if": { "properties": { "kind": { "const": "constraints" } }, "required": ["kind"] }, + "then": { "properties": { "content": { "$ref": "#/$defs/constraints" } } } + }, + { + "if": { "properties": { "kind": { "const": "diary-prompts" } }, "required": ["kind"] }, + "then": { "properties": { "content": { "$ref": "#/$defs/diaryPrompts" } } } + }, + { + "if": { "properties": { "kind": { "const": "lottery-presets" } }, "required": ["kind"] }, + "then": { "properties": { "content": { "$ref": "#/$defs/lotteryPresets" } } } + }, + { + "if": { "properties": { "kind": { "const": "canvas-palettes" } }, "required": ["kind"] }, + "then": { "properties": { "content": { "$ref": "#/$defs/canvasPalettes" } } } + } + ], + "additionalProperties": false, + "$defs": { + "semver": { + "type": "string", + "minLength": 5, + "maxLength": 64, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" + }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 }, + "itemId": { + "type": "string", + "minLength": 1, + "maxLength": 96, + "pattern": "^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$" + }, + "license": { + "oneOf": [ + { + "type": "object", + "required": ["type", "expression"], + "properties": { + "type": { "const": "spdx" }, + "expression": { "type": "string", "minLength": 1, "maxLength": 240 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["type", "name", "text"], + "properties": { + "type": { "const": "custom" }, + "name": { "$ref": "#/$defs/name" }, + "text": { "type": "string", "minLength": 1, "maxLength": 10000 } + }, + "additionalProperties": false + } + ] + }, + "oracleDeck": { + "type": "object", + "required": ["cards"], + "properties": { + "cards": { + "type": "array", + "minItems": 1, + "maxItems": 500, + "items": { + "type": "object", + "required": ["id", "name", "meaning"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "name": { "$ref": "#/$defs/name" }, + "meaning": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "symbol": { "type": "string", "maxLength": 16 }, + "category": { "type": "string", "maxLength": 64 }, + "keywords": { + "type": "array", + "maxItems": 16, + "items": { "type": "string", "minLength": 1, "maxLength": 64 } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "constraints": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "minItems": 1, + "maxItems": 2000, + "items": { + "type": "object", + "required": ["id", "text"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "text": { "type": "string", "minLength": 1, "maxLength": 500 }, + "category": { "type": "string", "maxLength": 64 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "diaryPrompts": { + "type": "object", + "required": ["prompts", "words"], + "properties": { + "prompts": { + "type": "array", + "maxItems": 2000, + "items": { + "type": "object", + "required": ["id", "text"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "text": { "type": "string", "minLength": 1, "maxLength": 1000 } + }, + "additionalProperties": false + } + }, + "words": { + "type": "array", + "maxItems": 5000, + "items": { + "type": "object", + "required": ["id", "text"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "text": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "additionalProperties": false + } + } + }, + "anyOf": [ + { "properties": { "prompts": { "type": "array", "minItems": 1 } } }, + { "properties": { "words": { "type": "array", "minItems": 1 } } } + ], + "additionalProperties": false + }, + "lotteryPresets": { + "type": "object", + "required": ["presets"], + "properties": { + "presets": { + "type": "array", + "minItems": 1, + "maxItems": 500, + "items": { "$ref": "#/$defs/lotteryPreset" } + } + }, + "additionalProperties": false + }, + "lotteryPresetBase": { + "type": "object", + "required": ["id", "name", "tool"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "name": { "$ref": "#/$defs/name" }, + "tool": { "enum": ["wheel", "name-picker", "shuffle", "number", "dice", "coin"] } + } + }, + "boundedList": { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "items": { "type": "string", "minLength": 1, "maxLength": 200 } + }, + "lotteryPreset": { + "oneOf": [ + { + "type": "object", + "required": ["id", "name", "tool", "items"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "name": { "$ref": "#/$defs/name" }, + "tool": { "enum": ["wheel", "name-picker", "shuffle"] }, + "items": { "$ref": "#/$defs/boundedList" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["id", "name", "tool", "minimum", "maximum", "integer"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "name": { "$ref": "#/$defs/name" }, + "tool": { "const": "number" }, + "minimum": { "type": "number", "minimum": -1000000000, "maximum": 1000000000 }, + "maximum": { "type": "number", "minimum": -1000000000, "maximum": 1000000000 }, + "integer": { "type": "boolean" } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["id", "name", "tool", "count", "sides"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "name": { "$ref": "#/$defs/name" }, + "tool": { "const": "dice" }, + "count": { "type": "integer", "minimum": 1, "maximum": 20 }, + "sides": { "type": "integer", "minimum": 2, "maximum": 1000 } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["id", "name", "tool", "heads", "tails"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "name": { "$ref": "#/$defs/name" }, + "tool": { "const": "coin" }, + "heads": { "type": "string", "minLength": 1, "maxLength": 80 }, + "tails": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "additionalProperties": false + } + ] + }, + "canvasPalettes": { + "type": "object", + "required": ["palettes"], + "properties": { + "palettes": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { + "type": "object", + "required": ["id", "name", "colors"], + "properties": { + "id": { "$ref": "#/$defs/itemId" }, + "name": { "$ref": "#/$defs/name" }, + "colors": { + "type": "array", + "minItems": 3, + "maxItems": 12, + "items": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?$" } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } +} diff --git a/src/schemas/v1/practice-store.schema.json b/src/schemas/v1/practice-store.schema.json new file mode 100644 index 0000000..6e4b770 --- /dev/null +++ b/src/schemas/v1/practice-store.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/practice-store/v1", + "title": "PracticeStore v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "schema_version", "updated_at", "plans"], + "properties": { + "schema": { "const": "sortilune.practice-store" }, + "schema_version": { "const": 1 }, + "updated_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "plans": { + "type": "array", + "maxItems": 64, + "items": { "$ref": "#/$defs/plan" } + } + }, + "$defs": { + "activity": { + "type": "object", + "additionalProperties": false, + "required": ["id", "title"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "title": { "type": "string", "minLength": 1, "maxLength": 240 } + } + }, + "plan": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "status", "activities", "eligible_weekdays", "created_at", "updated_at"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 }, + "status": { "enum": ["active", "paused", "stopped"] }, + "activities": { "type": "array", "minItems": 1, "maxItems": 24, "items": { "$ref": "#/$defs/activity" } }, + "eligible_weekdays": { + "type": "array", + "minItems": 1, + "maxItems": 7, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 0, "maximum": 6 } + }, + "created_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "updated_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" } + } + } + } +} diff --git a/src/schemas/v1/project.schema.json b/src/schemas/v1/project.schema.json new file mode 100644 index 0000000..983c996 --- /dev/null +++ b/src/schemas/v1/project.schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/project/v1", + "title": "Sortilune Project v1", + "type": "object", + "required": ["schema", "schema_version", "id", "name", "status", "created_at", "updated_at", "completed_at", "closed_at", "current_step_id", "notes", "template", "steps"], + "properties": { + "schema": { "const": "sortilune.project" }, + "schema_version": { "const": 1 }, + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "name": { "type": "string", "minLength": 1, "maxLength": 120 }, + "status": { "enum": ["active", "completed", "closed"] }, + "created_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "updated_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "completed_at": { "$ref": "#/$defs/nullableTimestamp" }, + "closed_at": { "$ref": "#/$defs/nullableTimestamp" }, + "current_step_id": { + "oneOf": [ + { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + { "type": "null" } + ] + }, + "notes": { "type": "string", "maxLength": 20000 }, + "template": { "$ref": "#/$defs/projectTemplate" }, + "steps": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { "$ref": "#/$defs/projectStep" } + } + }, + "additionalProperties": false, + "$defs": { + "nullableTimestamp": { + "oneOf": [ + { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + { "type": "null" } + ] + }, + "nullableDestination": { + "oneOf": [ + { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + { "type": "null" } + ] + }, + "destinationOption": { + "type": "object", + "required": ["destination", "label", "description"], + "properties": { + "destination": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "label": { "type": "string", "minLength": 1, "maxLength": 80 }, + "description": { "type": "string", "minLength": 1, "maxLength": 240 } + }, + "additionalProperties": false + }, + "projectTemplateStep": { + "type": "object", + "required": ["id", "title", "description", "destination", "destination_options"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "title": { "type": "string", "minLength": 1, "maxLength": 80 }, + "description": { "type": "string", "minLength": 1, "maxLength": 320 }, + "destination": { "$ref": "#/$defs/nullableDestination" }, + "destination_options": { + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/destinationOption" } + } + }, + "additionalProperties": false + }, + "projectTemplate": { + "type": "object", + "required": ["id", "version", "name", "description", "steps"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "version": { "type": "integer", "minimum": 1, "maximum": 9999 }, + "name": { "type": "string", "minLength": 1, "maxLength": 100 }, + "description": { "type": "string", "minLength": 1, "maxLength": 400 }, + "steps": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { "$ref": "#/$defs/projectTemplateStep" } + } + }, + "additionalProperties": false + }, + "projectRecordReference": { + "type": "object", + "required": ["id", "path", "chamber", "type", "summary", "created_at"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "path": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/archiveRelativePath" }, + "chamber": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "type": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "summary": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "created_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" } + }, + "additionalProperties": false + }, + "projectStep": { + "type": "object", + "required": ["id", "title", "description", "destination", "destination_options", "selected_destination", "attempt", "status", "note", "record_reference", "completed_at", "skipped_at"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "title": { "type": "string", "minLength": 1, "maxLength": 80 }, + "description": { "type": "string", "minLength": 1, "maxLength": 320 }, + "destination": { "$ref": "#/$defs/nullableDestination" }, + "destination_options": { + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/destinationOption" } + }, + "selected_destination": { "$ref": "#/$defs/nullableDestination" }, + "attempt": { "type": "integer", "minimum": 1, "maximum": 9999 }, + "status": { "enum": ["pending", "current", "completed", "skipped"] }, + "note": { "type": "string", "maxLength": 5000 }, + "record_reference": { + "oneOf": [ + { "$ref": "#/$defs/projectRecordReference" }, + { "type": "null" } + ] + }, + "completed_at": { "$ref": "#/$defs/nullableTimestamp" }, + "skipped_at": { "$ref": "#/$defs/nullableTimestamp" } + }, + "additionalProperties": false + } + } +} diff --git a/src/schemas/v1/provenance.schema.json b/src/schemas/v1/provenance.schema.json new file mode 100644 index 0000000..81c5bdf --- /dev/null +++ b/src/schemas/v1/provenance.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/provenance/v1", + "title": "Provenance v1", + "type": "object", + "required": ["source", "fetched_at", "raw"], + "properties": { + "source": { + "type": "object", + "required": ["id", "label", "kind"], + "properties": { + "id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "label": { "type": "string", "minLength": 1, "maxLength": 200 }, + "kind": { + "enum": ["public-randomness", "quantum", "atmospheric", "seismic", "weather", "system", "fixture", "imported"] + }, + "url": { "type": "string", "format": "uri", "maxLength": 2048 } + }, + "additionalProperties": false + }, + "fetched_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "raw": { "type": "string", "maxLength": 1000000 }, + "signature": { "type": ["string", "null"], "maxLength": 1000000 }, + "details": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" } + }, + "additionalProperties": false +} diff --git a/src/schemas/v1/receipt.schema.json b/src/schemas/v1/receipt.schema.json new file mode 100644 index 0000000..6a7e22d --- /dev/null +++ b/src/schemas/v1/receipt.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/receipt/v1", + "title": "Sortilune portable result receipt v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "schema_version", "kind", "created_at", "source", "content", "limitations", "integrity"], + "properties": { + "schema": { "const": "sortilune.receipt" }, + "schema_version": { "const": 1 }, + "kind": { "const": "result" }, + "created_at": { "type": "string", "format": "date-time" }, + "source": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "id", "title"], + "properties": { + "kind": { "enum": ["archive-record", "project-summary"] }, + "id": { "type": "string", "minLength": 1, "maxLength": 200 }, + "title": { "type": "string", "minLength": 1, "maxLength": 320 } + } + }, + "content": {}, + "limitations": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { "type": "string", "minLength": 1, "maxLength": 400 } + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "canonicalization", "value"], + "properties": { + "algorithm": { "const": "SHA-256" }, + "canonicalization": { "const": "sortilune.canonical-json/v1" }, + "value": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } + } +} diff --git a/src/schemas/v1/relation.schema.json b/src/schemas/v1/relation.schema.json new file mode 100644 index 0000000..3ec6e39 --- /dev/null +++ b/src/schemas/v1/relation.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/relation/v1", + "title": "Archive relation v1", + "type": "object", + "required": ["kind", "target_id"], + "properties": { + "kind": { + "enum": ["project", "practice", "daily-record", "rerolled-from", "rerolled-to", "source-record", "derived-from", "annotation-for"] + }, + "target_id": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/stableId" }, + "target_schema": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "metadata": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/boundedJson" } + }, + "additionalProperties": false +} diff --git a/src/schemas/v1/symphony-score.schema.json b/src/schemas/v1/symphony-score.schema.json new file mode 100644 index 0000000..f61b2c5 --- /dev/null +++ b/src/schemas/v1/symphony-score.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/symphony-score/v1", + "title": "SymphonyScore v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "schema_version", "synthesis", "exact", "started_at", "duration_ms", "atmosphere", "events"], + "properties": { + "schema": { "const": "sortilune.symphony-score" }, + "schema_version": { "const": 1 }, + "synthesis": { "enum": ["sortilune.symphony-synthesis/v1", "legacy-approximate"] }, + "exact": { "type": "boolean" }, + "started_at": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/rfc3339Timestamp" }, + "duration_ms": { "type": "integer", "minimum": 1000, "maximum": 1800000 }, + "atmosphere": { "$ref": "#/$defs/atmosphere" }, + "events": { "type": "array", "maxItems": 500, "items": { "$ref": "#/$defs/event" } } + }, + "$defs": { + "atmosphere": { + "type": "object", + "additionalProperties": false, + "required": ["fundamental_hz", "upper_hz", "gain", "filter_hz"], + "properties": { + "fundamental_hz": { "type": "number", "exclusiveMinimum": 0, "maximum": 24000 }, + "upper_hz": { "type": "number", "exclusiveMinimum": 0, "maximum": 24000 }, + "gain": { "type": "number", "minimum": 0, "maximum": 1 }, + "filter_hz": { "type": "number", "minimum": 20, "maximum": 24000 } + } + }, + "voice": { + "type": "object", + "additionalProperties": false, + "required": ["frequency_hz", "midi_note", "offset_ms", "duration_ms", "attack_ms", "gain", "pan", "detune_cents", "waveform"], + "properties": { + "frequency_hz": { "type": "number", "exclusiveMinimum": 0, "maximum": 24000 }, + "midi_note": { "type": "integer", "minimum": 0, "maximum": 127 }, + "offset_ms": { "type": "integer", "minimum": 0, "maximum": 1800000 }, + "duration_ms": { "type": "integer", "minimum": 1, "maximum": 60000 }, + "attack_ms": { "type": "integer", "minimum": 0, "maximum": 60000 }, + "gain": { "type": "number", "minimum": 0, "maximum": 1 }, + "pan": { "type": "number", "minimum": -1, "maximum": 1 }, + "detune_cents": { "type": "number", "minimum": -1200, "maximum": 1200 }, + "waveform": { "const": "sine" } + } + }, + "event": { + "type": "object", + "additionalProperties": false, + "required": ["id", "at_ms", "kind", "source", "source_id", "label", "mapping", "voices"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 260 }, + "at_ms": { "type": "integer", "minimum": 0, "maximum": 1800000 }, + "kind": { "enum": ["marker", "quake", "beacon", "wind", "motif"] }, + "source": { "enum": ["session", "usgs", "nist", "weather", "today", "legacy"] }, + "source_id": { "type": "string", "minLength": 1, "maxLength": 200 }, + "label": { "type": "string", "minLength": 1, "maxLength": 500 }, + "mapping": { "enum": ["sortilune.symphony-mapping/v1", "legacy-approximate"] }, + "voices": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/voice" } }, + "modulation": { + "type": "object", + "additionalProperties": false, + "required": ["wind_mps", "filter_hz"], + "properties": { + "wind_mps": { "type": "number", "minimum": 0, "maximum": 100 }, + "filter_hz": { "type": "number", "minimum": 20, "maximum": 24000 } + } + }, + "location": { + "type": "object", + "additionalProperties": false, + "required": ["lat", "lon", "magnitude"], + "properties": { + "lat": { "type": "number", "minimum": -90, "maximum": 90 }, + "lon": { "type": "number", "minimum": -180, "maximum": 180 }, + "magnitude": { "type": "number", "minimum": -10, "maximum": 20 } + } + } + } + } + } +} diff --git a/src/schemas/v2/settings.schema.json b/src/schemas/v2/settings.schema.json new file mode 100644 index 0000000..6d780c5 --- /dev/null +++ b/src/schemas/v2/settings.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sortilune.app/schemas/settings/v2", + "title": "Sortilune settings v2", + "type": "object", + "required": ["schema", "schema_version", "route", "theme", "entropy", "visual", "chambers"], + "properties": { + "schema": { "const": "sortilune.settings" }, + "schema_version": { "const": 2 }, + "route": { + "type": "object", + "required": ["destination", "params"], + "properties": { + "destination": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "params": { + "type": "object", + "maxProperties": 16, + "propertyNames": { "maxLength": 64 }, + "additionalProperties": { "type": "string", "maxLength": 256 } + } + }, + "additionalProperties": false + }, + "navigation": { + "type": "object", + "required": ["rail_mode"], + "properties": { + "rail_mode": { "enum": ["auto", "expanded", "compact"] } + }, + "additionalProperties": false + }, + "theme": { "enum": ["cosmic-dark", "cosmic-light", "high-contrast"] }, + "entropy": { + "type": "object", + "required": ["preferred_source", "enabled_sources"], + "properties": { + "preferred_source": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "enabled_sources": { + "type": "object", + "minProperties": 1, + "maxProperties": 32, + "propertyNames": { "$ref": "https://sortilune.app/schemas/common/v1#/$defs/identifier" }, + "additionalProperties": { "type": "boolean" } + } + }, + "additionalProperties": false + }, + "visual": { + "type": "object", + "required": ["starfield", "reduce_motion"], + "properties": { + "starfield": { "type": "boolean" }, + "reduce_motion": { "enum": ["system", "reduce", "allow"] } + }, + "additionalProperties": false + }, + "archive": { + "type": "object", + "required": ["search_diary_body", "on_this_day"], + "properties": { + "search_diary_body": { "type": "boolean" }, + "on_this_day": { "type": "boolean" } + }, + "additionalProperties": false + }, + "chambers": { + "type": "object", + "required": ["last_used_deck"], + "properties": { + "last_used_deck": { "enum": ["tarot", "i-ching", "runes", "cosmic"] } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/src/schemas/validate-pack.ts b/src/schemas/validate-pack.ts new file mode 100644 index 0000000..c884041 --- /dev/null +++ b/src/schemas/validate-pack.ts @@ -0,0 +1,3 @@ +export { validatePack } from './generated/pack-validator.generated.js'; + +export type { StandaloneValidator, ValidationError } from './generated/validators.generated.js'; diff --git a/src/schemas/validate.ts b/src/schemas/validate.ts new file mode 100644 index 0000000..2b2929a --- /dev/null +++ b/src/schemas/validate.ts @@ -0,0 +1,15 @@ +export { + validateArchiveAnnotationStore, + validateArchiveRecord, + validateAssetReference, + validateDailyRecord, + validateProvenance, + validateProject, + validatePracticeStore, + validateReceipt, + validateRelation, + validateSettingsV2, + validateSymphonyScore, +} from './generated/validators.generated.js'; + +export type { StandaloneValidator, ValidationError } from './generated/validators.generated.js'; diff --git a/src/settings/index.js b/src/settings/index.js index b5dad0b..d2d224e 100644 --- a/src/settings/index.js +++ b/src/settings/index.js @@ -1,156 +1,519 @@ -/** - * Settings modal: theme, sources, archive path, about/credits. - */ +/** Settings dialog organized around stable product capability sections. */ import { h, clear } from '../lib/dom.js'; import { getState, setState } from '../lib/state.js'; import entropy from '../lib/entropy/index.js'; import { humanDate } from '../lib/format.js'; +import { revealArchive } from '../lib/fs.js'; +import { toast } from '../lib/notify.js'; +import { Dialog } from '../ui/primitives.js'; +import { archiveWorkspace } from '../archive/workspace.js'; +import { archiveRepository } from '../archive/repository.js'; +import { packRepository } from '../packs/repository.js'; const SOURCE_IDS = ['nist-beacon', 'usgs-seismic', 'open-meteo', 'random-org', 'anu-quantum', 'system']; +const SECTION_IDS = ['appearance', 'sources', 'archive', 'packs', 'desktop', 'accessibility', 'about']; -let _root = null; +let _dialog = null; +let _content = null; let _testing = false; let _statusByID = {}; +let _packBusy = null; +let _packNotice = null; +let _historicalPacks = []; +let _unsubscribePacks = null; export function open() { - if (_root) return; - _root = document.createElement('div'); - _root.className = 'settings-overlay'; - _root.addEventListener('click', (e) => { if (e.target === _root) close(); }); - document.body.appendChild(_root); + if (_dialog?.element.isConnected) return; + _content = h('div', { class: 'settings-content' }); + _dialog = Dialog({ + title: 'Settings', + className: 'settings-dialog', + content: [_content], + closeLabel: 'Close settings', + onClose: () => { + _unsubscribePacks?.(); + _unsubscribePacks = null; + _dialog = null; + _content = null; + }, + }); + _unsubscribePacks = packRepository.subscribe(() => render()); + void refreshHistoricalPacks(); render(); + _dialog.open(); } export function close() { - if (_root) { _root.remove(); _root = null; } + _dialog?.close(); } function render() { - if (!_root) return; - clear(_root); + if (!_content) return; + clear(_content); const state = getState(); - const status = entropy.getSourceStatus(); - for (const s of status) _statusByID[s.id] = s; - const panel = h('div', { class: 'settings-panel' }, [ - h('div', { class: 'spread' }, [ - h('h2', { class: 'chamber-title' }, ['Settings']), - h('button', { class: 'btn btn-ghost btn-icon', onclick: close, title: 'Close' }, ['×']), - ]), - - section('Entropy sources', [ - h('p', { class: 'muted small' }, [ - 'NIST is the gold standard for provenance and should always be enabled. ', - 'Disable any source you don’t want consulted as part of the fallback chain.', - ]), - h('ul', { class: 'sources-list' }, - SOURCE_IDS.map((id) => { - const enabled = state.enabledSources?.[id] !== false; - const s = _statusByID[id] || {}; - return h('li', { class: 'source-row' }, [ - h('div', { class: 'source-meta' }, [ - h('div', { class: 'source-name' }, [s.displayName || id]), - h('div', { class: 'small muted mono' }, [id]), - ]), - h('div', { class: 'source-status' }, [ - s.lastSuccess - ? h('span', { class: 'small ok' }, [`OK · ${humanDate(s.lastSuccess)}`]) - : s.lastError - ? h('span', { class: 'small bad' }, [`ERR · ${s.lastErrorMessage || 'unknown'}`]) - : h('span', { class: 'small muted' }, ['untested']), - ]), - h('label', { class: 'toggle' }, [ - h('input', { - type: 'checkbox', checked: enabled ? true : null, - onchange: (e) => { - const next = { ...(getState().enabledSources || {}) }; - next[id] = e.target.checked; - setState({ enabledSources: next }); - entropy.setEnabled?.(next); - }, - }), - h('span', null, ['enable']), - ]), - ]); - }) - ), - h('div', { class: 'row', style: { marginTop: '12px', gap: '8px' } }, [ - h('button', { class: 'btn', disabled: _testing, onclick: testSources }, [_testing ? 'testing…' : 'Test all sources']), - ]), - ]), - - section('Theme', [ - h('div', { class: 'segmented' }, + for (const source of entropy.getSourceStatus()) _statusByID[source.id] = source; + const sections = [ + section('appearance', 'Appearance', [ + controlGroup('Theme', h('div', { class: 'segmented', role: 'group', 'aria-label': 'Theme' }, [['cosmic-dark', 'Dark'], ['cosmic-light', 'Light'], ['high-contrast', 'High contrast']] .map(([id, label]) => h('button', { class: 'segmented-opt', + type: 'button', 'aria-pressed': state.theme === id ? 'true' : 'false', onclick: () => { setState({ theme: id }); - document.documentElement.setAttribute('data-theme', id); render(); }, - }, [label]))), + }, [label])))), + controlGroup('Navigation rail', h('div', { class: 'segmented', role: 'group', 'aria-label': 'Navigation rail width' }, + [['auto', 'Automatic'], ['expanded', 'Wide'], ['compact', 'Compact']] + .map(([id, label]) => h('button', { + class: 'segmented-opt', + type: 'button', + 'aria-pressed': state.railMode === id ? 'true' : 'false', + onclick: () => { + setState({ railMode: id }); + render(); + }, + }, [label])))), ]), - - section('Archive', [ - h('p', { class: 'muted small' }, [ - 'Sortilune writes archived items to your OS application-data directory:', + section('sources', 'Sources', sourceSettings(state)), + section('archive', 'Archive', [ + h('p', { class: 'muted' }, ['Sortilune stores archived items as readable JSON and Markdown in your operating system application-data directory.']), + h('p', { class: 'mono small selectable' }, [archiveLocation()]), + h('button', { class: 'btn', type: 'button', onclick: openArchiveFolder }, ['Open archive folder']), + h('label', { class: 'settings-control settings-check' }, [ + h('span', { class: 'settings-control-copy' }, [ + h('strong', null, ['Search Diary body']), + h('span', { class: 'muted small' }, ['Off by default. When enabled, Diary text is indexed only in memory and is never written into the disposable cache.']), + ]), + h('input', { + type: 'checkbox', + checked: state.searchDiaryBody ? true : null, + onchange: (event) => { + setState({ searchDiaryBody: event.target.checked }); + archiveWorkspace.setSearchDiaryBody(event.target.checked); + }, + }), ]), - h('p', { class: 'mono small' }, [ - navigator.userAgent.includes('Windows') - ? '%APPDATA%/com.sortilune.desktop/archive/' - : navigator.userAgent.includes('Mac') - ? '~/Library/Application Support/com.sortilune.desktop/archive/' - : '~/.local/share/com.sortilune.desktop/archive/', - ]), - h('p', { class: 'small muted' }, [ - 'Files are plain JSON and Markdown — readable in any editor even without Sortilune installed.', + h('label', { class: 'settings-control settings-check' }, [ + h('span', { class: 'settings-control-copy' }, [ + h('strong', null, ['On this day']), + h('span', { class: 'muted small' }, ['Show optional memories from the same calendar day in previous years.']), + ]), + h('input', { + type: 'checkbox', + checked: state.onThisDay ? true : null, + onchange: (event) => setState({ onThisDay: event.target.checked }), + }), ]), + archiveHealth(state), ]), - - section('About', [ - h('p', null, [ - h('strong', null, ['Sortilune']), - ' — moon-cast lots, divination by physical randomness.', + section('packs', 'Packs', packSettings()), + section('desktop', 'Desktop', [ + h('p', { class: 'muted' }, ['This portable build runs only while its window is open. Tray, autostart, global shortcuts, and notifications remain off unless later enabled explicitly.']), + h('dl', { class: 'settings-status-list' }, [ + statusRow('Background activity', 'Off'), + statusRow('Autostart', 'Off'), + statusRow('Global shortcut', 'Off'), + statusRow('Notifications', 'Off'), ]), - h('p', { class: 'small muted' }, [ - 'No telemetry. No cloud. No accounts. The app phones home zero times after install.', + ]), + section('accessibility', 'Accessibility', [ + h('label', { class: 'settings-control' }, [ + h('span', { class: 'settings-control-copy' }, [ + h('strong', null, ['Motion']), + h('span', { class: 'muted small' }, ['Follow the operating system, reduce motion, or allow the full visual treatment.']), + ]), + h('select', { + class: 'select', + value: state.reduceMotion, + onchange: (event) => setState({ reduceMotion: event.target.value }), + }, [ + h('option', { value: 'system', selected: state.reduceMotion === 'system' ? true : null }, ['Use system setting']), + h('option', { value: 'reduce', selected: state.reduceMotion === 'reduce' ? true : null }, ['Reduce motion']), + h('option', { value: 'allow', selected: state.reduceMotion === 'allow' ? true : null }, ['Allow motion']), + ]), ]), - h('h4', { style: { marginTop: '16px' } }, ['Credits']), - h('p', { class: 'small' }, [ - 'This app’s premise depends on these public services existing. Always credit them:', + h('label', { class: 'settings-control settings-check' }, [ + h('span', { class: 'settings-control-copy' }, [ + h('strong', null, ['Starfield']), + h('span', { class: 'muted small' }, ['Show the decorative background when motion is allowed.']), + ]), + h('input', { + type: 'checkbox', + checked: state.starfield ? true : null, + onchange: (event) => setState({ starfield: event.target.checked }), + }), ]), + h('p', { class: 'small muted' }, ['High contrast, forced colors, reduced transparency, and text scaling are also honored from Windows.']), + ]), + section('about', 'About', [ + h('p', null, [h('strong', null, ['Sortilune']), ' — moon-cast lots, divination by physical randomness.']), + h('p', { class: 'muted' }, ['No telemetry, accounts, or Sortilune-operated cloud. Network access is limited to credited public entropy and planetary-data services.']), + h('h4', null, ['Credits']), h('ul', { class: 'credits' }, [ - h('li', null, [h('strong', null, ['NIST']), ' — Randomness Beacon ', h('span', { class: 'small muted' }, ['(cryptographically signed pulses)'])]), - h('li', null, [h('strong', null, ['ANU']), ' — Quantum Random Numbers Server ', h('span', { class: 'small muted' }, ['(vacuum fluctuations)'])]), - h('li', null, [h('strong', null, ['random.org']), ' — atmospheric radio noise ', h('span', { class: 'small muted' }, ['(Dublin)'])]), - h('li', null, [h('strong', null, ['USGS']), ' — earthquake feed ', h('span', { class: 'small muted' }, ['(global seismic activity)'])]), - h('li', null, [h('strong', null, ['NOAA / Open-Meteo']), ' — planetary weather data ', h('span', { class: 'small muted' }, ['(global current conditions)'])]), + credit('NIST', 'Randomness Beacon'), + credit('ANU', 'Quantum Random Numbers Server'), + credit('random.org', 'atmospheric radio noise'), + credit('USGS', 'global earthquake feed'), + credit('NOAA / Open-Meteo', 'planetary weather data'), ]), ]), + ]; + const navigation = h('nav', { class: 'settings-navigation', 'aria-label': 'Settings sections' }, SECTION_IDS.map((id) => { + const target = sections.find((candidate) => candidate.id === `settings-${id}`); + return h('button', { + class: 'settings-navigation-item', + type: 'button', + onclick: () => navigateToSection(target), + }, [target?.querySelector('h3')?.textContent ?? id]); + })); + const sectionPicker = h('label', { class: 'settings-section-picker' }, [ + h('span', { class: 'label' }, ['Go to section']), + h('select', { + class: 'select', + 'aria-label': 'Go to settings section', + onchange: (event) => { + const target = sections.find((candidate) => candidate.id === `settings-${event.target.value}`); + navigateToSection(target); + event.target.value = ''; + }, + }, [ + h('option', { value: '', selected: true }, ['Choose a section…']), + ...SECTION_IDS.map((id) => h('option', { value: id }, [ + sections.find((candidate) => candidate.id === `settings-${id}`)?.querySelector('h3')?.textContent ?? id, + ])), + ]), + ]); + _content.append(h('div', { class: 'settings-layout' }, [ + navigation, + sectionPicker, + h('div', { class: 'settings-sections' }, sections), + ])); +} + +function navigateToSection(target) { + if (!target) return; + target.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' }); + target.focus({ preventScroll: true }); +} + +function sourceSettings(state) { + return [ + h('p', { class: 'muted' }, [ + 'Choose the source order used by Oracle, Constraint, Canvas, Lottery, and casual Decider. ', + 'Today always tries NIST first and offers its own clearly labeled local fallback; Symphony uses its planetary feeds.', + ]), + h('p', { class: 'small ok settings-save-status', role: 'status' }, ['Changes save automatically on this device.']), + h('label', { class: 'settings-control' }, [ + h('span', { class: 'settings-control-copy' }, [h('strong', null, ['Preferred source'])]), + h('select', { + class: 'select', + value: state.preferredEntropySource, + onchange: (event) => { + setState({ preferredEntropySource: event.target.value }); + entropy.setPreferred?.(event.target.value); + markSettingsSaved(); + }, + }, [ + ['preferred', 'Automatic priority'], ['nist-beacon', 'NIST Beacon'], ['usgs-seismic', 'USGS earthquakes'], + ['open-meteo', 'Open-Meteo weather'], ['random-org', 'random.org'], ['anu-quantum', 'ANU Quantum'], ['system', 'On-device randomness'], + ].map(([value, label]) => h('option', { value, selected: value === state.preferredEntropySource ? true : null }, [label]))), + ]), + h('ul', { class: 'sources-list' }, SOURCE_IDS.map((id) => sourceRow(id, state))), + h('button', { class: 'btn', type: 'button', disabled: _testing, onclick: testSources }, [_testing ? 'Testing sources…' : 'Test all sources']), + ]; +} + +function sourceRow(id, state) { + const enabled = state.enabledSources?.[id] !== false; + const source = _statusByID[id] || {}; + return h('li', { class: 'source-row' }, [ + h('div', { class: 'source-meta' }, [ + h('div', { class: 'source-name' }, [source.displayName || id]), + h('div', { class: 'small muted mono' }, [id]), + ]), + h('div', { class: 'source-status' }, [ + source.lastSuccess + ? h('span', { class: 'small ok' }, [`OK · ${humanDate(source.lastSuccess)}`]) + : source.lastError + ? h('span', { class: 'small bad' }, [`Unavailable · ${friendlySourceError(source.lastErrorMessage)}`]) + : h('span', { class: 'small muted' }, ['Not tested']), + ]), + h('label', { class: 'toggle' }, [ + h('input', { + type: 'checkbox', + checked: enabled ? true : null, + onchange: (event) => { + const next = { ...(getState().enabledSources || {}) }; + next[id] = event.target.checked; + setState({ enabledSources: next }); + entropy.setEnabled?.(next); + markSettingsSaved(); + }, + }), + h('span', null, ['Enable']), + ]), ]); - _root.appendChild(panel); } -function section(title, children) { - return h('section', { class: 'settings-section' }, [ +function markSettingsSaved() { + const status = _content?.querySelector?.('#settings-sources .settings-save-status'); + if (status) status.textContent = 'Saved automatically.'; +} + +function packSettings() { + const snapshot = packRepository.snapshot; + const archiveCounts = new Map(_historicalPacks.map((item) => [`${item.id}\0${item.version}`, item.count])); + const installedKeys = new Set(snapshot.versions.map((item) => `${item.pack.pack_id}\0${item.pack.version}`)); + const historicalOnly = _historicalPacks.filter((item) => !installedKeys.has(`${item.id}\0${item.version}`)); + const busy = Boolean(_packBusy) || snapshot.loading; + return [ + h('p', { class: 'muted' }, [ + 'Add local JSON content for Oracle, Constraint, Diary, Lottery, or Canvas. Packs stay on this computer and never contact a marketplace.', + ]), + h('div', { class: 'pack-manager-actions row', style: { flexWrap: 'wrap' } }, [ + h('button', { + class: 'btn btn-primary', type: 'button', disabled: busy || !snapshot.available || Boolean(snapshot.registry_error), + onclick: () => runPackAction('import', async () => { + const imported = await packRepository.importFromDialog(); + return imported ? `Installed ${imported.pack.name} ${imported.pack.version}.` : 'Import cancelled; nothing changed.'; + }), + }, [_packBusy === 'import' ? 'Importing…' : 'Import pack']), + h('button', { + class: 'btn', type: 'button', disabled: busy || !snapshot.available, + onclick: () => runPackAction('refresh', async () => { + await packRepository.recheck(); + return 'Pack files rechecked.'; + }), + }, [_packBusy === 'refresh' ? 'Checking…' : 'Recheck']), + ]), + _packNotice ? h('p', { + class: _packNotice.error ? 'bad small' : 'ok small', + role: _packNotice.error ? 'alert' : 'status', + }, [_packNotice.message]) : null, + !snapshot.available ? h('div', { class: 'panel pack-empty' }, [ + h('strong', null, ['Desktop build required']), + h('p', { class: 'muted small' }, ['Native pack import and storage are unavailable in this browser preview.']), + ]) : null, + snapshot.registry_error ? h('div', { class: 'panel pack-error', role: 'alert' }, [ + h('strong', null, ['Pack registry could not be read']), + h('p', { class: 'bad small' }, [snapshot.registry_error]), + h('p', { class: 'muted small' }, ['The file was preserved and no installed pack was changed.']), + ]) : null, + snapshot.available && !snapshot.loading && !snapshot.registry_error && snapshot.versions.length === 0 && historicalOnly.length === 0 + ? h('div', { class: 'panel pack-empty' }, [ + h('strong', null, ['No packs installed']), + h('p', { class: 'muted small' }, ['Import one of the example packs or a local starter pack to add content.']), + ]) + : null, + snapshot.versions.length > 0 ? h('ul', { class: 'pack-list', 'aria-label': 'Installed pack versions' }, + snapshot.versions.map((item) => packRow(item, archiveCounts.get(`${item.pack.pack_id}\0${item.pack.version}`) || 0, busy))) : null, + historicalOnly.length > 0 ? h('div', { class: 'pack-history' }, [ + h('h4', null, ['Used by archived results']), + h('p', { class: 'muted small' }, ['These versions are no longer installed. Archived results retain their selected content snapshot.']), + h('ul', { class: 'pack-list' }, historicalOnly.map((item) => h('li', { class: 'pack-row panel' }, [ + h('div', { class: 'pack-row-main' }, [ + h('strong', null, [item.id]), + h('span', { class: 'mono small muted' }, [`${item.version} · ${item.count} archived ${item.count === 1 ? 'result' : 'results'}`]), + ]), + h('span', { class: 'badge' }, ['historical']), + ]))), + ]) : null, + ]; +} + +function packRow(item, archiveCount, busy) { + const statusCopy = { + installed: 'installed', + 'update-available': 'newer local version installed', + disabled: 'disabled', + 'missing-dependency': 'missing dependency', + invalid: 'invalid', + }[item.status] || item.status; + return h('li', { class: `pack-row panel pack-status-${item.status}` }, [ + h('div', { class: 'pack-row-main' }, [ + h('div', { class: 'spread' }, [ + h('strong', null, [item.pack.name]), + h('span', { class: `badge${item.status === 'invalid' || item.status === 'missing-dependency' ? ' bad' : ''}` }, [statusCopy]), + ]), + h('div', { class: 'mono small muted' }, [`${item.pack.pack_id} · ${item.pack.version} · ${item.pack.kind}`]), + h('div', { class: 'small muted' }, [ + `${item.pack.author.name} · ${item.pack.license.type === 'spdx' ? item.pack.license.expression : item.pack.license.name}`, + ]), + archiveCount > 0 ? h('div', { class: 'small' }, [`Used by ${archiveCount} archived ${archiveCount === 1 ? 'result' : 'results'}.`]) : null, + item.missing_dependencies.length > 0 + ? h('div', { class: 'bad small' }, [`Needs ${item.missing_dependencies.join(', ')}.`]) : null, + item.errors.map((error) => h('div', { class: 'bad small', role: 'alert' }, [error])), + ]), + h('div', { class: 'pack-row-actions' }, [ + item.status !== 'invalid' ? h('button', { + class: 'btn btn-ghost', type: 'button', disabled: busy, + onclick: () => runPackAction('toggle', async () => { + await packRepository.setEnabled(item.pack.pack_id, item.pack.version, !item.enabled); + return `${item.pack.name} ${item.enabled ? 'disabled' : 'enabled'}.`; + }), + }, [item.enabled ? 'Disable' : 'Enable']) : null, + h('button', { + class: 'btn btn-ghost', type: 'button', disabled: busy, + onclick: () => { + if (!window.confirm(`Uninstall ${item.pack.name} ${item.pack.version}? Archived results will keep their snapshots.`)) return; + void runPackAction('uninstall', async () => { + let uninstallError = null; + try { + await packRepository.uninstall(item.pack.pack_id, item.pack.version); + } catch (error) { + uninstallError = error; + } + await refreshHistoricalPacks(); + if (uninstallError) throw uninstallError; + return `${item.pack.name} ${item.pack.version} uninstalled.`; + }); + }, + }, ['Uninstall']), + ]), + ]); +} + +async function runPackAction(action, task) { + if (_packBusy) return; + _packBusy = action; + _packNotice = null; + render(); + try { + _packNotice = { error: false, message: await task() }; + } catch (error) { + _packNotice = { error: true, message: packErrorMessage(error) }; + } finally { + _packBusy = null; + render(); + } +} + +async function refreshHistoricalPacks() { + try { + const items = await archiveRepository.list(); + const counts = new Map(); + for (const item of items) { + const pack = item.status === 'ok' ? item.record.pack : null; + if (!pack || typeof pack.version !== 'string') continue; + const key = `${pack.id}\0${pack.version}`; + counts.set(key, (counts.get(key) || 0) + 1); + } + _historicalPacks = [...counts].map(([key, count]) => { + const [id, version] = key.split('\0'); + return { id, version, count }; + }).sort((left, right) => left.id.localeCompare(right.id) || right.version.localeCompare(left.version)); + if (_content) render(); + } catch { + _historicalPacks = []; + } +} + +function packErrorMessage(error) { + if (error instanceof Error) return error.message; + if (error && typeof error === 'object' && error.message) return String(error.message); + return String(error); +} + +function friendlySourceError(message) { + const value = String(message || '').toLowerCase(); + if (/timeout|timed out/u.test(value)) return 'request timed out'; + if (/disabled|not enabled/u.test(value)) return 'source is disabled'; + if (/http|fetch|network|request|status|failed|unavailable|unreachable/u.test(value)) return 'network or service error'; + return 'source could not be reached'; +} + +function section(id, title, children) { + return h('section', { class: 'settings-section', id: `settings-${id}`, tabindex: -1 }, [ h('h3', { class: 'settings-section-title' }, [title]), ...children, ]); } +function controlGroup(label, control) { + return h('div', { class: 'settings-control' }, [ + h('div', { class: 'settings-control-copy' }, [h('strong', null, [label])]), + control, + ]); +} + +function statusRow(term, value) { + return [h('dt', null, [term]), h('dd', null, [value])]; +} + +function credit(name, description) { + return h('li', null, [h('strong', null, [name]), ` — ${description}`]); +} + +function archiveLocation() { + if (navigator.userAgent.includes('Windows')) return '%APPDATA%/com.sortilune.desktop/archive/'; + if (navigator.userAgent.includes('Mac')) return '~/Library/Application Support/com.sortilune.desktop/archive/'; + return '~/.local/share/com.sortilune.desktop/archive/'; +} + +function reducedMotion() { + const preference = getState().reduceMotion; + return preference === 'reduce' || (preference === 'system' && matchMedia('(prefers-reduced-motion: reduce)').matches); +} + async function testSources() { _testing = true; render(); try { const results = await entropy.testAllSources(); - for (const r of results) { - _statusByID[r.id] = { ..._statusByID[r.id], ...(r.ok ? { lastSuccess: new Date().toISOString(), lastError: null } : { lastError: new Date().toISOString(), lastErrorMessage: r.error }) }; + for (const result of results) { + _statusByID[result.id] = { + ..._statusByID[result.id], + ...(result.ok + ? { lastSuccess: new Date().toISOString(), lastError: null } + : { lastError: new Date().toISOString(), lastErrorMessage: result.error }), + }; } - } catch (e) { - console.error(e); + } catch (error) { + toast(`Source test failed: ${error?.message || error}`, 'danger'); + } finally { + _testing = false; + render(); } - _testing = false; - render(); +} + +async function openArchiveFolder() { + try { + const path = await revealArchive(); + toast(`Opened ${path}`, 'success'); + } catch (error) { + toast(`Could not open archive: ${error?.message || error}`, 'danger'); + } +} + +function archiveHealth() { + const health = archiveWorkspace.health; + return h('div', { class: 'settings-archive-health panel' }, [ + h('div', { class: 'spread' }, [ + h('strong', null, ['Index health']), + h('span', { class: `badge${health.status === 'error' ? ' bad' : ''}` }, [health.status]), + ]), + h('dl', { class: 'settings-status-list small' }, [ + statusRow('Records indexed', `${health.indexed_count} / ${health.source_count}`), + statusRow('Read errors', String(health.error_count)), + statusRow('Automatic refresh', health.watcher), + statusRow('Last rebuild', health.last_rebuild_at ? humanDate(health.last_rebuild_at) : 'Not yet'), + ]), + health.annotation_error ? h('p', { class: 'bad small', role: 'alert' }, [health.annotation_error]) : null, + h('button', { + class: 'btn btn-ghost', type: 'button', + onclick: async () => { + try { + await archiveWorkspace.rebuild(true); + toast('Archive index rebuilt', 'success'); + render(); + } catch (error) { + toast(`Archive rebuild failed: ${error?.message || error}`, 'danger'); + } + }, + }, ['Rebuild index']), + ]); } diff --git a/src/styles/archive.css b/src/styles/archive.css index 5555ac7..11f7f2d 100644 --- a/src/styles/archive.css +++ b/src/styles/archive.css @@ -1,31 +1,245 @@ -/* Archive browser + Settings modal */ +/* Archive v2 */ + +.archive-v2 { --archive-max: 1440px; } + +.archive-header, +.archive-project-timeline, +.archive-viewbar, +.archive-filter-panel, +.archive-active-filters, +.archive-health-notice, +.archive-on-this-day, +.archive-selection-bar, +.archive-workspace { + width: min(var(--archive-max), calc(100% - 2 * var(--space-5))); + margin-inline: auto; +} .archive-header { - max-width: 1200px; - margin: 0 auto; - padding: var(--space-7) var(--space-5) var(--space-3); + padding-block: var(--space-7) var(--space-4); display: flex; justify-content: space-between; align-items: flex-start; + gap: var(--space-5); +} + +.archive-project-timeline { + margin-top: var(--space-4); + display: grid; + gap: var(--space-4); + border-left: 3px solid var(--accent); +} +.archive-project-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); +} +.archive-project-heading h2, +.archive-project-heading p { margin: 3px 0 0; } +.archive-project-steps { + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--space-2); + margin: 0; + padding: 0; +} +.archive-project-step { + min-width: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-2); + align-items: start; + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--bg); +} +.archive-project-step .btn { grid-column: 2; justify-self: start; } +.archive-project-step-number { + width: 24px; + height: 24px; + display: grid; + place-items: center; + border: 1px solid var(--border-strong); + border-radius: 50%; + color: var(--text-secondary); + font: var(--size-xs) var(--font-mono); +} +.archive-project-step.is-completed .archive-project-step-number { color: var(--success); border-color: var(--success); } +.archive-project-step-copy { min-width: 0; display: grid; gap: 4px; } +.archive-project-step-copy span { overflow-wrap: anywhere; } +.archive-project-relation { display: grid !important; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); } +.archive-project-relation-copy { display: grid; min-width: 0; } + +@media (max-width: 620px) { + .archive-project-heading { flex-direction: column; } + .archive-project-relation { grid-template-columns: 1fr !important; } +} +.archive-header-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } + +.archive-viewbar { + display: flex; + gap: 4px; + padding: 4px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-3); + overflow-x: auto; +} +.archive-view-option { + appearance: none; + border: 0; + border-radius: var(--radius-2); + background: transparent; + color: var(--text-secondary); + padding: 9px 16px; + font: inherit; + white-space: nowrap; + cursor: pointer; +} +.archive-view-option:hover { color: var(--text-primary); background: var(--surface-raised); } +.archive-view-option[aria-pressed='true'] { color: var(--text-primary); background: var(--accent-soft); box-shadow: var(--inset-1); } +.archive-view-option:focus-visible { outline: 2px solid var(--highlight); outline-offset: 1px; } + +.archive-filter-panel { + margin-top: var(--space-3); + display: grid; + grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 0.8fr)) auto auto auto auto; + gap: var(--space-2); + align-items: center; +} +.archive-search, .archive-filter-control { min-width: 0; } +.archive-search, .archive-filter-control { display: grid; gap: 5px; } +.archive-filter-label { color: var(--text-primary); font-size: var(--size-sm); } +.archive-filter-toggle { + min-height: 42px; + border: 1px solid var(--border); + border-radius: var(--radius-2); + color: var(--text-secondary); + background: var(--surface); + padding: 8px 12px; + font: inherit; + cursor: pointer; + white-space: nowrap; +} +.archive-filter-toggle:hover { border-color: var(--border-strong); color: var(--text-primary); } +.archive-filter-toggle.active { color: var(--accent); background: var(--accent-soft); border-color: transparent; } +.archive-filter-toggle:focus-visible { outline: 2px solid var(--highlight); outline-offset: 2px; } +.archive-more-filters { position: relative; } +.archive-more-filters > summary { + min-height: 42px; + display: grid; + place-items: center; + padding: 8px 12px; + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--surface); + cursor: pointer; + white-space: nowrap; +} +.archive-more-filters[open] > summary { color: var(--text-primary); border-color: var(--border-strong); } +.archive-more-filter-grid { + position: absolute; + z-index: 8; + right: 0; + top: calc(100% + var(--space-2)); + width: min(620px, calc(100vw - 2 * var(--space-5))); + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-3); + padding: var(--space-4); + background: var(--surface-raised); + border: 1px solid var(--border-strong); + border-radius: var(--radius-3); + box-shadow: var(--shadow-2); } +.archive-date-control { display: grid; gap: 4px; } -.archive-filter { - max-width: 1200px; - margin: 0 auto; - padding: 0 var(--space-5); - gap: 12px; +.archive-active-filters { + margin-top: var(--space-3); + display: flex; + gap: var(--space-2); + align-items: center; flex-wrap: wrap; } +.archive-filter-chip, .archive-tag { + border: 1px solid transparent; + border-radius: 999px; + background: var(--highlight-soft); + color: var(--highlight); + padding: 5px 10px; + font: inherit; + font-size: var(--size-xs); +} +.archive-filter-chip { cursor: pointer; } +.archive-filter-chip:hover { border-color: var(--highlight); } +.archive-clear-filters { padding-block: 5px; } + +.archive-health-notice { + margin-top: var(--space-3); + padding: var(--space-3) var(--space-4); + border-left: 2px solid var(--danger); + background: color-mix(in srgb, var(--danger) 8%, transparent); + color: var(--text-secondary); +} +.archive-health-notice p { margin: 0; } +.archive-health-notice p + p { margin-top: var(--space-2); } + +.archive-on-this-day { + margin-top: var(--space-4); + display: grid; + grid-template-columns: minmax(150px, 0.4fr) minmax(0, 1fr); + gap: var(--space-5); + align-items: center; + padding: var(--space-4) var(--space-5); +} +.archive-on-this-day h2 { margin: 3px 0 0; font-size: var(--size-lg); } +.archive-memory-list { display: flex; gap: var(--space-2); overflow-x: auto; padding: 2px; } +.archive-memory { + min-width: min(240px, 70vw); + display: flex; + justify-content: space-between; + gap: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--bg); + color: var(--text-primary); + padding: var(--space-3); + text-align: left; + cursor: pointer; +} -.archive-body { - max-width: 1200px; - margin: var(--space-5) auto; - padding: 0 var(--space-5) var(--space-7); +.archive-selection-bar { + position: sticky; + top: var(--space-3); + z-index: 7; + margin-top: var(--space-4); + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + background: color-mix(in srgb, var(--surface-raised) 94%, transparent); + border: 1px solid var(--border-strong); + border-radius: var(--radius-3); + box-shadow: var(--shadow-2); + backdrop-filter: blur(12px); +} + +.archive-workspace { + margin-top: var(--space-5); + padding-bottom: var(--space-7); display: grid; - grid-template-columns: 1fr 1.2fr; + grid-template-columns: minmax(0, 1fr); gap: var(--space-5); + align-items: start; } -@media (max-width: 1000px) { .archive-body { grid-template-columns: 1fr; } } +.archive-workspace.has-detail { grid-template-columns: minmax(460px, 1.35fr) minmax(360px, 0.85fr); } +.archive-results { min-width: 0; } +.archive-state { min-height: 320px; } .archive-list { list-style: none; @@ -37,94 +251,240 @@ overflow: hidden; } .archive-row { + position: relative; display: grid; - grid-template-columns: 88px 1fr auto; - gap: 12px; - padding: 10px 16px; - align-items: baseline; - cursor: pointer; + grid-template-columns: 42px minmax(0, 1fr); border-bottom: 1px solid var(--border); - font-size: var(--size-sm); } -.archive-row:last-child { border-bottom: none; } -.archive-row:hover { background: var(--surface-raised); } -.archive-row.active { background: var(--surface-raised); border-left: 2px solid var(--accent); } -.archive-chamber { color: var(--text-faint); letter-spacing: 0.1em; text-transform: uppercase; } +.archive-row:last-child { border-bottom: 0; } +.archive-row:hover, .archive-row.active { background: var(--surface-raised); } +.archive-row.active::before { content: ''; position: absolute; inset-block: 0; left: 0; width: 2px; background: var(--accent); } +.archive-row.error { background: color-mix(in srgb, var(--danger) 5%, var(--surface)); } +.archive-row-main { + appearance: none; + border: 0; + background: transparent; + color: inherit; + min-width: 0; + padding: 12px var(--space-4) 12px 4px; + display: grid; + grid-template-columns: 86px minmax(100px, 1fr) auto auto; + gap: var(--space-3); + align-items: center; + text-align: left; + cursor: pointer; +} +.archive-row-main:focus-visible { outline: 2px solid var(--highlight); outline-offset: -3px; } +.archive-select-record { display: grid; place-items: center; cursor: pointer; } +.archive-select-record input { width: 17px; height: 17px; accent-color: var(--accent); } +.archive-chamber { color: var(--text-faint); letter-spacing: 0.08em; text-transform: uppercase; overflow: hidden; text-overflow: ellipsis; } .archive-summary { color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.archive-time { color: var(--text-faint); } -.archive-list.empty { background: transparent; border: 1px dashed var(--border); padding: 40px; } +.archive-row-badges { display: flex; gap: 4px; } +.archive-time { white-space: nowrap; } -.archive-detail-empty { - background: var(--surface); - border: 1px dashed var(--border); +.archive-gallery { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--space-4); +} +.archive-gallery-card { + position: relative; + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); border-radius: var(--radius-3); - padding: 60px; - text-align: center; + background: var(--surface); +} +.archive-gallery-card:hover, .archive-gallery-card.active { border-color: var(--border-strong); box-shadow: var(--shadow-1); } +.archive-gallery-card > .archive-select-record { + position: absolute; + z-index: 2; + top: var(--space-2); + right: var(--space-2); + width: 34px; + height: 34px; + border-radius: 999px; + background: color-mix(in srgb, var(--surface) 88%, transparent); +} +.archive-gallery-main { appearance: none; border: 0; padding: 0; width: 100%; background: transparent; color: inherit; text-align: left; cursor: pointer; } +.archive-gallery-main:focus-visible { outline: 2px solid var(--highlight); outline-offset: -3px; } +.archive-thumbnail { + position: relative; + aspect-ratio: 16 / 10; + display: grid; + place-items: center; + overflow: hidden; + background: + radial-gradient(circle at var(--thumb-orbit) var(--thumb-orbit), hsl(var(--thumb-hue-2) 75% 68% / 0.42) 0 3%, transparent 4%), + linear-gradient(var(--thumb-angle), hsl(var(--thumb-hue) 38% 20%), hsl(var(--thumb-hue-2) 42% 9%)); + color: hsl(var(--thumb-hue-2) 80% 80%); } +.archive-thumbnail::before, .archive-thumbnail::after { content: ''; position: absolute; border: 1px solid currentColor; border-radius: 50%; opacity: 0.28; } +.archive-thumbnail::before { width: 60%; aspect-ratio: 1; } +.archive-thumbnail::after { width: 32%; aspect-ratio: 1; transform: translate(32%, -12%); } +.archive-thumbnail span { position: relative; z-index: 1; font-size: clamp(2rem, 6vw, 4rem); text-shadow: 0 6px 22px rgb(0 0 0 / 0.45); } +.archive-thumbnail-error { background: color-mix(in srgb, var(--danger) 18%, var(--bg)); color: var(--danger); font-size: 3rem; } +.archive-gallery-copy { display: grid; gap: 4px; padding: var(--space-3) var(--space-4) var(--space-4); } +.archive-gallery-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.archive-detail { padding: var(--space-5); } -.archive-detail pre { font-size: var(--size-sm); line-height: 1.5; } -.archive-diary-text { +.archive-detail { + position: sticky; + top: var(--space-4); + max-height: calc(100vh - 2 * var(--space-4)); + overflow: auto; + min-width: 0; +} +.archive-detail-close { float: right; margin: calc(-1 * var(--space-2)) calc(-1 * var(--space-2)) var(--space-2) var(--space-2); } +.archive-detail-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-4); clear: both; } +.archive-detail-heading h2 { margin: var(--space-2) 0 0; overflow-wrap: anywhere; } +.archive-detail-kicker, .archive-detail-actions, .archive-tag-list { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; } +.archive-tag-list { margin-top: var(--space-3); } +.archive-detail-facts { display: grid; grid-template-columns: minmax(100px, auto) minmax(0, 1fr); gap: var(--space-2) var(--space-4); margin: var(--space-5) 0; } +.archive-detail-facts dt { color: var(--text-faint); } +.archive-detail-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; color: var(--text-primary); } +.archive-pack-reference { margin-block: var(--space-5); padding-block: var(--space-4); border-block: 1px solid var(--border); } +.archive-pack-reference h3 { margin: 0; } +.archive-pack-reference .archive-detail-facts { margin-block: var(--space-3); } +.archive-pack-snapshot pre { max-height: 280px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } +.archive-detail h3 { margin: var(--space-5) 0 var(--space-3); font-size: var(--size-md); } +.archive-detail ul { list-style: none; margin: 0; padding: 0; } +.archive-diary-text, .archive-raw pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-2); - padding: 12px; + padding: var(--space-3); + font: inherit; + font-family: var(--font-mono); + font-size: var(--size-sm); line-height: 1.6; white-space: pre-wrap; - max-height: 480px; overflow: auto; + max-height: 420px; } +.archive-relation, .archive-asset { display: grid; gap: 4px; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-2); } +.archive-relation + .archive-relation, .archive-asset + .archive-asset { margin-top: var(--space-2); } +.archive-relation.dangling, .archive-asset.missing { border-color: color-mix(in srgb, var(--danger) 55%, var(--border)); } +.archive-relation-kind { color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; } +.archive-relation-link { appearance: none; border: 0; background: transparent; color: var(--highlight); padding: 0; width: fit-content; font: inherit; text-align: left; cursor: pointer; } +.archive-relation-link:hover { text-decoration: underline; } +.archive-raw { margin-top: var(--space-5); } +.archive-raw summary { cursor: pointer; color: var(--text-secondary); } +.archive-hide-action { margin-top: var(--space-5); color: var(--text-faint); } -/* Settings overlay */ -.settings-overlay { - position: fixed; - inset: 0; - background: color-mix(in srgb, var(--bg) 70%, transparent); - backdrop-filter: blur(6px); - z-index: 200; - display: flex; - align-items: flex-start; - justify-content: center; - padding: 64px 24px; - overflow: auto; -} -.settings-panel { +.archive-calendar-layout { display: grid; gap: var(--space-5); } +.archive-calendar { padding: var(--space-4); } +.archive-calendar-header { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: var(--space-3); } +.archive-calendar-month { margin: 0; text-align: center; font-size: var(--size-lg); } +.archive-calendar-weekdays, .archive-calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); } +.archive-calendar-weekdays { margin-top: var(--space-4); color: var(--text-faint); font-size: var(--size-xs); text-align: center; text-transform: uppercase; letter-spacing: 0.07em; } +.archive-calendar-weekdays span { padding: var(--space-2); } +.archive-calendar-cell { min-width: 0; } +.archive-calendar-day { + appearance: none; + position: relative; width: 100%; - max-width: 720px; - background: var(--surface); + min-height: 72px; + display: grid; + align-content: space-between; border: 1px solid var(--border); - border-radius: var(--radius-3); - padding: var(--space-5); - display: flex; - flex-direction: column; - gap: var(--space-5); - box-shadow: var(--shadow-2); + border-width: 1px 0 0 1px; + background: transparent; + color: var(--text-secondary); + padding: var(--space-2); + font: inherit; + text-align: left; + cursor: pointer; } +.archive-calendar-cell:nth-child(7n) .archive-calendar-day { border-right-width: 1px; } +.archive-calendar-cell:nth-last-child(-n + 7) .archive-calendar-day { border-bottom-width: 1px; } +.archive-calendar-day:hover { background: var(--surface-raised); } +.archive-calendar-day.other-month { opacity: 0.42; } +.archive-calendar-day.has-records { color: var(--text-primary); background: var(--highlight-soft); } +.archive-calendar-day.selected { box-shadow: inset 0 0 0 2px var(--accent); z-index: 1; } +.archive-calendar-day:focus-visible { outline: 2px solid var(--highlight); outline-offset: -3px; z-index: 2; } +.archive-calendar-count { justify-self: end; min-width: 22px; height: 22px; display: grid; place-items: center; border-radius: 99px; background: var(--accent); color: #1a1d24; font-size: var(--size-xs); } +.archive-calendar-agenda h3 { margin-top: 0; } + +.archive-collections { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-4); } +.archive-collection-card h2 { margin: 3px 0 0; } +.archive-collection-card p { min-height: 2.8em; } + +.archive-pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-4); margin-top: var(--space-4); } + +.archive-annotation-dialog .dialog-panel { width: min(660px, 100%); } +.archive-dialog-field { display: grid; gap: var(--space-2); margin-bottom: var(--space-4); } +.archive-collection-field { border: 0; margin: 0; padding: 0; } +.archive-collection-checks { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-2); margin-bottom: var(--space-3); } +.archive-collection-check { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-2); } +.archive-dialog-actions { display: flex; justify-content: flex-end; margin-top: var(--space-5); } +.settings-archive-health { margin-top: var(--space-4); } +.settings-archive-health .settings-status-list { margin-block: var(--space-3); } -.settings-section { - border-top: 1px solid var(--border); - padding-top: var(--space-4); +@media (max-width: 1250px) { + .archive-filter-panel { grid-template-columns: minmax(220px, 2fr) repeat(3, minmax(130px, 1fr)); } + .archive-filter-toggle, .archive-more-filters { min-width: 0; } } -.settings-section:first-of-type { border-top: none; padding-top: 0; } -.settings-section-title { - font-size: var(--size-lg); - font-weight: 400; - margin: 0 0 8px; - color: var(--text-primary); + +@media (max-width: 1000px) { + .archive-workspace.has-detail { grid-template-columns: 1fr; } + .archive-detail { position: static; max-height: none; } + .archive-filter-panel { grid-template-columns: minmax(200px, 2fr) repeat(2, minmax(130px, 1fr)); } + .archive-row-main { grid-template-columns: 78px minmax(100px, 1fr) auto; } + .archive-row-badges { display: none; } } -.sources-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 4px; } -.source-row { - display: grid; - grid-template-columns: 1fr 1fr auto; - gap: 12px; - padding: 10px 12px; - background: var(--surface-raised); - border-radius: var(--radius-2); - align-items: center; +@media (max-width: 700px) { + .archive-header, + .archive-viewbar, + .archive-filter-panel, + .archive-active-filters, + .archive-health-notice, + .archive-on-this-day, + .archive-selection-bar, + .archive-workspace { width: min(100% - 2 * var(--space-3), var(--archive-max)); } + .archive-header { padding-top: var(--space-5); flex-direction: column; justify-content: flex-start; gap: var(--space-4); } + .archive-header .chamber-title-big { margin: var(--space-2) 0; } + .archive-header .chamber-tagline { margin: 0; } + .archive-header-actions { justify-content: flex-start; } + .archive-viewbar { scroll-snap-type: x proximity; } + .archive-view-option { flex: 1 0 auto; scroll-snap-align: start; } + .archive-filter-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .archive-search { grid-column: 1 / -1; } + .archive-filter-toggle, .archive-more-filters { width: 100%; } + .archive-more-filters { grid-column: 1 / -1; } + .archive-more-filter-grid { position: fixed; inset: auto var(--space-3) var(--space-3); width: auto; grid-template-columns: 1fr; max-height: 70vh; overflow: auto; } + .archive-on-this-day { grid-template-columns: 1fr; } + .archive-selection-bar { flex-wrap: wrap; } + .archive-row-main { grid-template-columns: 1fr auto; gap: 4px var(--space-2); } + .archive-chamber { grid-column: 1; grid-row: 1; } + .archive-summary { grid-column: 1 / -1; grid-row: 2; } + .archive-time { grid-column: 2; grid-row: 1; } + .archive-gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } + .archive-calendar { padding: var(--space-3); overflow-x: auto; } + .archive-calendar-weekdays, .archive-calendar-grid { min-width: 560px; } + .archive-calendar-day { min-height: 64px; } + .archive-detail-heading { flex-direction: column; } + .archive-collection-checks { grid-template-columns: 1fr; } +} + +@media (max-width: 430px) { + .archive-filter-panel { grid-template-columns: 1fr; } + .archive-search { grid-column: auto; } + .archive-gallery { grid-template-columns: 1fr; } + .archive-pagination { flex-wrap: wrap; gap: var(--space-2); } + .archive-detail { padding: var(--space-4); } + .archive-detail-facts { grid-template-columns: 1fr; gap: 2px; } + .archive-detail-facts dd + dt { margin-top: var(--space-2); } +} + +@media (forced-colors: active) { + .archive-thumbnail { background: Canvas; color: CanvasText; border-bottom: 1px solid CanvasText; } + .archive-calendar-day.has-records, .archive-filter-toggle.active, .archive-view-option[aria-pressed='true'] { outline: 2px solid Highlight; } +} + +@media (prefers-reduced-transparency: reduce) { + .archive-selection-bar { backdrop-filter: none; background: var(--surface-raised); } } -.source-meta .source-name { font-size: var(--size-md); } -.source-status .ok { color: var(--success); } -.source-status .bad { color: var(--danger); } -.toggle { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: var(--size-sm); color: var(--text-secondary); } -.credits { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-direction: column; gap: 6px; } diff --git a/src/styles/base.css b/src/styles/base.css index 732cb6f..0a9ec11 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -10,7 +10,7 @@ html, body, #app { html { font-family: var(--font-sans); - font-size: var(--size-base); + font-size: 16px; font-feature-settings: 'cv11', 'ss01'; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; @@ -20,6 +20,7 @@ html { body { background: var(--bg); color: var(--text-primary); + font-size: var(--size-base); line-height: 1.55; overflow: hidden; /* shell controls its own scrolling */ overflow-x: hidden; /* belt + suspenders: never let content force horizontal scroll */ @@ -141,3 +142,15 @@ code, pre, .mono { border: 2px solid var(--bg); } ::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } +/* Visually hidden but available to assistive technology. */ +.sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} diff --git a/src/styles/chambers/beacon.css b/src/styles/chambers/beacon.css index e3dfd98..0ca7e42 100644 --- a/src/styles/chambers/beacon.css +++ b/src/styles/chambers/beacon.css @@ -1,17 +1,41 @@ /* Beacon chamber */ +.beacon-frame { + display: flex; + flex-direction: column; +} + +.beacon-frame > .beacon-workflow { order: 1; } +.beacon-frame.has-result > .chamber-intro { order: 2; } + +.beacon-workflow { + display: grid; + gap: var(--space-4); +} + .beacon-panel { padding: var(--space-5); + transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), padding var(--t-fast) var(--ease); } + +.beacon-workflow.has-result .beacon-panel { + padding: var(--space-3) var(--space-4); + background: transparent; + border-color: var(--border); + box-shadow: none; +} + +.beacon-workflow.has-result .beacon-panel > p { margin-bottom: var(--space-2); } +.beacon-compose-actions { gap: var(--space-3); margin-top: var(--space-5); } .beacon-textarea { font-family: var(--font-mono); line-height: 1.7; } -.sealed-view { margin-top: var(--space-5); } +.sealed-view { margin-top: var(--space-2); } .sealed-badge { - padding: var(--space-5); + padding: 0; } .sealed-pulse-id { font-size: var(--size-2xl); @@ -57,3 +81,11 @@ .verify-result.success { border-color: var(--success); } .verify-result.fail { border-color: var(--danger); } +.verify-result .state-panel, +.beacon-error .state-panel { + border: 0; + background: transparent; + box-shadow: none; +} + +.beacon-loading { min-height: 220px; align-content: center; } diff --git a/src/styles/chambers/canvas.css b/src/styles/chambers/canvas.css index 7f56703..21336ff 100644 --- a/src/styles/chambers/canvas.css +++ b/src/styles/chambers/canvas.css @@ -1,5 +1,10 @@ /* Canvas chamber */ +.canvas-frame { + display: flex; + flex-direction: column; +} + .canvas-header { max-width: 1200px; margin: 0 auto; @@ -7,11 +12,22 @@ } .canvas-config { + order: 2; max-width: 1200px; margin: 0 auto; padding: 0 var(--space-5); + width: 100%; + transition: padding var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease); } +.canvas-frame.has-result .canvas-config { + padding-block: var(--space-3); + border-top: 1px solid var(--border); +} + +.canvas-frame.has-result .canvas-type-pill { padding: var(--space-2) var(--space-3); } +.canvas-frame.has-result .canvas-type-pill .small { display: none; } + .canvas-type-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); @@ -42,9 +58,11 @@ } .canvas-result { + order: 1; max-width: 1200px; margin: var(--space-6) auto; - padding: 0 var(--space-5) var(--space-7); + padding: var(--space-5); + width: calc(100% - var(--space-5) - var(--space-5)); } .canvas-art-stage { @@ -52,7 +70,6 @@ border: 1px solid var(--border); border-radius: var(--radius-3); padding: 8px; - margin-bottom: var(--space-4); } .canvas-art { width: 100%; @@ -63,11 +80,11 @@ .canvas-art .canvas-svg { width: 100%; height: auto; - max-height: 70vh; + max-height: 36vh; display: block; } -.canvas-meta { padding: var(--space-5); } +.canvas-meta { padding: 0; } .canvas-title { font-size: var(--size-xl); font-weight: 300; @@ -77,7 +94,11 @@ } .canvas-empty { + order: 1; max-width: 600px; margin: var(--space-7) auto; text-align: center; } + +.canvas-loading { min-height: 320px; align-content: center; } +.canvas-error .state-panel { border: 0; background: transparent; box-shadow: none; } diff --git a/src/styles/chambers/constraint.css b/src/styles/chambers/constraint.css index b9d106a..93eb2a5 100644 --- a/src/styles/chambers/constraint.css +++ b/src/styles/chambers/constraint.css @@ -1,6 +1,34 @@ /* Constraint chamber */ -.constraint-categories { padding: var(--space-5); } +.constraint-frame { + display: flex; + flex-direction: column; +} + +.constraint-categories { + order: 2; + padding: var(--space-5); + transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), padding var(--t-fast) var(--ease); +} + +.constraint-draw-action { order: 3; } + +.constraint-frame.has-result .constraint-categories { + order: 3; + padding: var(--space-3); + background: transparent; + border-color: var(--border); + box-shadow: none; +} + +.constraint-frame.has-result .constraint-categories > .label { + color: var(--text-secondary); +} + +.constraint-frame.has-result .category-pill { padding: var(--space-2) var(--space-3); } +.constraint-frame.has-result .cat-desc { display: none; } +.constraint-frame.has-result > .chamber-intro { order: 2; } +.constraint-frame.has-result .constraint-draw-action { order: 4; display: none; } .category-grid { display: grid; @@ -42,7 +70,14 @@ font-size: var(--size-xs); } -.constraint-result { padding: var(--space-6); } +.constraint-result { + order: 1; + padding: var(--space-6); + margin-block: var(--space-4); +} + +.constraint-loading { min-height: 180px; place-content: center; } +.constraint-error { color: var(--danger); } .constraint-text { font-size: var(--size-2xl); font-weight: 300; diff --git a/src/styles/chambers/decider.css b/src/styles/chambers/decider.css index 3f88413..2db747d 100644 --- a/src/styles/chambers/decider.css +++ b/src/styles/chambers/decider.css @@ -14,8 +14,14 @@ margin: 0 auto; padding: 0 var(--space-5) var(--space-7); } +.decider-body.has-result { grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr); } +.decider-body.has-result .decider-input { + align-self: start; + background: color-mix(in srgb, var(--surface) 62%, transparent); +} @media (max-width: 1100px) { .decider-body { grid-template-columns: 1fr; } + .decider-body.has-result { grid-template-columns: 1fr; } } .decider-input .label { color: var(--text-secondary); margin-bottom: var(--space-2); } @@ -81,6 +87,7 @@ padding: var(--space-6); position: relative; } +.cert.result-stage { border-color: var(--chamber-accent); gap: var(--space-4); } .cert::before { content: ''; position: absolute; @@ -138,3 +145,19 @@ margin-top: 6px; word-break: break-word; } +.decider-receipt-details { + margin-top: var(--space-4); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.decider-receipt-details > summary { + cursor: pointer; + padding: var(--space-3) 0; + color: var(--text-secondary); + font-weight: 600; +} +.decider-receipt-details-body { + display: grid; + gap: var(--space-3); + padding: 0 0 var(--space-4); +} diff --git a/src/styles/chambers/diary.css b/src/styles/chambers/diary.css index 2633027..462168d 100644 --- a/src/styles/chambers/diary.css +++ b/src/styles/chambers/diary.css @@ -27,6 +27,13 @@ margin: 0 auto; padding: 0 var(--space-5) var(--space-7); } +.diary-has-prompt { + display: flex; + flex-direction: column; +} +.diary-has-prompt .diary-header { order: 1; width: 100%; } +.diary-has-prompt .diary-body { order: 2; width: 100%; } +.diary-has-prompt .chamber-intro { order: 3; width: min(calc(100% - 2 * var(--space-5)), 1200px); margin-inline: auto; margin-bottom: var(--space-7); } @media (max-width: 980px) { .diary-body { grid-template-columns: 1fr; } } .diary-no-prompt { @@ -91,7 +98,29 @@ } .diary-provenance .provenance { margin-top: 10px; } -.diary-entry-area { margin-top: var(--space-5); } +.diary-entry-area { margin-top: 0; } +.diary-writing-context { display: grid; gap: var(--space-2); } +.diary-writing-question { + margin: 0; + color: var(--text-primary); + font-size: var(--size-xl); + font-weight: 300; + line-height: 1.35; +} +.diary-writing-meta { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); } +.diary-writing-meta > span:not(:last-child)::after { content: '·'; margin-left: var(--space-2); color: var(--text-faint); } +.diary-writing-color { width: 18px; height: 18px; border: 1px solid var(--border-strong); border-radius: 50%; } +.diary-prompt-details { margin-top: var(--space-4); } +.diary-prompt-details > summary { + cursor: pointer; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--surface); + color: var(--text-secondary); + font-weight: 600; +} +.diary-prompt-details[open] > summary { margin-bottom: var(--space-3); } .diary-textarea { font-family: var(--font-mono); line-height: 1.7; diff --git a/src/styles/chambers/lottery.css b/src/styles/chambers/lottery.css index 594edfd..2fadfa9 100644 --- a/src/styles/chambers/lottery.css +++ b/src/styles/chambers/lottery.css @@ -120,10 +120,10 @@ padding: 8px 6px; font-size: var(--size-sm); border-radius: var(--radius-2); - cursor: pointer; align-items: baseline; } -.recent-row:hover { background: var(--surface-raised); } +.recent-row[role='button'] { cursor: pointer; } +.recent-row[role='button']:hover { background: var(--surface-raised); } .recent-type { color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.1em; font-size: var(--size-xs); } .recent-summary { color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .recent-time { color: var(--text-faint); } @@ -135,6 +135,26 @@ .picker-intro { margin-bottom: var(--space-3); } .lottery-result { margin-top: var(--space-3); } +.lottery-picker.has-result > * { order: 2; } +.lottery-picker.has-result > .picker-intro { order: 0; } +.lottery-picker.has-result > .lottery-result { order: 1; } + +.lottery-result.result-stage { + padding: var(--space-5); + margin: 0 0 var(--space-3); + border-color: var(--chamber-accent); +} + +.lottery-result .state-panel { + border: 0; + background: transparent; + box-shadow: none; +} + +.lottery-picker.has-result > :not(.picker-intro):not(.lottery-result) { + position: relative; +} + /* Coin */ .coin-stage { display: flex; @@ -265,7 +285,7 @@ font-size: var(--size-sm); opacity: 0; transition: opacity var(--t-fast) var(--ease), transform var(--t-fast) var(--ease); - z-index: 100; + z-index: 300; } .toast.visible { opacity: 1; diff --git a/src/styles/chambers/oracle.css b/src/styles/chambers/oracle.css index 5007709..9c51fe3 100644 --- a/src/styles/chambers/oracle.css +++ b/src/styles/chambers/oracle.css @@ -155,6 +155,15 @@ flex-direction: column; gap: var(--space-5); } +.oracle-has-result .oracle-header { padding-bottom: var(--space-2); } +.oracle-has-result .oracle-result { margin-top: var(--space-2); } +.oracle-result-summary { + position: relative; + z-index: 1; + padding-bottom: var(--space-3); + border-bottom: 1px solid var(--border); +} +.oracle-result.result-stage { padding: var(--space-5); } .oracle-card { padding: 0; overflow: hidden; @@ -177,14 +186,34 @@ } .oracle-card-art { + appearance: none; + width: 100%; display: flex; align-items: center; justify-content: center; + position: relative; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-2); padding: 12px; min-height: 380px; + color: inherit; + font: inherit; + cursor: zoom-in; + transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease); +} +.oracle-card-art:hover { border-color: var(--chamber-accent); background: var(--surface-raised); } +.oracle-art-expand { + position: absolute; + right: var(--space-3); + bottom: var(--space-3); + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: var(--size-xs); } .oracle-illustration { width: 100%; @@ -218,3 +247,9 @@ .oracle-card-provenance { font-size: var(--size-sm); } .oracle-reflection { padding: var(--space-5); } + +.oracle-art-dialog .dialog-panel { width: min(760px, 100%); } +.oracle-art-dialog .dialog-content { padding: var(--space-4); } +.oracle-art-focus { display: grid; justify-items: center; gap: var(--space-3); margin: 0; color: var(--chamber-accent); } +.oracle-art-focus .oracle-illustration { width: min(62vh, 640px); max-width: 100%; } +.oracle-art-focus figcaption { color: var(--text-secondary); font-size: var(--size-md); } diff --git a/src/styles/chambers/symphony.css b/src/styles/chambers/symphony.css index a0d0ba2..bb9ebc7 100644 --- a/src/styles/chambers/symphony.css +++ b/src/styles/chambers/symphony.css @@ -17,11 +17,40 @@ grid-template-columns: 1.4fr 1fr; gap: var(--space-5); } + +.symphony-daily-motif { + max-width: 1150px; + margin: 0 auto var(--space-5); + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(220px, 1.3fr) auto; + gap: var(--space-4); + align-items: center; + border-style: dashed; +} +.symphony-daily-motif h2 { margin: 0; } +.symphony-motif-notes { + min-height: 74px; + display: flex; + align-items: flex-end; + gap: 7px; +} +.symphony-motif-notes span { + display: block; + min-width: 10px; + max-width: 28px; + border-radius: 999px 999px 3px 3px; + background: var(--chamber-accent); + opacity: 0.72; +} +@media (max-width: 760px) { + .symphony-daily-motif { grid-template-columns: 1fr; } +} +.symphony-controls { align-self: start; } @media (max-width: 1000px) { .symphony-body { grid-template-columns: 1fr; } } .symphony-stage { background: var(--surface); - border: 1px solid var(--border); + border: 1px var(--chamber-line-style) var(--chamber-accent); border-radius: var(--radius-3); padding: var(--space-3); } @@ -98,6 +127,48 @@ } .sym-libretto { margin-top: 24px; } +.sym-replay { + margin-top: var(--space-5); + padding-top: var(--space-4); + border-top: 1px solid var(--border); + display: grid; + gap: var(--space-3); +} +.sym-replay.empty { gap: var(--space-1); } +.sym-replay.empty p { margin: 0; } +.sym-replay-buttons, +.sym-export-buttons { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; +} +.sym-speed { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: var(--space-2); +} +.sym-speed select { + min-width: 76px; + min-height: 36px; +} +.sym-seek { + display: grid; + grid-template-columns: minmax(120px, 1fr) auto; + align-items: center; + gap: var(--space-3); +} +.sym-seek input { margin: 0; } +.sym-log-row.is-current { + color: var(--text-primary); + background: var(--chamber-accent-soft); + box-shadow: inset 2px 0 0 var(--chamber-accent); +} +@media (max-width: 540px) { + .sym-speed { margin-left: 0; width: 100%; justify-content: space-between; } + .sym-seek { grid-template-columns: 1fr; gap: var(--space-1); } +} .sym-log { list-style: none; padding: 0; @@ -106,16 +177,38 @@ flex-direction: column; gap: 4px; max-height: 360px; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; } .sym-log-row { display: grid; - grid-template-columns: 84px 1fr; + grid-template-columns: 84px minmax(72px, auto) 1fr; gap: 8px; padding: 4px 6px; font-size: var(--size-sm); align-items: baseline; } +.sym-log-row > span:last-child { + min-width: 0; + overflow-wrap: anywhere; +} + +.sym-source { + width: fit-content; + max-width: 100%; + padding: 1px 7px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-secondary); + background: var(--surface-raised); + font-size: var(--size-xs); + white-space: nowrap; +} + +@media (max-width: 620px) { + .sym-log-row { grid-template-columns: 72px 1fr; } + .sym-log-row > span:last-child { grid-column: 2; } +} input[type="range"] { width: 100%; diff --git a/src/styles/journal.css b/src/styles/journal.css new file mode 100644 index 0000000..1bb98f1 --- /dev/null +++ b/src/styles/journal.css @@ -0,0 +1,33 @@ +.journal-header { width: min(1480px, 100%); margin: 0 auto; padding: var(--space-7) var(--space-5) var(--space-4); display: flex; justify-content: space-between; gap: var(--space-5); align-items: end; } +.journal-selection-count { color: var(--text-secondary); } +.journal-layout { width: min(1480px, 100%); margin: 0 auto; padding: 0 var(--space-5) var(--space-7); display: grid; grid-template-columns: minmax(320px, 410px) minmax(0, 1fr); gap: var(--space-5); align-items: start; } +.journal-controls { display: grid; gap: var(--space-5); position: sticky; top: var(--space-3); max-height: calc(100vh - 120px); overflow: auto; } +.journal-options { display: grid; gap: var(--space-3); } +.journal-options > label:not(.journal-check-option) { display: grid; gap: var(--space-1); } +.journal-check-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2); align-items: start; } +.journal-check-option span { display: grid; gap: 2px; } +.journal-record-picker { min-height: 0; display: grid; gap: var(--space-2); } +.journal-picker-actions { display: flex; gap: var(--space-2); } +.journal-record-list { list-style: none; padding: 0; margin: 0; max-height: 330px; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-2); } +.journal-record-choice { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2); padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--border); cursor: pointer; } +.journal-record-choice:hover { background: var(--surface-raised); } +.journal-record-choice > span { min-width: 0; display: grid; } +.journal-record-choice strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } +.journal-pdf-note { margin: calc(var(--space-4) * -1) 0 0; } +.journal-preview { min-width: 0; display: grid; gap: var(--space-3); } +.journal-preview-heading { display: flex; justify-content: space-between; gap: var(--space-4); align-items: end; } +.journal-preview-heading h2 { margin: var(--space-1) 0 0; } +.journal-preview-frame { width: 100%; min-height: 920px; border: 1px solid var(--border); border-radius: var(--radius-3); background: #f8f4ec; box-shadow: var(--shadow-2); } +.journal-loading { width: min(960px, calc(100% - 2 * var(--space-5))); margin: 0 auto; } +@media (max-width: 1000px) { + .journal-layout { grid-template-columns: 1fr; } + .journal-controls { position: static; max-height: none; } + .journal-preview-frame { min-height: 760px; } +} +@media (max-width: 620px) { + .journal-header { padding-inline: var(--space-3); align-items: start; } + .journal-layout { padding-inline: var(--space-3); } + .journal-selection-count { display: none; } + .journal-preview-frame { min-height: 640px; } +} +@media (forced-colors: active) { .journal-preview-frame, .journal-record-list { border-color: CanvasText; } } diff --git a/src/styles/practices.css b/src/styles/practices.css new file mode 100644 index 0000000..3a1997f --- /dev/null +++ b/src/styles/practices.css @@ -0,0 +1,34 @@ +.practices-frame { --accent: #8dc8a7; --accent-soft: rgb(141 200 167 / 16%); max-width: 1500px; } +.practices-header, .practice-section-heading, .practice-plan-actions { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } +.practices-body { display: grid; gap: var(--space-6); } +.practice-section-heading { align-items: end; margin-bottom: var(--space-3); } +.practice-section-heading h2, .practice-plan-card h3 { margin: 0; } +.practice-kicker { color: var(--accent); font: 600 var(--size-xs) var(--font-mono); letter-spacing: .12em; text-transform: uppercase; } +.practice-starter-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); } +.practice-starter-card { min-width: 0; display: grid; align-content: start; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-lg); background: linear-gradient(145deg, var(--accent-soft), var(--surface)); } +.practice-starter-card h3, .practice-starter-card p { margin: 0; } +.practice-starter-card .btn { margin-top: auto; } +.practice-starter-mark { width: 42px; height: 42px; display: grid; place-items: center; border: 1px solid var(--accent); border-radius: 50%; color: var(--accent); font-size: var(--size-xl); } +.practice-plan-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.practice-plan-card { min-width: 0; display: grid; gap: var(--space-3); } +.practice-plan-card.is-stopped { opacity: .78; } +.practice-status { color: var(--accent); font: var(--size-xs) var(--font-mono); text-transform: uppercase; letter-spacing: .12em; } +.practice-assignment { display: grid; gap: var(--space-2); padding: var(--space-4); border-left: 3px solid var(--accent); border-radius: var(--radius-md); background: var(--accent-soft); } +.practice-assignment p { margin: 0; } +.practice-assignment.is-recorded { border-left-color: var(--success); } +.practice-plan-actions { justify-content: flex-start; flex-wrap: wrap; padding-top: var(--space-2); border-top: 1px solid var(--border); } +.practice-days { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: 0; padding: 0; border: 0; } +.practice-day-check { display: inline-flex; align-items: center; gap: 6px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-pill); } +.today-practice-callout { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; margin-top: var(--space-4); padding: var(--space-4); border: 1px solid var(--border); border-left: 3px solid #8dc8a7; border-radius: var(--radius-lg); background: rgb(141 200 167 / 9%); } +.today-practice-callout h2, .today-practice-callout p { margin: 0; } +.today-practice-callout-copy { display: grid; gap: var(--space-1); } +@media (max-width: 950px) { + .practice-starter-grid, .practice-plan-grid { grid-template-columns: 1fr; } +} +@media (max-width: 620px) { + .practices-header, .practice-section-heading { display: grid; } + .today-practice-callout { grid-template-columns: 1fr; } +} +@media (forced-colors: active) { + .practice-starter-card, .practice-assignment, .today-practice-callout { background: Canvas; border-color: CanvasText; } +} diff --git a/src/styles/projects.css b/src/styles/projects.css new file mode 100644 index 0000000..d842f47 --- /dev/null +++ b/src/styles/projects.css @@ -0,0 +1,190 @@ +/* Creative Projects */ + +.projects-frame { + width: min(1180px, 100%); + margin-inline: auto; + gap: var(--space-5); +} + +.projects-header, +.project-detail-header, +.project-detail-nav, +.project-section-heading, +.project-card-topline, +.project-step-card-heading, +.project-management, +.project-context-strip { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.projects-header { align-items: end; } +.projects-header > div:first-child { max-width: 760px; } +.projects-orbit { position: relative; width: 92px; height: 58px; flex: 0 0 auto; } +.projects-orbit::before, +.projects-orbit::after { + content: ''; + position: absolute; + border: 1px solid color-mix(in srgb, var(--accent) 54%, var(--border)); + border-radius: 50%; + transform: rotate(-18deg); +} +.projects-orbit::before { inset: 8px 2px; } +.projects-orbit::after { inset: 17px 20px; } +.projects-orbit span { position: absolute; z-index: 1; width: 6px; height: 6px; background: var(--accent); border-radius: 50%; } +.projects-orbit span:nth-child(1) { left: 8px; top: 24px; } +.projects-orbit span:nth-child(2) { right: 12px; top: 13px; } +.projects-orbit span:nth-child(3) { left: 44px; bottom: 7px; } + +.projects-landing, +.project-detail { display: grid; gap: var(--space-6); } +.project-template-section, +.project-list-section { display: grid; gap: var(--space-4); } +.project-section-heading { align-items: end; } +.project-section-heading h2, +.project-management h2, +.project-notes h2 { margin: 2px 0 0; font-size: var(--size-xl); } +.project-section-heading > .muted { max-width: 34rem; margin: 0; text-align: right; } +.project-kicker { + display: block; + color: var(--accent); + font-family: var(--font-mono); + font-size: var(--size-xs); + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.project-template-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-4); } +.project-template-card, +.project-card { + min-width: 0; + display: grid; + gap: var(--space-3); + padding: var(--space-5); + border: 1px solid var(--border); + border-radius: var(--radius-4); + background: linear-gradient(145deg, color-mix(in srgb, var(--surface-raised) 94%, transparent), var(--surface)); + box-shadow: var(--shadow-1); +} +.project-template-card { grid-template-rows: auto auto 1fr auto; position: relative; overflow: hidden; } +.project-template-card::after { content: ''; position: absolute; inset: auto -15% -44% 30%; height: 160px; border: 1px solid var(--border); border-radius: 50%; pointer-events: none; } +.project-template-card > * { position: relative; z-index: 1; } +.project-template-mark { width: 42px; height: 42px; display: grid; place-items: center; color: var(--accent); background: var(--accent-soft); border-radius: 50%; font-size: var(--size-xl); } +.project-template-copy h3, +.project-card h3 { margin: 0; font-size: var(--size-lg); } +.project-template-copy p { margin: var(--space-2) 0 0; color: var(--text-secondary); line-height: 1.55; } +.project-template-steps { display: grid; gap: 6px; margin: 0; padding-left: 1.25rem; color: var(--text-secondary); font-size: var(--size-sm); } +.project-template-card .btn { width: 100%; } + +.project-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.project-card { padding: var(--space-4); } +.project-card.is-closed { border-style: dashed; } +.project-card-template, +.project-card-next { margin: 0; color: var(--text-secondary); } +.project-card-next { min-height: 1.5em; } +.project-status { display: inline-flex; width: fit-content; align-items: center; min-height: 24px; padding: 3px 9px; border-radius: 999px; font-size: var(--size-xs); border: 1px solid var(--border); } +.project-status-active { color: var(--highlight); background: var(--highlight-soft); border-color: transparent; } +.project-status-completed { color: var(--success); background: color-mix(in srgb, var(--success) 12%, transparent); border-color: transparent; } +.project-status-closed { color: var(--text-secondary); } + +.project-progress { display: flex; align-items: center; gap: var(--space-3); } +.project-progress progress { width: 100%; height: 7px; accent-color: var(--accent); } +.project-progress span { white-space: nowrap; } +.project-history { display: grid; gap: var(--space-4); } +.project-history > summary { cursor: pointer; color: var(--text-secondary); } +.project-history[open] > summary { margin-bottom: var(--space-4); color: var(--text-primary); } +.project-read-errors ul { color: var(--text-secondary); overflow-wrap: anywhere; } + +.projects-detail-frame { width: min(1120px, 100%); } +.project-detail-nav { min-height: 36px; } +.project-detail-header { align-items: end; } +.project-detail-header > div:first-child { max-width: 760px; } +.project-detail-progress { flex: 0 0 auto; display: grid; text-align: right; } +.project-detail-progress strong { font-size: var(--size-2xl); color: var(--accent); } +.project-current-stage { display: grid; gap: var(--space-4); padding: clamp(var(--space-4), 3vw, var(--space-6)); border-left: 3px solid var(--accent); } +.project-current-heading { display: flex; align-items: flex-start; gap: var(--space-4); } +.project-current-heading h2 { margin: 2px 0 var(--space-2); font-size: var(--size-2xl); } +.project-current-heading p { margin: 0; } +.project-step-number { flex: 0 0 48px; height: 48px; display: grid; place-items: center; border: 1px solid var(--accent); border-radius: 50%; color: var(--accent); font-family: var(--font-mono); font-size: var(--size-lg); } +.project-step-note .textarea { min-height: 78px; } +.project-destination-choice { display: grid; grid-template-columns: minmax(220px, 0.6fr) minmax(260px, 1fr); gap: var(--space-4); align-items: end; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-3); background: var(--surface); } +.project-destination-choice p { margin: 0 0 10px; } + +.project-detail-grid { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.55fr); gap: var(--space-5); align-items: start; } +.project-timeline { min-width: 0; display: grid; gap: var(--space-4); } +.project-step-list { list-style: none; display: grid; gap: 0; margin: 0; padding: 0; } +.project-step-item { display: grid; grid-template-columns: 40px minmax(0, 1fr); min-width: 0; } +.project-step-rail { position: relative; display: flex; justify-content: center; } +.project-step-rail::after { content: ''; position: absolute; top: 30px; bottom: -1px; width: 1px; background: var(--border); } +.project-step-item:last-child .project-step-rail::after { display: none; } +.project-step-dot { position: relative; z-index: 1; width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--bg); color: var(--text-secondary); font: var(--size-xs) var(--font-mono); } +.project-step-item.is-current .project-step-dot { color: var(--bg); background: var(--accent); border-color: var(--accent); } +.project-step-item.is-completed .project-step-dot { color: var(--success); border-color: var(--success); } +.project-step-card { min-width: 0; display: grid; gap: var(--space-2); margin: 0 0 var(--space-4) var(--space-2); padding: 0 0 var(--space-4); border-bottom: 1px solid var(--border); } +.project-step-item:last-child .project-step-card { border-bottom: 0; } +.project-step-card h3 { margin: 0; font-size: var(--size-lg); } +.project-step-card > p { margin: 0; line-height: 1.55; } +.project-step-saved-note { padding: var(--space-3); border-left: 2px solid var(--border-strong); background: var(--surface); color: var(--text-secondary); white-space: pre-wrap; } +.project-record-reference { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: var(--space-3); align-items: center; padding: var(--space-3); border: 1px solid var(--border); border-radius: var(--radius-2); background: var(--surface); } +.project-record-reference > span:nth-child(2) { min-width: 0; display: grid; gap: 3px; } +.project-record-reference strong { overflow-wrap: anywhere; } +.project-record-mark { color: var(--accent); } + +.project-notes { position: sticky; top: var(--space-4); display: grid; gap: var(--space-3); } +.project-notes > p { margin: 0; } +.project-notes-field { min-height: 220px; } +.project-handoff-summary { display: grid; gap: 4px; padding-top: var(--space-3); border-top: 1px solid var(--border); } +.project-management { align-items: end; } +.project-management-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.project-completion { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: var(--space-4); padding: var(--space-5); border: 1px solid color-mix(in srgb, var(--success) 55%, var(--border)); } +.project-completion .action-bar { grid-column: 1 / -1; } +.project-completion h2 { margin: 2px 0 var(--space-2); } +.project-completion p { margin: 0; } +.project-completion-mark { width: 52px; height: 52px; display: grid; place-items: center; color: var(--success); border: 1px solid var(--success); border-radius: 50%; font-size: var(--size-xl); } +.project-closed { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-5); border-style: dashed; } +.project-closed h2, .project-closed p { margin: 0; } +.project-delete-retained { display: grid; gap: var(--space-2); padding: var(--space-4); border-left: 3px solid var(--accent); background: var(--accent-soft); } + +.project-context-strip { width: min(1180px, 100%); margin: 0 auto var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border)); border-radius: var(--radius-3); background: color-mix(in srgb, var(--surface) 94%, transparent); box-shadow: var(--shadow-1); } +.project-context-strip[data-state='warning'] { border-color: var(--danger); } +.project-context-copy { min-width: 0; display: grid; grid-template-columns: auto auto; align-items: baseline; column-gap: var(--space-2); } +.project-context-eyebrow { grid-column: 1 / -1; color: var(--text-secondary); font: var(--size-xs) var(--font-mono); overflow-wrap: anywhere; } +.project-context-title { overflow-wrap: anywhere; } +.project-context-status { color: var(--text-secondary); font-size: var(--size-sm); } +.project-context-return { flex: 0 0 auto; } +.project-route-content { min-width: 0; } + +@media (max-width: 860px) { + .project-template-grid { grid-template-columns: 1fr; } + .project-card-grid { grid-template-columns: 1fr; } + .project-detail-grid { grid-template-columns: 1fr; } + .project-notes { position: static; } + .project-destination-choice { grid-template-columns: 1fr; } +} + +@media (max-width: 620px) { + .projects-header, + .project-detail-header, + .project-section-heading, + .project-management, + .project-closed { align-items: flex-start; flex-direction: column; } + .projects-orbit { display: none; } + .project-section-heading > .muted { text-align: left; } + .project-detail-progress { text-align: left; } + .project-management-actions { width: 100%; justify-content: flex-start; } + .project-management-actions .btn { flex: 1 1 140px; } + .project-record-reference { grid-template-columns: auto minmax(0, 1fr); } + .project-record-reference .btn { grid-column: 2; justify-self: start; } + .project-context-strip { align-items: flex-start; } + .project-context-copy { grid-template-columns: 1fr; } + .project-context-eyebrow { grid-column: 1; } +} + +@media (forced-colors: active) { + .project-template-card, + .project-card, + .project-context-strip { background: Canvas; } + .project-step-item.is-current .project-step-dot { color: HighlightText; background: Highlight; } +} diff --git a/src/styles/settings.css b/src/styles/settings.css new file mode 100644 index 0000000..17b8d27 --- /dev/null +++ b/src/styles/settings.css @@ -0,0 +1,105 @@ +/* Settings uses the shared modal dialog and a stable section navigator. */ +.settings-dialog .dialog-panel { width: min(980px, 100%); } +.settings-dialog .dialog-content { padding: 0; } +.settings-content { min-height: 0; } +.settings-layout { display: grid; grid-template-columns: 180px minmax(0, 1fr); min-height: min(660px, calc(100vh - 150px)); } +.settings-navigation { + position: sticky; + top: 0; + align-self: start; + display: grid; + gap: 2px; + padding: var(--space-3); + border-right: 1px solid var(--border); +} +.settings-navigation-item { + appearance: none; + min-height: 38px; + padding: var(--space-2) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-2); + background: transparent; + color: var(--text-secondary); + font: inherit; + text-align: left; + cursor: pointer; +} +.settings-navigation-item:hover { color: var(--text-primary); background: var(--surface-raised); } +.settings-section-picker { + display: none; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.settings-section-picker .select { min-width: 0; width: 100%; } +.settings-sections { min-width: 0; padding: var(--space-5); } +.settings-section { scroll-margin-top: var(--space-4); padding: var(--space-6) 0; border-top: 1px solid var(--border); } +.settings-section:first-child { border-top: 0; padding-top: 0; } +.settings-section-title { + font-size: var(--size-lg); + font-weight: 500; + margin: 0 0 var(--space-4); + color: var(--text-primary); +} +.settings-control { display: flex; align-items: center; justify-content: space-between; gap: var(--space-5); padding: var(--space-3) 0; } +.settings-control-copy { display: grid; gap: 2px; max-width: 42ch; } +.settings-control .segmented { flex: 0 0 auto; } +.settings-check input { width: 18px; height: 18px; } +.settings-status-list { display: grid; grid-template-columns: 1fr auto; gap: var(--space-2) var(--space-5); margin: 0; } +.settings-status-list dt { color: var(--text-secondary); } +.settings-status-list dd { margin: 0; color: var(--text-primary); font-family: var(--font-mono); } + +.sources-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 4px; } +.source-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 12px; + padding: 10px 12px; + background: var(--surface-raised); + border-radius: var(--radius-2); + align-items: center; +} +.source-meta .source-name { font-size: var(--size-md); } +.source-status .ok { color: var(--success); } +.source-status .bad { color: var(--danger); } +.toggle { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: var(--size-sm); color: var(--text-secondary); } +.credits { list-style: none; padding: 0; margin: 8px 0 0; display: flex; flex-direction: column; gap: 6px; } + +.pack-manager-actions { gap: var(--space-2); margin-bottom: var(--space-3); } +.pack-list { list-style: none; display: grid; gap: var(--space-3); padding: 0; margin: var(--space-4) 0 0; } +.pack-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-4); align-items: start; } +.pack-row-main { min-width: 0; display: grid; gap: var(--space-2); overflow-wrap: anywhere; } +.pack-row-main .spread { gap: var(--space-3); align-items: flex-start; } +.pack-row-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } +.pack-error { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); } +.pack-history { margin-top: var(--space-5); } +.pack-history h4 { margin-bottom: var(--space-2); } + +@media (max-width: 720px) { + .settings-layout { grid-template-columns: 1fr; } + .settings-navigation { display: none; } + .settings-section-picker { display: flex; position: sticky; top: 0; z-index: 2; } + .settings-control { align-items: flex-start; flex-direction: column; gap: var(--space-2); } + .source-row { grid-template-columns: 1fr auto; } + .source-status { grid-column: 1 / -1; } + .pack-row { grid-template-columns: 1fr; } + .pack-row-actions { justify-content: flex-start; } +} + +:root[data-large-text='true'] .settings-layout { grid-template-columns: 1fr; } +:root[data-large-text='true'] .settings-navigation { display: none; } +:root[data-large-text='true'] .settings-section-picker { + display: flex; + position: sticky; + top: 0; + z-index: 2; + align-items: stretch; + flex-direction: column; +} +:root[data-large-text='true'] .settings-control { align-items: flex-start; flex-direction: column; gap: var(--space-2); } +:root[data-large-text='true'] .source-row { grid-template-columns: 1fr auto; } +:root[data-large-text='true'] .source-status { grid-column: 1 / -1; } +:root[data-large-text='true'] .pack-row { grid-template-columns: 1fr; } +:root[data-large-text='true'] .pack-row-actions { justify-content: flex-start; } diff --git a/src/styles/shell.css b/src/styles/shell.css index 587f430..11e9629 100644 --- a/src/styles/shell.css +++ b/src/styles/shell.css @@ -1,254 +1,305 @@ -/* Top bar + chamber container + theme/settings controls */ +/* Adaptive application shell, navigation rail, and page framing. */ .shell { - display: flex; - flex-direction: column; - height: 100%; + --effective-rail-width: var(--rail-expanded); + display: grid; + grid-template-columns: var(--effective-rail-width) minmax(0, 1fr); width: 100%; + height: 100%; + min-width: 0; + background: var(--bg); + transition: grid-template-columns var(--t-base) var(--ease); } -/* --- Top bar --- */ -.topbar { - height: var(--topbar-h); +.shell[data-rail-mode='compact'] { --effective-rail-width: var(--rail-compact); } +:root[data-large-text='true'] .shell { --effective-rail-width: var(--rail-compact); } + +.app-rail { + position: relative; + z-index: 20; display: grid; - grid-template-columns: 1fr auto 1fr; + grid-template-rows: auto minmax(0, 1fr) auto; + min-width: 0; + min-height: 0; + overflow: hidden; + color: var(--text-secondary); + background: color-mix(in srgb, var(--surface) 94%, var(--bg)); + border-right: 1px solid var(--border); +} + +.rail-brand-row { + min-height: var(--page-header-h); + display: flex; align-items: center; - padding: 0 var(--space-5); + justify-content: space-between; + gap: var(--space-2); + padding: 0 var(--space-3) 0 var(--space-4); border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--bg) 86%, transparent); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - position: relative; - z-index: 10; } -.brand { - display: inline-flex; +.rail-brand { + min-width: 0; + display: flex; align-items: center; gap: var(--space-3); - font-variant: small-caps; - letter-spacing: 0.18em; - font-size: var(--size-md); color: var(--text-primary); } -.brand .brand-mark { - display: inline-flex; - width: 22px; - height: 22px; - color: var(--accent); -} -.tabs { - display: flex; +.rail-brand-mark, +.rail-item-icon { + flex: 0 0 auto; + display: inline-flex; align-items: center; - gap: 2px; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 999px; - padding: 4px; + justify-content: center; } -.tab { +.rail-brand-mark { width: 28px; height: 28px; color: var(--accent); } +.rail-brand-mark svg, .rail-item-icon svg { width: 100%; height: 100%; } +.rail-brand-label { font-variant: small-caps; letter-spacing: .16em; font-size: var(--size-md); } + +.rail-toggle { appearance: none; + flex: 0 0 auto; + width: 32px; + height: 32px; + display: inline-grid; + place-items: center; + border: 1px solid transparent; + border-radius: var(--radius-2); background: transparent; - border: none; color: var(--text-secondary); - cursor: pointer; font: inherit; - font-size: var(--size-sm); - letter-spacing: 0.04em; - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-radius: 999px; - transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease); -} -.tab:hover { color: var(--text-primary); background: var(--surface-raised); } -.tab[aria-current='true'] { - background: var(--surface-raised); - color: var(--accent); - box-shadow: var(--inset-1); -} -.tab .tab-icon { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; -} -.tab .tab-label { display: none; } -@media (min-width: 1180px) { - .tab .tab-label { display: inline; } -} - -.topbar-right { - display: inline-flex; - align-items: center; - gap: var(--space-2); - justify-content: flex-end; -} - -/* --- Chamber container --- */ -.chamber-container { - flex: 1; - overflow: auto; - position: relative; -} - -.chamber-frame { - max-width: var(--content-max); - margin: 0 auto; - padding: var(--space-7) var(--space-5); + font-size: 1.25rem; + cursor: pointer; } +.rail-toggle:hover { color: var(--text-primary); background: var(--surface-raised); border-color: var(--border); } -.chamber-frame.full-bleed { - max-width: none; - padding: 0; +.rail-navigation { + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: var(--space-3) var(--space-2); + scrollbar-width: thin; + scrollbar-color: var(--border-strong) var(--surface); } +.rail-navigation::-webkit-scrollbar-track { background: var(--surface); } +.rail-navigation::-webkit-scrollbar-thumb { background: var(--border-strong); border-color: var(--surface); } -.chamber-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-5); - margin-bottom: var(--space-6); -} -.chamber-header .chamber-id { +.rail-group { margin: 0 0 var(--space-3); } +.rail-group-label { + margin: 0; + padding: var(--space-2) var(--space-3) var(--space-1); + color: var(--text-faint); font-family: var(--font-mono); font-size: var(--size-xs); - letter-spacing: 0.2em; + font-weight: 500; + letter-spacing: .12em; text-transform: uppercase; - color: var(--text-faint); - margin-bottom: var(--space-2); } -.chamber-header .chamber-title-big { - font-size: var(--size-3xl); - font-weight: 300; - letter-spacing: -0.01em; + +.rail-item { + appearance: none; + width: 100%; + min-height: 44px; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-2); + background: transparent; + color: var(--text-secondary); + font: inherit; + text-align: left; + cursor: pointer; +} +.rail-item + .rail-item { margin-top: 2px; } +.rail-item:hover { color: var(--text-primary); background: var(--surface-raised); } +.rail-item[aria-current='page'] { color: var(--text-primary); - margin: 0; + border-color: color-mix(in srgb, var(--chamber-accent) 38%, var(--border)); + background: var(--chamber-accent-soft); + box-shadow: inset 3px 0 0 var(--chamber-accent); } -.chamber-header .chamber-tagline { - color: var(--text-secondary); - margin-top: var(--space-2); - font-size: var(--size-md); +.rail-item-icon { width: 22px; height: 22px; color: var(--chamber-accent); } +.rail-item-copy { min-width: 0; display: grid; gap: 1px; } +.rail-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rail-item-description { + overflow: hidden; + color: var(--text-faint); + font-size: var(--size-xs); + line-height: 1.3; + text-overflow: ellipsis; + white-space: nowrap; } -/* --- Placeholder card (Phase 1) --- */ -.placeholder { - display: grid; - grid-template-columns: 1fr; - gap: var(--space-6); - padding: var(--space-7); +/* Keep every destination discoverable in ordinary laptop-height windows. + * The selected destination retains its context line; inactive destinations + * use the shorter one-line treatment until more vertical room is available. */ +@media (max-height: 960px) { + .shell:not([data-rail-mode='compact']) .rail-navigation { padding-block: var(--space-2); } + .shell:not([data-rail-mode='compact']) .rail-group { margin-bottom: var(--space-1); } + .shell:not([data-rail-mode='compact']) .rail-group-label { padding-block: 2px; } + .shell:not([data-rail-mode='compact']) .rail-item-description { display: none; } + .shell:not([data-rail-mode='compact']) .rail-item[aria-current='page'] .rail-item-description { display: block; } } -.placeholder-hero { - display: flex; - align-items: center; - justify-content: center; - height: 200px; - color: var(--accent); - position: relative; -} -.placeholder-hero svg { - width: 96px; - height: 96px; + +.rail-footer { padding: var(--space-2); border-top: 1px solid var(--border); } + +.shell[data-rail-mode='compact'] .rail-label { display: none; } +.shell[data-rail-mode='compact'] .rail-brand-row { justify-content: center; padding-inline: var(--space-2); } +.shell[data-rail-mode='compact'] .rail-brand { display: none; } +.shell[data-rail-mode='compact'] .rail-item { + grid-template-columns: 1fr; + justify-items: center; + padding-inline: var(--space-2); } -.placeholder-hero::before, -.placeholder-hero::after { - content: ''; - position: absolute; - top: 50%; - width: 80px; - border-top: 1px dashed var(--border-strong); +.shell[data-rail-mode='compact'] .rail-item[aria-current='page'] { box-shadow: inset 2px 0 0 var(--chamber-accent); } + +:root[data-large-text='true'] .rail-label { display: none; } +:root[data-large-text='true'] .rail-brand-row { justify-content: center; padding-inline: var(--space-2); } +:root[data-large-text='true'] .rail-brand { display: none; } +:root[data-large-text='true'] .rail-toggle { display: none; } +:root[data-large-text='true'] .rail-item { + grid-template-columns: 1fr; + justify-items: center; + padding-inline: var(--space-2); } -.placeholder-hero::before { left: calc(50% - 160px); } -.placeholder-hero::after { right: calc(50% - 160px); } +:root[data-large-text='true'] .page-context, +:root[data-large-text='true'] .command-trigger-label { display: none; } +:root[data-large-text='true'] .command-trigger { min-width: 112px; justify-content: center; gap: var(--space-2); padding-inline: var(--space-2); } +:root[data-large-text='true'] .command-trigger-icon { display: inline-flex; } -.placeholder-meta { +.app-workspace { + min-width: 0; + min-height: 0; display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-3); + grid-template-rows: var(--page-header-h) minmax(0, 1fr); } -.placeholder-meta .meta-row { + +.page-header { + position: relative; + z-index: 10; + min-width: 0; display: flex; - gap: var(--space-3); - font-family: var(--font-mono); - font-size: var(--size-sm); - color: var(--text-secondary); - padding: var(--space-3) 0; - border-top: 1px solid var(--border); -} -.placeholder-meta .meta-row .meta-key { - width: 110px; - color: var(--text-faint); - letter-spacing: 0.08em; - text-transform: uppercase; - font-size: var(--size-xs); + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: 0 var(--space-5); + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(10px); } -.placeholder-blurb { - max-width: 600px; - margin: 0 auto; - text-align: center; +.page-context { + min-width: 0; + overflow: hidden; color: var(--text-secondary); + font-family: var(--font-mono); + font-size: var(--size-sm); + letter-spacing: .04em; + text-overflow: ellipsis; + white-space: nowrap; } +.page-header-actions { display: flex; align-items: center; gap: var(--space-3); } -.phase-tag { - display: inline-flex; +.command-trigger { + appearance: none; + min-width: 220px; + height: 36px; + display: flex; align-items: center; - gap: 6px; - background: var(--accent-soft); - color: var(--accent); - font-family: var(--font-mono); - font-size: var(--size-xs); - letter-spacing: 0.18em; - text-transform: uppercase; - padding: 4px 10px; - border-radius: 999px; + justify-content: space-between; + gap: var(--space-5); + padding: 0 var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-2); + background: var(--surface); + color: var(--text-secondary); + font: inherit; + cursor: pointer; } +.command-trigger:hover { border-color: var(--border-strong); color: var(--text-primary); background: var(--surface-raised); } +.command-trigger-icon { display: none; width: 16px; height: 16px; flex: 0 0 auto; } +.command-trigger-icon svg { width: 100%; height: 100%; } +.command-trigger kbd { color: var(--text-faint); font-family: var(--font-mono); font-size: var(--size-xs); } -/* --- Settings popover / theme switch --- */ .theme-switch { display: inline-flex; align-items: center; gap: 2px; - background: var(--surface); + padding: 3px; border: 1px solid var(--border); border-radius: 999px; - padding: 3px; + background: var(--surface); } .theme-switch button { appearance: none; + width: 30px; + height: 30px; + display: inline-grid; + place-items: center; + border: 1px solid transparent; + border-radius: 999px; background: transparent; - border: none; color: var(--text-faint); - width: 28px; - height: 28px; - border-radius: 999px; cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease); -} -.theme-switch button[aria-pressed='true'] { - background: var(--surface-raised); - color: var(--accent); - box-shadow: var(--inset-1); } .theme-switch button:hover { color: var(--text-primary); } +.theme-switch button[aria-pressed='true'] { color: var(--accent); background: var(--surface-raised); box-shadow: var(--inset-1); } +.theme-switch svg { width: 15px; height: 15px; } -.theme-switch svg { width: 14px; height: 14px; } +.chamber-container { + min-width: 0; + min-height: 0; + overflow: auto; + position: relative; + scroll-padding-block: var(--space-5) 88px; +} +.chamber-container h1[tabindex='-1']:focus { + /* Navigation moves focus to the page heading for screen-reader context. + * A negative-tabindex heading is not an interactive control, so suppress + * the browser's input-like rectangle without weakening control focus. */ + outline: none; +} +.chamber-frame { width: min(100%, var(--content-max)); margin: 0 auto; padding: var(--space-7) var(--space-5); } +.chamber-frame.full-bleed { width: 100%; max-width: none; padding: 0; } +.chamber-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-5); margin-bottom: var(--space-6); } +.chamber-header .chamber-id { margin-bottom: var(--space-2); color: var(--text-faint); font-family: var(--font-mono); font-size: var(--size-xs); letter-spacing: .18em; text-transform: uppercase; } +.chamber-header .chamber-title-big { margin: 0; color: var(--text-primary); font-size: var(--size-3xl); font-weight: 300; letter-spacing: -.01em; } +.chamber-header .chamber-tagline { margin-top: var(--space-2); color: var(--text-secondary); font-size: var(--size-md); } + +.placeholder { display: grid; gap: var(--space-6); padding: var(--space-7); } +.placeholder-hero { position: relative; height: 200px; display: grid; place-items: center; color: var(--accent); } +.placeholder-hero svg { width: 96px; height: 96px; } +.placeholder-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.placeholder-meta .meta-row { display: flex; gap: var(--space-3); padding: var(--space-3) 0; border-top: 1px solid var(--border); color: var(--text-secondary); font-family: var(--font-mono); font-size: var(--size-sm); } +.placeholder-meta .meta-row .meta-key { width: 110px; color: var(--text-faint); font-size: var(--size-xs); letter-spacing: .08em; text-transform: uppercase; } +.placeholder-blurb { max-width: 600px; margin: 0 auto; color: var(--text-secondary); text-align: center; } -/* --- Empty state line (used during loads) --- */ -.loading-line { - height: 1px; - background: linear-gradient(90deg, transparent, var(--accent), transparent); - background-size: 200% 100%; - animation: scan 1.6s linear infinite; +.loading-line { height: 1px; background: linear-gradient(90deg, transparent, var(--accent), transparent); background-size: 200% 100%; animation: scan 1.6s linear infinite; } +@keyframes scan { from { background-position: 200% 0; } to { background-position: -200% 0; } } + +@media (max-width: 1007px), (max-height: 800px) and (max-width: 1199px) { + .shell[data-rail-mode='auto'] { --effective-rail-width: var(--rail-compact); } + .shell[data-rail-mode='auto'] .rail-label { display: none; } + .shell[data-rail-mode='auto'] .rail-brand-row { justify-content: center; padding-inline: var(--space-2); } + .shell[data-rail-mode='auto'] .rail-brand { display: none; } + .shell[data-rail-mode='auto'] .rail-item { grid-template-columns: 1fr; justify-items: center; padding-inline: var(--space-2); } +} + +@media (max-width: 640px) { + .page-header { padding-inline: var(--space-3); } + .page-context, .command-trigger-label, .theme-switch { display: none; } + .command-trigger { min-width: 96px; justify-content: center; gap: var(--space-2); padding-inline: var(--space-2); } + .command-trigger-icon { display: inline-flex; } + .chamber-frame { padding: var(--space-5) var(--space-3); } + .chamber-header { margin-bottom: var(--space-5); } + .placeholder-meta { grid-template-columns: 1fr; } } -@keyframes scan { - from { background-position: 200% 0; } - to { background-position: -200% 0; } + +@media (forced-colors: active) { + .rail-item[aria-current='page'] { forced-color-adjust: auto; border-color: Highlight; } + .app-rail, .page-header { background: Canvas; } } diff --git a/src/styles/system.css b/src/styles/system.css new file mode 100644 index 0000000..26022c5 --- /dev/null +++ b/src/styles/system.css @@ -0,0 +1,196 @@ +/* Shared interface primitives and adaptive-shell surfaces. */ + +:where(button, [href], input, select, textarea, summary, [tabindex]:not([tabindex='-1'])):focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} + +.result-stage { + position: relative; + display: grid; + gap: var(--space-5); + min-width: 0; + padding: clamp(var(--space-4), 3vw, var(--space-6)); + border: 1px var(--chamber-line-style) var(--chamber-accent); + border-radius: var(--radius-4); + background: + linear-gradient(135deg, var(--chamber-accent-soft), transparent 42%), + var(--surface); + box-shadow: var(--shadow-1); +} +.result-stage::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + opacity: var(--chamber-motif-opacity); + background-image: repeating-linear-gradient(90deg, transparent 0 31px, var(--chamber-accent) 32px 33px); + mask-image: linear-gradient(to bottom, black, transparent 42%); +} +.result-stage[data-status='live']::before { background-image: repeating-radial-gradient(circle at 50% 0, transparent 0 18px, var(--chamber-accent) 19px 20px); } +.result-stage[data-status='error'] { border-color: var(--danger); } + +.action-bar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-3); + min-height: 56px; +} +.action-bar-primary, .action-bar-secondary { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); } +.action-bar-sticky { + position: sticky; + bottom: 0; + z-index: 4; + margin: 0 calc(var(--space-4) * -1) calc(var(--space-4) * -1); + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--bg) 94%, transparent); + backdrop-filter: blur(12px); +} + +.source-badge { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + min-height: 32px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface-raised); + color: var(--text-secondary); + font-size: var(--size-sm); +} +.source-badge-mark { color: var(--chamber-accent); } +.source-badge-name { color: var(--text-primary); font-weight: 600; } +.source-badge-id, .source-badge-state, .source-badge-verification { white-space: nowrap; } +.source-badge-state::before, .source-badge-verification::before { content: '·'; margin-right: var(--space-2); color: var(--text-faint); } +.source-badge-state.is-fetched, .source-badge-verification.has-signature { color: var(--success); } + +.provenance-disclosure { + min-width: 0; + border-top: 1px solid var(--border); + padding-top: var(--space-3); +} +.provenance-summary { cursor: pointer; list-style: none; width: fit-content; max-width: 100%; border-radius: 999px; } +.provenance-summary::-webkit-details-marker { display: none; } +.provenance-details { + display: grid; + gap: var(--space-2); + margin-top: var(--space-3); + padding: var(--space-4); + border-left: 2px solid var(--chamber-accent); + background: var(--surface-raised); + font-family: var(--font-mono); + font-size: var(--size-sm); +} +.provenance-row { display: grid; grid-template-columns: minmax(96px, 0.25fr) 1fr; gap: var(--space-3); min-width: 0; } +.provenance-key { color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.06em; } +.provenance-value { min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; color: var(--text-primary); } + +.dialog-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: var(--space-4); + background: color-mix(in srgb, var(--bg) 70%, transparent); + backdrop-filter: blur(10px); +} +.dialog-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + width: min(720px, 100%); + max-height: min(760px, calc(100vh - 32px)); + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: var(--radius-4); + background: var(--surface); + box-shadow: var(--shadow-2); +} +.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4) var(--space-5); border-bottom: 1px solid var(--border); } +.dialog-title { font-size: var(--size-xl); } +.dialog-content { min-height: 0; overflow: auto; padding: var(--space-5); } + +.command-palette-overlay { align-items: start; padding-top: min(12vh, 96px); } +.command-palette-overlay .dialog-panel { width: min(680px, 100%); } +.command-palette { display: grid; gap: var(--space-3); } +.command-palette-input { width: 100%; min-height: 48px; font-size: var(--size-md); } +.command-palette-help { margin: 0; color: var(--text-faint); font-size: var(--size-sm); } +.command-palette-results { max-height: min(520px, 58vh); overflow-y: auto; padding-right: var(--space-1); } +.command-palette-group { + position: sticky; + top: 0; + z-index: 1; + padding: var(--space-3) var(--space-3) var(--space-1); + background: var(--surface); + color: var(--text-faint); + font-family: var(--font-mono); + font-size: var(--size-xs); + font-weight: 600; + letter-spacing: .12em; + text-transform: uppercase; +} +.command-palette-option { + display: grid; + grid-template-columns: minmax(140px, .75fr) minmax(0, 1fr); + gap: var(--space-3); + align-items: baseline; + min-height: 46px; + padding: var(--space-2) var(--space-3); + border: 1px solid transparent; + border-radius: var(--radius-2); + color: var(--text-secondary); + cursor: pointer; +} +.command-palette-option.is-active { border-color: var(--chamber-accent); background: var(--chamber-accent-soft); } +.command-palette-label { color: var(--text-primary); font-weight: 600; } +.command-palette-description { overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; } + +.workspace-placeholder-mark { + width: 104px; + height: 104px; + margin: var(--space-3) auto; + color: var(--chamber-accent); +} +.workspace-placeholder-mark svg { width: 100%; height: 100%; } +.workspace-placeholder-stage .state-panel { padding-top: var(--space-3); border: 0; background: transparent; } + +.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); } +.field-group { min-width: 0; margin: 0; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-3); } +.field-group-legend { padding: 0 var(--space-2); color: var(--text-secondary); font-size: var(--size-sm); font-weight: 600; } +.menu-button { position: relative; } +.menu-popup { position: absolute; z-index: 20; top: calc(100% + 6px); right: 0; min-width: 180px; padding: var(--space-1); border: 1px solid var(--border); border-radius: var(--radius-2); background: var(--surface); box-shadow: var(--shadow-2); } +.menu-item { display: flex; width: 100%; min-height: 36px; align-items: center; padding: 6px 10px; border: 0; border-radius: var(--radius-1); background: transparent; color: var(--text-primary); font: inherit; cursor: pointer; } +.menu-item:hover { background: var(--surface-raised); } + +.state-panel { display: grid; justify-items: center; gap: var(--space-2); padding: var(--space-7) var(--space-5); text-align: center; border: 1px dashed var(--border-strong); border-radius: var(--radius-3); } +.state-panel-mark { display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid currentColor; border-radius: 50%; color: var(--chamber-accent); font-family: var(--font-mono); } +.state-panel-title { font-size: var(--size-lg); } +.state-panel-description { max-width: 56ch; color: var(--text-secondary); } +.error-state .state-panel-mark { color: var(--danger); } + +.skeleton { display: grid; gap: var(--space-3); } +.skeleton-line { height: 14px; border-radius: 999px; background: linear-gradient(90deg, var(--surface-raised), var(--border), var(--surface-raised)); background-size: 200% 100%; animation: skeleton-shift 1.4s linear infinite; } +.skeleton-line:last-child { width: 62%; } +@keyframes skeleton-shift { to { background-position: -200% 0; } } + +.toast-region { position: fixed; inset: auto var(--space-5) var(--space-5) auto; z-index: 200; display: grid; justify-items: end; gap: var(--space-2); max-width: calc(100vw - (2 * var(--space-5))); pointer-events: none; } +.toast-region .toast { position: static; max-width: min(560px, calc(100vw - (2 * var(--space-5)))); overflow-wrap: anywhere; transform: translateY(8px); } +.toast-region .toast.visible { transform: translateY(0); } + +@media (max-width: 640px) { + .provenance-row { grid-template-columns: 1fr; gap: 2px; } + .source-badge-state, .source-badge-verification { display: none; } + .action-bar > * { width: 100%; } + .action-bar-primary, .action-bar-secondary { justify-content: stretch; } + .command-palette-option { grid-template-columns: 1fr; gap: 1px; } +} + +@media (forced-colors: active) { + .result-stage, .source-badge, .dialog-panel, .field-group, .state-panel { forced-color-adjust: auto; } + .result-stage::before { display: none; } +} diff --git a/src/styles/today.css b/src/styles/today.css new file mode 100644 index 0000000..d927a6f --- /dev/null +++ b/src/styles/today.css @@ -0,0 +1,439 @@ +.today-frame { + max-width: 1180px; + margin-inline: auto; + gap: var(--space-5); +} + +.today-header { + display: flex; + align-items: end; + justify-content: space-between; + gap: var(--space-5); +} + +.today-zone { + max-width: 22rem; + padding: var(--space-2) var(--space-3); + color: var(--text-secondary); + border: 1px solid var(--border); + border-radius: 999px; + overflow-wrap: anywhere; +} + +.today-state-stage { + min-height: min(520px, 62vh); + display: grid; + place-content: center; + padding: var(--space-7); +} + +.today-state-stage > * { + width: min(620px, 100%); +} + +.today-constellation { + position: relative; + display: grid; + gap: var(--space-5); + padding: clamp(var(--space-4), 3vw, var(--space-6)); + overflow: clip; + background: + radial-gradient(circle at 18% 14%, var(--chamber-accent-soft), transparent 29%), + linear-gradient(145deg, color-mix(in srgb, var(--surface-raised) 88%, transparent), var(--surface)); +} + +.today-constellation::before { + content: ''; + position: absolute; + inset: 1.25rem; + pointer-events: none; + opacity: 0.3; + background-image: radial-gradient(circle, var(--chamber-accent) 0 1px, transparent 1.5px); + background-size: 54px 54px; + mask-image: linear-gradient(to bottom, black, transparent 62%); +} + +.today-constellation.is-local { + border-style: dashed; + background: + repeating-linear-gradient(135deg, transparent 0 13px, var(--chamber-accent-soft) 13px 14px), + var(--surface); +} + +.today-source-line, +.today-source-seal, +.today-verification summary, +.today-symphony-copy, +.today-canvas-caption { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.today-source-line { + position: relative; + z-index: 1; + flex-wrap: wrap; +} + +.today-source-seal { + justify-content: start; + width: fit-content; + padding: var(--space-2) var(--space-3); + color: var(--text-primary); + background: var(--chamber-accent-soft); + border: 1px solid color-mix(in srgb, var(--chamber-accent) 55%, var(--border)); + border-radius: 999px; + font-size: var(--size-sm); + letter-spacing: 0.02em; +} + +.today-source-seal.is-local { + border-style: dashed; +} + +.today-source-seal .mono { + color: var(--text-secondary); +} + +.today-source-mark { + color: var(--chamber-accent); +} + +.today-composition { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(280px, 1.18fr) minmax(260px, 0.82fr); + grid-template-areas: + 'oracle canvas' + 'oracle constraint' + 'diary diary' + 'symphony symphony'; + gap: var(--space-4); +} + +.today-composition > section { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius-4); + background: color-mix(in srgb, var(--surface-raised) 92%, transparent); + box-shadow: var(--shadow-1); +} + +.today-region-label { + color: var(--text-faint); + font-family: var(--font-mono); + font-size: var(--size-xs); + letter-spacing: 0.11em; + text-transform: uppercase; +} + +.today-oracle { + grid-area: oracle; + display: grid; + grid-template-rows: auto minmax(220px, 1fr) auto; + gap: var(--space-3); + padding: var(--space-4); + border-top: 2px solid color-mix(in srgb, #b8a0e8 72%, var(--border)) !important; +} + +.today-oracle-art { + min-height: 220px; + display: grid; + place-items: center; + color: #b8a0e8; + overflow: hidden; + border-radius: var(--radius-3); + background: color-mix(in srgb, #b8a0e8 8%, var(--bg)); +} + +.today-oracle-art svg { + display: block; + width: min(100%, 360px); + height: min(100%, 360px); + max-height: 360px; +} + +.today-oracle-copy { + display: grid; + gap: var(--space-2); +} + +.today-oracle-copy h2, +.today-diary h2, +.today-symphony h2, +.today-canvas h2 { + margin: 0; +} + +.today-oracle-copy p { + margin: 0; + color: var(--text-secondary); + line-height: 1.62; +} + +.today-oracle-category { + color: #b8a0e8 !important; + font-family: var(--font-mono); + font-size: var(--size-xs); + text-transform: uppercase; + letter-spacing: 0.1em; +} + +.today-oracle-copy .btn, +.today-constraint .btn { + justify-self: start; + margin-top: var(--space-2); +} + +.today-canvas { + grid-area: canvas; + display: grid; + gap: var(--space-2); + padding: var(--space-3); + border-top: 2px double color-mix(in srgb, #9e9bdb 72%, var(--border)) !important; +} + +.today-canvas-art { + min-height: 150px; + overflow: hidden; + border-radius: var(--radius-3); + background: var(--bg); +} + +.today-canvas-art svg { + display: block; + width: 100%; + height: 100%; + min-height: 150px; + object-fit: cover; +} + +.today-canvas-caption h2 { + font-size: var(--size-lg); +} + +.today-constraint { + grid-area: constraint; + display: grid; + align-content: center; + gap: var(--space-3); + padding: var(--space-5); + border-top: 2px dashed color-mix(in srgb, #ddbd73 75%, var(--border)) !important; +} + +.today-constraint blockquote { + margin: 0; + color: var(--text-primary); + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(var(--size-lg), 2.1vw, var(--size-xl)); + line-height: 1.45; +} + +.today-diary { + grid-area: diary; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-4) var(--space-5); + align-items: center; + padding: var(--space-5); + border-left: 3px solid #b7a27d !important; +} + +.today-diary .today-region-label, +.today-diary .today-coordinates { + grid-column: 1 / -1; +} + +.today-diary h2 { + max-width: 48rem; + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(var(--size-xl), 2.5vw, var(--size-2xl)); + font-weight: 500; + line-height: 1.35; +} + +.today-coordinates { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.today-coordinate { + display: inline-flex; + align-items: center; + gap: var(--space-2); + min-height: 36px; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: 999px; + background: var(--surface); +} + +.today-coordinate-key { + color: var(--text-faint); + font-family: var(--font-mono); + font-size: var(--size-xs); +} + +.today-color-swatch { + width: 1rem; + height: 1rem; + border: 1px solid var(--border-strong); + border-radius: 50%; +} + +.today-symphony { + grid-area: symphony; + display: grid; + grid-template-columns: auto minmax(180px, 1fr) minmax(210px, auto); + align-items: center; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + border-top: 2px dashed color-mix(in srgb, #78b9a5 70%, var(--border)) !important; +} + +.today-motif { + min-height: 80px; + display: flex; + align-items: end; + justify-content: center; + gap: var(--space-2); + padding-inline: var(--space-4); + border-inline: 1px solid var(--border); +} + +.today-motif span { + display: block; + max-height: 76px; + border-radius: 999px 999px var(--radius-1) var(--radius-1); + background: linear-gradient(to top, #78b9a5, color-mix(in srgb, #78b9a5 35%, transparent)); +} + +.today-symphony-copy { + align-items: end; + flex-direction: column; +} + +.today-symphony h2 { + font-size: var(--size-lg); +} + +.today-verification { + position: relative; + z-index: 1; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--border); + border-radius: var(--radius-3); + background: color-mix(in srgb, var(--surface) 92%, transparent); +} + +.today-verification summary { + min-height: 38px; + cursor: pointer; + font-weight: 600; +} + +.today-verification-summary { + color: var(--text-secondary); + font-size: var(--size-sm); + font-weight: 400; + text-align: right; +} + +.today-verification-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + padding-block: var(--space-3); +} + +.today-verification-fact { + display: grid; + grid-template-columns: 20px 1fr; + gap: var(--space-2); + min-width: 0; + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-2); +} + +.today-verification-fact strong, +.today-verification-fact .small { + display: block; +} + +.today-verification-fact .small { + margin-top: var(--space-1); + overflow-wrap: anywhere; +} + +.today-verification-dot { + color: var(--text-faint); + font-weight: 700; +} + +.today-verification-fact.is-verified .today-verification-dot { color: var(--success); } +.today-verification-fact.is-failed .today-verification-dot { color: var(--danger); } + +.today-receipt { + max-height: 52vh; + margin: 0; + padding: var(--space-4); + overflow: auto; + color: var(--text-secondary); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-2); + font: var(--size-xs)/1.55 var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.today-corrupt-row { + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-2); +} + +.today-corrupt-row + .today-corrupt-row { margin-top: var(--space-2); } +.today-corrupt-row code { overflow-wrap: anywhere; } + +@media (max-width: 820px) { + .today-header { align-items: start; flex-direction: column; } + .today-composition { + grid-template-columns: 1fr; + grid-template-areas: 'oracle' 'constraint' 'canvas' 'diary' 'symphony'; + } + .today-oracle { grid-template-rows: auto auto auto; } + .today-diary { grid-template-columns: 1fr; } + .today-diary .btn { justify-self: start; } + .today-symphony { grid-template-columns: 1fr; } + .today-motif { border-inline: 0; border-block: 1px solid var(--border); padding-block: var(--space-3); } + .today-symphony-copy { align-items: start; } + .today-verification-grid { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 520px) { + .today-state-stage { padding: var(--space-4); } + .today-constellation { padding: var(--space-3); } + .today-source-line { align-items: start; flex-direction: column; } + .today-source-seal { flex-wrap: wrap; border-radius: var(--radius-3); } + .today-oracle, + .today-constraint, + .today-diary, + .today-symphony { padding: var(--space-4); } + .today-verification-grid { grid-template-columns: 1fr; } + .today-verification summary { align-items: start; flex-direction: column; } + .today-verification-summary { text-align: left; } +} + +@media (forced-colors: active) { + .today-constellation::before { display: none; } + .today-source-seal, + .today-coordinate, + .today-composition > section { forced-color-adjust: auto; } + .today-motif span { background: Highlight; } +} diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 878187e..f39ba75 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -1,128 +1,210 @@ -/* Sortilune design tokens. - * Three themes, applied via [data-theme] on . - * All chamber CSS reads from these — never hardcode hex. +/* + * Sortilune token architecture + * 1. Foundation: type, spacing, shape, timing, and layout. + * 2. Theme: raw palette/effect values for each appearance. + * 3. Semantic: stable component-facing names mapped to the active theme. + * 4. Chamber: scoped identity values mapped by the active destination. */ -:root, -[data-theme='cosmic-dark'] { - /* Surfaces */ - --bg: #0a0e14; - --surface: #141923; - --surface-raised: #1a2030; - --border: #1f2630; - --border-strong: #2a3342; - - /* Ink */ - --text-primary: #e8ecf1; - --text-secondary: #8b94a3; - --text-faint: #5a6373; - - /* Accents */ - --accent: #d4a574; /* sodium-vapor amber */ - --accent-soft: rgba(212, 165, 116, 0.16); - --highlight: #7fb3d5; /* starlight blue */ - --highlight-soft: rgba(127, 179, 213, 0.14); - --danger: #c97171; - --success: #8db58a; - - /* Effects */ - --shadow-1: 0 1px 0 rgba(255, 255, 255, 0.02), 0 2px 8px rgba(0, 0, 0, 0.4); - --shadow-2: 0 1px 0 rgba(255, 255, 255, 0.03), 0 8px 30px rgba(0, 0, 0, 0.55); - --inset-1: inset 0 0 0 1px var(--border); - - /* Type scale */ +:root { + /* Foundation · typography (12px caption and 14px body floors) */ --font-sans: 'Inter', -apple-system, 'Segoe UI', system-ui, sans-serif; --font-mono: 'SF Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace; - - --size-xs: 11px; - --size-sm: 12px; - --size-base: 14px; - --size-md: 15px; - --size-lg: 18px; - --size-xl: 22px; - --size-2xl: 28px; - --size-3xl: 38px; - --size-4xl: 56px; - - /* Spacing scale */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 24px; - --space-6: 32px; - --space-7: 48px; - --space-8: 64px; - --space-9: 96px; - - /* Radii */ + --size-xs: 0.75rem; + --size-sm: 0.75rem; + --size-base: 0.875rem; + --size-md: 0.9375rem; + --size-lg: 1.125rem; + --size-xl: 1.375rem; + --size-2xl: 1.75rem; + --size-3xl: 2.375rem; + --size-4xl: 3.5rem; + + /* Foundation · the primary 8/12/16/24/32 relationship */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; + --space-6: 2rem; + --space-7: 3rem; + --space-8: 4rem; + --space-9: 6rem; --radius-1: 3px; --radius-2: 6px; --radius-3: 10px; --radius-4: 16px; - - /* Easing & timing — slow, deliberate */ --ease: cubic-bezier(0.4, 0, 0.2, 1); --ease-reveal: cubic-bezier(0.65, 0, 0.35, 1); - --t-fast: 180ms; - --t-base: 320ms; - --t-slow: 800ms; - --t-reveal: 1200ms; - - /* Layout */ + --duration-fast: 180ms; + --duration-base: 320ms; + --duration-slow: 800ms; + --duration-reveal: 900ms; + --t-fast: var(--duration-fast); + --t-base: var(--duration-base); + --t-slow: var(--duration-slow); + --t-reveal: var(--duration-reveal); --content-max: 960px; - --topbar-h: 56px; -} - -[data-theme='cosmic-light'] { - --bg: #f7f5f0; - --surface: #ffffff; - --surface-raised: #fbfaf6; - --border: #e3dfd4; - --border-strong: #c8c2b3; + --rail-expanded: 248px; + --rail-compact: 72px; + --page-header-h: 64px; + + /* Semantic layer; theme values remain swappable below. */ + --bg: var(--theme-bg); + --surface: var(--theme-surface); + --surface-raised: var(--theme-surface-raised); + --border: var(--theme-border); + --border-strong: var(--theme-border-strong); + --text-primary: var(--theme-text-primary); + --text-secondary: var(--theme-text-secondary); + --text-faint: var(--theme-text-faint); + --accent: var(--theme-accent); + --accent-soft: var(--theme-accent-soft); + --highlight: var(--theme-highlight); + --highlight-soft: var(--theme-highlight-soft); + --danger: var(--theme-danger); + --success: var(--theme-success); + --shadow-1: var(--theme-shadow-1); + --shadow-2: var(--theme-shadow-2); + --inset-1: inset 0 0 0 1px var(--border); + --focus-ring: 0 0 0 2px var(--bg), 0 0 0 5px var(--highlight); - --text-primary: #1a1d24; - --text-secondary: #555c69; - --text-faint: #8a909b; + /* Chamber layer; amber remains the application identity. */ + --chamber-accent: var(--accent); + --chamber-accent-soft: var(--accent-soft); + --chamber-line-style: solid; + --chamber-motif-opacity: 0.12; +} - --accent: #b6863f; - --accent-soft: rgba(182, 134, 63, 0.12); - --highlight: #3c7aa3; - --highlight-soft: rgba(60, 122, 163, 0.10); - --danger: #a44545; - --success: #5b8055; +[data-theme='cosmic-dark'] { + --theme-bg: #0a0e14; + --theme-surface: #141923; + --theme-surface-raised: #1a2030; + --theme-border: #27303d; + --theme-border-strong: #3a4658; + --theme-text-primary: #e8ecf1; + --theme-text-secondary: #a3adbb; + --theme-text-faint: #929dac; + --theme-accent: #d4a574; + --theme-accent-soft: rgba(212, 165, 116, 0.16); + --theme-highlight: #8fc6e8; + --theme-highlight-soft: rgba(143, 198, 232, 0.16); + --theme-danger: #e08a8a; + --theme-success: #9ac696; + --theme-shadow-1: 0 1px 0 rgba(255, 255, 255, 0.03), 0 2px 8px rgba(0, 0, 0, 0.34); + --theme-shadow-2: 0 1px 0 rgba(255, 255, 255, 0.04), 0 10px 32px rgba(0, 0, 0, 0.48); +} - --shadow-1: 0 1px 0 rgba(0, 0, 0, 0.02), 0 2px 8px rgba(0, 0, 0, 0.06); - --shadow-2: 0 1px 0 rgba(0, 0, 0, 0.03), 0 8px 30px rgba(0, 0, 0, 0.08); +[data-theme='cosmic-light'] { + --theme-bg: #f7f5f0; + --theme-surface: #ffffff; + --theme-surface-raised: #fbfaf6; + --theme-border: #d8d3c7; + --theme-border-strong: #aaa392; + --theme-text-primary: #1a1d24; + --theme-text-secondary: #4e5663; + --theme-text-faint: #5f6774; + --theme-accent: #9b6c28; + --theme-accent-soft: rgba(155, 108, 40, 0.13); + --theme-highlight: #286f9d; + --theme-highlight-soft: rgba(40, 111, 157, 0.12); + --theme-danger: #963b3b; + --theme-success: #4c7547; + --theme-shadow-1: 0 1px 0 rgba(0, 0, 0, 0.02), 0 2px 8px rgba(0, 0, 0, 0.06); + --theme-shadow-2: 0 1px 0 rgba(0, 0, 0, 0.03), 0 10px 30px rgba(0, 0, 0, 0.10); } [data-theme='high-contrast'] { - --bg: #000000; - --surface: #0a0a0a; - --surface-raised: #141414; - --border: #ffffff; - --border-strong: #ffffff; - - --text-primary: #ffffff; - --text-secondary: #d6d6d6; - --text-faint: #a0a0a0; - - --accent: #ff8a1a; - --accent-soft: rgba(255, 138, 26, 0.20); - --highlight: #ffffff; - --highlight-soft: rgba(255, 255, 255, 0.10); - --danger: #ff4d4d; - --success: #4dff88; - - --shadow-1: 0 0 0 1px #ffffff; - --shadow-2: 0 0 0 2px #ffffff; + --theme-bg: #000000; + --theme-surface: #080808; + --theme-surface-raised: #141414; + --theme-border: #ffffff; + --theme-border-strong: #ffffff; + --theme-text-primary: #ffffff; + --theme-text-secondary: #e4e4e4; + --theme-text-faint: #c2c2c2; + --theme-accent: #ff9d3d; + --theme-accent-soft: rgba(255, 157, 61, 0.24); + --theme-highlight: #ffffff; + --theme-highlight-soft: rgba(255, 255, 255, 0.14); + --theme-danger: #ff6b6b; + --theme-success: #65ff9a; + --theme-shadow-1: 0 0 0 1px #ffffff; + --theme-shadow-2: 0 0 0 2px #ffffff; } +/* Chamber identity combines hue with line, rhythm, and motif treatment. */ +[data-chamber='oracle'] { --chamber-accent: #b8a0e8; --chamber-accent-soft: rgba(184, 160, 232, 0.15); --chamber-line-style: double; } +[data-chamber='constraint'] { --chamber-accent: #ddbd73; --chamber-accent-soft: rgba(221, 189, 115, 0.15); --chamber-line-style: dashed; } +[data-chamber='decider'] { --chamber-accent: #7fbad1; --chamber-accent-soft: rgba(127, 186, 209, 0.15); --chamber-line-style: solid; } +[data-chamber='lottery'] { --chamber-accent: #d6a875; --chamber-accent-soft: rgba(214, 168, 117, 0.15); --chamber-line-style: dotted; } +[data-chamber='diary'] { --chamber-accent: #b7a27d; --chamber-accent-soft: rgba(183, 162, 125, 0.15); --chamber-line-style: solid; } +[data-chamber='canvas'] { --chamber-accent: #9e9bdb; --chamber-accent-soft: rgba(158, 155, 219, 0.15); --chamber-line-style: double; } +[data-chamber='symphony'] { --chamber-accent: #78b9a5; --chamber-accent-soft: rgba(120, 185, 165, 0.15); --chamber-line-style: dashed; } +[data-chamber='beacon'] { --chamber-accent: #80b8dc; --chamber-accent-soft: rgba(128, 184, 220, 0.15); --chamber-line-style: dotted; } +[data-chamber='today'] { --chamber-accent: #d4a574; --chamber-accent-soft: rgba(212, 165, 116, 0.16); --chamber-line-style: solid; } + @media (prefers-reduced-motion: reduce) { - :root { + :root:not([data-motion='allow']) { --t-fast: 0ms; --t-base: 0ms; --t-slow: 0ms; --t-reveal: 0ms; } + :root:not([data-motion='allow']) *, + :root:not([data-motion='allow']) *::before, + :root:not([data-motion='allow']) *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + } +} + +:root[data-motion='reduce'] { + --t-fast: 0ms; + --t-base: 0ms; + --t-slow: 0ms; + --t-reveal: 0ms; +} +:root[data-motion='reduce'] *, +:root[data-motion='reduce'] *::before, +:root[data-motion='reduce'] *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; +} + +@media (prefers-reduced-transparency: reduce) { + :root { --chamber-motif-opacity: 0; } + .app-rail, .page-header, .dialog-overlay { backdrop-filter: none !important; } +} + +@media (prefers-contrast: more) { + :root { + --border: var(--text-primary); + --border-strong: var(--text-primary); + --text-faint: var(--text-secondary); + --chamber-motif-opacity: 0.18; + } +} + +@media (forced-colors: active) { + :root { + --bg: Canvas; + --surface: Canvas; + --surface-raised: Canvas; + --border: CanvasText; + --border-strong: CanvasText; + --text-primary: CanvasText; + --text-secondary: CanvasText; + --text-faint: CanvasText; + --accent: Highlight; + --chamber-accent: Highlight; + --highlight: Highlight; + --danger: Mark; + --success: CanvasText; + --shadow-1: none; + --shadow-2: none; + --focus-ring: 0 0 0 3px Highlight; + } } diff --git a/src/symphony/export.ts b/src/symphony/export.ts new file mode 100644 index 0000000..6ad3fcd --- /dev/null +++ b/src/symphony/export.ts @@ -0,0 +1,172 @@ +import { frequencyToMidi, type SymphonyScore, type SymphonyScoreEvent, type SymphonyVoice } from './score.js'; + +export const WAV_SAMPLE_RATE = 22_050; +export const MAX_WAV_DURATION_MS = 5 * 60 * 1000; +const MAX_EXPORT_VOICES = 4_000; + +interface TimedVoice { + startMs: number; + voice: SymphonyVoice; + event: SymphonyScoreEvent; +} + +export function encodeSymphonyWav(score: SymphonyScore): Uint8Array { + assertExportable(score); + if (score.duration_ms > MAX_WAV_DURATION_MS) throw new RangeError('WAV export supports sessions up to five minutes.'); + const sampleCount = Math.max(1, Math.ceil(score.duration_ms * WAV_SAMPLE_RATE / 1_000)); + const samples = new Float32Array(sampleCount); + renderAtmosphere(samples, score); + for (const entry of timedVoices(score)) renderVoice(samples, entry.startMs, entry.voice); + + let peak = 0; + for (const sample of samples) peak = Math.max(peak, Math.abs(sample)); + const normalization = peak > 0.92 ? 0.92 / peak : 1; + const output = new Uint8Array(44 + sampleCount * 2); + const view = new DataView(output.buffer); + ascii(output, 0, 'RIFF'); + view.setUint32(4, output.length - 8, true); + ascii(output, 8, 'WAVE'); + ascii(output, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, WAV_SAMPLE_RATE, true); + view.setUint32(28, WAV_SAMPLE_RATE * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + ascii(output, 36, 'data'); + view.setUint32(40, sampleCount * 2, true); + for (let index = 0; index < sampleCount; index++) { + const value = Math.max(-1, Math.min(1, samples[index]! * normalization)); + view.setInt16(44 + index * 2, Math.round(value * (value < 0 ? 32768 : 32767)), true); + } + return output; +} + +export function encodeSymphonyMidi(score: SymphonyScore): Uint8Array { + assertExportable(score); + const events: Array<{ tick: number; priority: number; bytes: number[] }> = []; + events.push({ tick: 0, priority: 0, bytes: [0xff, 0x03, ...textBytes('Sortilune Symphony')] }); + events.push({ tick: 0, priority: 0, bytes: [0xff, 0x51, 0x03, 0x0f, 0x42, 0x40] }); // 60 BPM + events.push({ tick: 0, priority: 0, bytes: [0xff, 0x01, ...textBytes(score.exact ? score.synthesis : 'approximate legacy score')] }); + + const atmosphereNote = frequencyToMidi(score.atmosphere.fundamental_hz); + addMidiNote(events, 0, score.duration_ms, atmosphereNote, 16, 3); + for (const entry of timedVoices(score)) { + const channel = entry.event.kind === 'quake' ? 0 : entry.event.kind === 'beacon' ? 1 : 2; + addMidiNote( + events, + entry.startMs, + entry.startMs + entry.voice.duration_ms, + entry.voice.midi_note, + Math.max(8, Math.min(110, Math.round(entry.voice.gain * 480))), + channel, + ); + } + events.sort((left, right) => left.tick - right.tick || left.priority - right.priority); + const track: number[] = []; + let previous = 0; + for (const event of events) { + track.push(...variableLength(event.tick - previous), ...event.bytes); + previous = event.tick; + } + track.push(0x00, 0xff, 0x2f, 0x00); + + const output = new Uint8Array(14 + 8 + track.length); + const view = new DataView(output.buffer); + ascii(output, 0, 'MThd'); + view.setUint32(4, 6, false); + view.setUint16(8, 0, false); + view.setUint16(10, 1, false); + view.setUint16(12, 480, false); + ascii(output, 14, 'MTrk'); + view.setUint32(18, track.length, false); + output.set(track, 22); + return output; +} + +function timedVoices(score: SymphonyScore): TimedVoice[] { + const voices = score.events.flatMap((event) => event.voices.map((voice) => ({ + startMs: event.at_ms + voice.offset_ms, + voice, + event, + }))); + if (voices.length > MAX_EXPORT_VOICES) throw new RangeError('Symphony score has too many voices to export.'); + return voices; +} + +function renderAtmosphere(samples: Float32Array, score: SymphonyScore): void { + const base = score.atmosphere.fundamental_hz; + for (let index = 0; index < samples.length; index++) { + const time = index / WAV_SAMPLE_RATE; + const fadeIn = Math.min(1, time / 6); + const fadeOut = Math.min(1, (samples.length - index) / (WAV_SAMPLE_RATE * 1.2)); + const envelope = Math.max(0, Math.min(fadeIn, fadeOut)); + samples[index] = envelope * score.atmosphere.gain * ( + Math.sin(2 * Math.PI * base * time) * 0.44 + + Math.sin(2 * Math.PI * base * 2 ** (-7 / 1200) * time) * 0.36 + + Math.sin(2 * Math.PI * score.atmosphere.upper_hz * time) * 0.20 + ); + } +} + +function renderVoice(samples: Float32Array, startMs: number, voice: SymphonyVoice): void { + const start = Math.max(0, Math.floor(startMs * WAV_SAMPLE_RATE / 1_000)); + const length = Math.max(1, Math.ceil(voice.duration_ms * WAV_SAMPLE_RATE / 1_000)); + const attack = Math.max(1, Math.ceil(voice.attack_ms * WAV_SAMPLE_RATE / 1_000)); + const frequency = voice.frequency_hz * 2 ** (voice.detune_cents / 1200); + for (let offset = 0; offset < length && start + offset < samples.length; offset++) { + const attackEnvelope = Math.min(1, offset / attack); + const releaseEnvelope = Math.max(0, 1 - offset / length) ** 2; + const time = offset / WAV_SAMPLE_RATE; + const index = start + offset; + samples[index] = samples[index]! + Math.sin(2 * Math.PI * frequency * time) * voice.gain * attackEnvelope * releaseEnvelope; + } +} + +function addMidiNote( + events: Array<{ tick: number; priority: number; bytes: number[] }>, + startMs: number, + endMs: number, + note: number, + velocity: number, + channel: number, +): void { + const start = millisecondsToTicks(startMs); + const end = Math.max(start + 1, millisecondsToTicks(endMs)); + events.push({ tick: start, priority: 2, bytes: [0x90 | channel, note, velocity] }); + events.push({ tick: end, priority: 1, bytes: [0x80 | channel, note, 0] }); +} + +function millisecondsToTicks(milliseconds: number): number { + return Math.max(0, Math.round(milliseconds * 0.48)); +} + +function textBytes(value: string): number[] { + const bytes = [...new TextEncoder().encode(value)].slice(0, 127); + return [bytes.length, ...bytes]; +} + +function variableLength(value: number): number[] { + let buffer = value & 0x7f; + const output: number[] = []; + while ((value >>= 7)) { + buffer <<= 8; + buffer |= (value & 0x7f) | 0x80; + } + while (true) { + output.push(buffer & 0xff); + if (buffer & 0x80) buffer >>= 8; + else break; + } + return output; +} + +function assertExportable(score: SymphonyScore): void { + if (score.schema !== 'sortilune.symphony-score' || score.schema_version !== 1) throw new TypeError('Unsupported Symphony score.'); + if (!Number.isSafeInteger(score.duration_ms) || score.duration_ms < 1_000) throw new TypeError('Symphony score duration is invalid.'); +} + +function ascii(target: Uint8Array, offset: number, value: string): void { + for (let index = 0; index < value.length; index++) target[offset + index] = value.charCodeAt(index); +} diff --git a/src/symphony/score.ts b/src/symphony/score.ts new file mode 100644 index 0000000..c05ace7 --- /dev/null +++ b/src/symphony/score.ts @@ -0,0 +1,279 @@ +export const SYMPHONY_MAPPING = 'sortilune.symphony-mapping/v1' as const; +export const SYMPHONY_SYNTHESIS = 'sortilune.symphony-synthesis/v1' as const; +export const MAX_SCORE_MS = 30 * 60 * 1000; + +export type SymphonyEventKind = 'marker' | 'quake' | 'beacon' | 'wind' | 'motif'; + +export interface SymphonyVoice { + frequency_hz: number; + midi_note: number; + offset_ms: number; + duration_ms: number; + attack_ms: number; + gain: number; + pan: number; + detune_cents: number; + waveform: 'sine'; +} + +export interface SymphonyScoreEvent { + id: string; + at_ms: number; + kind: SymphonyEventKind; + source: 'session' | 'usgs' | 'nist' | 'weather' | 'today' | 'legacy'; + source_id: string; + label: string; + mapping: typeof SYMPHONY_MAPPING | 'legacy-approximate'; + voices: SymphonyVoice[]; + modulation?: { wind_mps: number; filter_hz: number }; + location?: { lat: number; lon: number; magnitude: number }; +} + +export interface SymphonyScore { + schema: 'sortilune.symphony-score'; + schema_version: 1; + synthesis: typeof SYMPHONY_SYNTHESIS | 'legacy-approximate'; + exact: boolean; + started_at: string; + duration_ms: number; + atmosphere: { + fundamental_hz: number; + upper_hz: number; + gain: number; + filter_hz: number; + }; + events: SymphonyScoreEvent[]; +} + +interface BaseEventInput { + atMs: number; + sourceId: string; + label: string; +} + +const D_DORIAN_MIDI = Array.from({ length: 61 }, (_, index) => 36 + index) + .filter((note) => [0, 2, 3, 5, 7, 9, 10].includes(((note - 2) % 12 + 12) % 12)); + +export const SYMPHONY_ATMOSPHERE = Object.freeze({ + fundamental_hz: round(nearestDorianFrequency(98)), + upper_hz: round(nearestDorianFrequency(98) * 2), + gain: 0.07, + filter_hz: 600, +}); + +export function quakeScoreEvent(input: BaseEventInput & { + magnitude?: number; + depth?: number; + lat?: number; + lon?: number; +}): SymphonyScoreEvent { + const magnitude = clamp(finite(input.magnitude, 0), -10, 20); + const depth = clamp(finite(input.depth, 30), 0, 700); + const lon = clamp(finite(input.lon, 0), -180, 180); + const lat = clamp(finite(input.lat, 0), -90, 90); + const targetHz = 196 + (700 - depth) * (523 - 196) / 700; + const frequency = nearestDorianFrequency(clamp(targetHz, 196, 523)); + const gain = clamp((magnitude - 1.5) / 8, 0.03, 0.22); + const detune = (hash32(input.sourceId) % 401) / 100 - 2; + return baseEvent(input, 'quake', 'usgs', [{ + frequency_hz: round(frequency), + midi_note: frequencyToMidi(frequency), + offset_ms: 0, + duration_ms: 8_000, + attack_ms: 600, + gain: round(gain), + pan: round(lon / 180), + detune_cents: round(detune), + waveform: 'sine', + }], { location: { lat: round(lat), lon: round(lon), magnitude: round(magnitude) } }); +} + +export function beaconScoreEvent(input: BaseEventInput): SymphonyScoreEvent { + const fundamental = nearestDorianFrequency(523); + const partials = [ + { multiplier: 1, gain: 0.10, duration: 6_000 }, + { multiplier: 2, gain: 0.05, duration: 4_500 }, + { multiplier: 3, gain: 0.025, duration: 3_000 }, + { multiplier: 4, gain: 0.012, duration: 2_500 }, + ]; + return baseEvent(input, 'beacon', 'nist', partials.map((partial) => ({ + frequency_hz: round(fundamental * partial.multiplier), + midi_note: frequencyToMidi(fundamental * partial.multiplier), + offset_ms: 0, + duration_ms: partial.duration, + attack_ms: 20, + gain: partial.gain, + pan: 0, + detune_cents: 0, + waveform: 'sine' as const, + }))); +} + +export function motifScoreEvent(input: BaseEventInput & { + tempo: number; + notes: number[]; + durations: number[]; + source?: 'today' | 'session'; +}): SymphonyScoreEvent { + assertMotif(input.tempo, input.notes, input.durations); + const beatMs = 60_000 / input.tempo; + let cursor = 0; + const voices = input.notes.map((note, index) => { + const duration = Math.round(input.durations[index]! * beatMs); + const voice: SymphonyVoice = { + frequency_hz: round(midiToFrequency(note)), + midi_note: note, + offset_ms: Math.round(cursor), + duration_ms: duration, + attack_ms: Math.min(80, Math.round(duration / 4)), + gain: 0.055, + pan: 0, + detune_cents: 0, + waveform: 'sine', + }; + cursor += duration; + return voice; + }); + return baseEvent(input, 'motif', input.source ?? 'today', voices); +} + +export function windScoreEvent(input: BaseEventInput & { windMps: number }): SymphonyScoreEvent { + const wind = clamp(finite(input.windMps, 0), 0, 100); + const filter = 400 + clamp(wind / 20, 0, 1) * 700; + return baseEvent(input, 'wind', 'weather', [], { + modulation: { wind_mps: round(wind), filter_hz: round(filter) }, + }); +} + +export function markerScoreEvent(input: BaseEventInput & { + source?: SymphonyScoreEvent['source']; +}): SymphonyScoreEvent { + return baseEvent(input, 'marker', input.source ?? 'session', []); +} + +export function buildSymphonyScore( + startedAt: string, + events: readonly SymphonyScoreEvent[], + stoppedAt?: string | null, +): SymphonyScore { + const started = Date.parse(startedAt); + if (!Number.isFinite(started)) throw new TypeError('Symphony score start time is invalid.'); + const ordered = structuredClone([...events]).sort((left, right) => left.at_ms - right.at_ms || left.id.localeCompare(right.id)); + const eventEnd = ordered.reduce((latest, event) => Math.max(latest, event.at_ms, ...event.voices.map((voice) => event.at_ms + voice.offset_ms + voice.duration_ms)), 0); + const stoppedDuration = stoppedAt && Number.isFinite(Date.parse(stoppedAt)) ? Math.max(0, Date.parse(stoppedAt) - started) : 0; + return { + schema: 'sortilune.symphony-score', + schema_version: 1, + synthesis: SYMPHONY_SYNTHESIS, + exact: true, + started_at: new Date(started).toISOString(), + duration_ms: clamp(Math.ceil(Math.max(1_000, eventEnd, stoppedDuration)), 1_000, MAX_SCORE_MS), + atmosphere: { ...SYMPHONY_ATMOSPHERE }, + events: ordered, + }; +} + +export function scoreFromSessionPayload(payload: unknown): SymphonyScore | null { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + const value = payload as Record; + if (isScoreShape(value.score)) return structuredClone(value.score); + if (!Array.isArray(value.events)) return null; + const startedAt = typeof value.started_at === 'string' && Number.isFinite(Date.parse(value.started_at)) + ? value.started_at : new Date(0).toISOString(); + const started = Date.parse(startedAt); + const events = value.events.slice(0, 500).map((candidate, index) => { + const item = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate as Record : {}; + const time = typeof item.time === 'number' ? item.time + : typeof item.time === 'string' ? Date.parse(item.time) : Number.NaN; + const atMs = Number.isFinite(time) ? clamp(time - started, 0, MAX_SCORE_MS) : index * 1_000; + const label = typeof item.text === 'string' && item.text.trim() ? item.text.slice(0, 500) : `Legacy event ${index + 1}`; + const event = markerScoreEvent({ atMs, sourceId: `legacy-${index + 1}`, label, source: 'legacy' }); + event.mapping = 'legacy-approximate'; + return event; + }); + const score = buildSymphonyScore(startedAt, events, typeof value.stopped_at === 'string' ? value.stopped_at : null); + score.synthesis = 'legacy-approximate'; + score.exact = false; + return score; +} + +export function midiToFrequency(note: number): number { + return 440 * 2 ** ((note - 69) / 12); +} + +export function frequencyToMidi(frequency: number): number { + return clamp(Math.round(69 + 12 * Math.log2(frequency / 440)), 0, 127); +} + +export function nearestDorianFrequency(target: number): number { + let best = midiToFrequency(D_DORIAN_MIDI[0]!); + let distance = Number.POSITIVE_INFINITY; + for (const note of D_DORIAN_MIDI) { + const frequency = midiToFrequency(note); + const candidate = Math.abs(Math.log2(frequency / target)); + if (candidate < distance) { + best = frequency; + distance = candidate; + } + } + return best; +} + +function baseEvent( + input: BaseEventInput, + kind: SymphonyEventKind, + source: SymphonyScoreEvent['source'], + voices: SymphonyVoice[], + extra: Pick = {}, +): SymphonyScoreEvent { + const atMs = clamp(Math.round(finite(input.atMs, 0)), 0, MAX_SCORE_MS); + const sourceId = String(input.sourceId).normalize('NFC').trim().slice(0, 200) || 'unknown'; + const label = String(input.label).normalize('NFC').trim().slice(0, 500) || kind; + return { + id: `${kind}-${atMs}-${hash32(`${sourceId}\0${label}`).toString(16).padStart(8, '0')}`, + at_ms: atMs, + kind, + source, + source_id: sourceId, + label, + mapping: SYMPHONY_MAPPING, + voices, + ...extra, + }; +} + +function assertMotif(tempo: number, notes: number[], durations: number[]): void { + if (!Number.isSafeInteger(tempo) || tempo < 54 || tempo > 84 + || notes.length !== 8 || notes.some((note) => !Number.isSafeInteger(note) || note < 0 || note > 127) + || durations.length !== 8 || durations.some((duration) => !Number.isSafeInteger(duration) || duration < 1 || duration > 4)) { + throw new TypeError('Symphony motif is invalid.'); + } +} + +function isScoreShape(value: unknown): value is SymphonyScore { + return Boolean(value && typeof value === 'object' && !Array.isArray(value) + && (value as SymphonyScore).schema === 'sortilune.symphony-score' + && (value as SymphonyScore).schema_version === 1 + && Array.isArray((value as SymphonyScore).events)); +} + +function hash32(value: string): number { + let hash = 0x811c9dc5; + for (const byte of new TextEncoder().encode(value)) { + hash ^= byte; + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash; +} + +function finite(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function clamp(value: number, minimum: number, maximum: number): number { + return Math.min(maximum, Math.max(minimum, value)); +} + +function round(value: number): number { + return Math.round(value * 1_000_000) / 1_000_000; +} diff --git a/src/test-entropy.html b/src/test-entropy.html deleted file mode 100644 index af6ce91..0000000 --- a/src/test-entropy.html +++ /dev/null @@ -1,99 +0,0 @@ - - - - - Sortilune — entropy bench - - - - - - -

entropy bench

-

A developer-only test page for the entropy engine. Not shipped in the user-facing shell.

- -

1. Health check all sources

- -
(no results yet)
- -

2. Draw an integer

-
- - - - -
-
(no draw yet)
- -

3. Convert kinds (deterministic helpers)

- -

-
-    
-  
-
diff --git a/src/types/spdx-expression-parse.d.ts b/src/types/spdx-expression-parse.d.ts
new file mode 100644
index 0000000..9d2286f
--- /dev/null
+++ b/src/types/spdx-expression-parse.d.ts
@@ -0,0 +1,12 @@
+declare module 'spdx-expression-parse' {
+  export interface SpdxExpressionNode {
+    license?: string;
+    exception?: string;
+    plus?: boolean;
+    conjunction?: 'and' | 'or';
+    left?: SpdxExpressionNode;
+    right?: SpdxExpressionNode;
+  }
+
+  export default function parse(expression: string): SpdxExpressionNode;
+}
diff --git a/src/ui/command-palette.ts b/src/ui/command-palette.ts
new file mode 100644
index 0000000..42d202c
--- /dev/null
+++ b/src/ui/command-palette.ts
@@ -0,0 +1,207 @@
+import { NAVIGATION_ITEMS, groupForDestination } from '../app/navigation.js';
+import type { ThemeId } from '../domain/settings.js';
+import { Dialog, type DialogController } from './primitives.js';
+
+interface PaletteCommand {
+  id: string;
+  label: string;
+  description: string;
+  group: string;
+  keywords: string[];
+  run(): void | Promise;
+}
+
+export interface CommandPaletteController {
+  open(): void;
+  close(): void;
+  recordRecent(destination: string): void;
+  destroy(): void;
+}
+
+export function createCommandPalette(options: {
+  navigate(destination: string): Promise;
+  openSettings(): void;
+  setTheme(theme: ThemeId): void;
+}): CommandPaletteController {
+  const recent: string[] = [];
+  let dialog: DialogController | null = null;
+  let activeIndex = 0;
+
+  const commands = (): PaletteCommand[] => {
+    const recentSet = new Set(recent);
+    const navigation = NAVIGATION_ITEMS.map((item) => ({
+      id: `navigate-${item.id}`,
+      label: item.label,
+      description: item.description,
+      group: recentSet.has(item.id) ? 'Recent destinations' : groupForDestination(item.id)?.label ?? 'Navigate',
+      keywords: item.keywords,
+      run: () => options.navigate(item.id),
+    }));
+    return [
+      ...navigation.sort((left, right) => {
+        const leftRecent = recent.indexOf(left.id.replace('navigate-', ''));
+        const rightRecent = recent.indexOf(right.id.replace('navigate-', ''));
+        if (leftRecent >= 0 || rightRecent >= 0) return (leftRecent < 0 ? 99 : leftRecent) - (rightRecent < 0 ? 99 : rightRecent);
+        return left.label.localeCompare(right.label);
+      }),
+      { id: 'settings', label: 'Open Settings', description: 'Appearance, sources, archive, and accessibility.', group: 'Actions', keywords: ['preferences', 'configuration'], run: options.openSettings },
+      ...([
+        ['cosmic-dark', 'Use dark theme'],
+        ['cosmic-light', 'Use light theme'],
+        ['high-contrast', 'Use high contrast theme'],
+      ] as Array<[ThemeId, string]>).map(([theme, label]) => ({
+        id: `theme-${theme}`,
+        label,
+        description: 'Change appearance.',
+        group: 'Actions',
+        keywords: ['appearance', 'theme', theme],
+        run: () => options.setTheme(theme),
+      })),
+    ];
+  };
+
+  const close = () => dialog?.close();
+
+  const open = () => {
+    if (dialog?.element.isConnected) return;
+    // A second aria-modal dialog would make both focus traps and the declared
+    // accessibility tree incorrect. Commands that launch a dialog close this
+    // palette before running, while Ctrl+K inside another dialog is ignored.
+    if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
+    const root = document.createElement('div');
+    root.className = 'command-palette';
+    const helpId = 'command-palette-help';
+    const listId = 'command-palette-results';
+    const input = document.createElement('input');
+    input.className = 'input command-palette-input';
+    input.type = 'search';
+    input.placeholder = 'Type a destination or action';
+    input.setAttribute('role', 'combobox');
+    input.setAttribute('aria-autocomplete', 'list');
+    input.setAttribute('aria-expanded', 'true');
+    input.setAttribute('aria-controls', listId);
+    input.setAttribute('aria-describedby', helpId);
+    const help = document.createElement('p');
+    help.id = helpId;
+    help.className = 'command-palette-help';
+    help.textContent = 'Use ↑ and ↓ to move, Enter to choose, and Escape to close.';
+    const status = document.createElement('div');
+    status.className = 'sr-only';
+    status.setAttribute('role', 'status');
+    status.setAttribute('aria-live', 'polite');
+    const list = document.createElement('div');
+    list.className = 'command-palette-results';
+    list.id = listId;
+    list.setAttribute('role', 'listbox');
+    root.append(input, help, status, list);
+
+    let filtered: PaletteCommand[] = [];
+    const updateActive = () => {
+      list.querySelectorAll('[role="option"]').forEach((option, index) => {
+        option.classList.toggle('is-active', index === activeIndex);
+        option.setAttribute('aria-selected', String(index === activeIndex));
+      });
+      const active = filtered[activeIndex];
+      if (active) input.setAttribute('aria-activedescendant', `command-option-${active.id}`);
+      else input.removeAttribute('aria-activedescendant');
+    };
+    const render = () => {
+      const query = input.value.trim().toLocaleLowerCase('en-US');
+      filtered = commands().filter((command) => (
+        !query || `${command.label} ${command.description} ${command.group} ${command.keywords.join(' ')}`
+          .toLocaleLowerCase('en-US')
+          .includes(query)
+      ));
+      activeIndex = Math.max(0, Math.min(activeIndex, filtered.length - 1));
+      list.replaceChildren();
+      let previousGroup = '';
+      filtered.forEach((command, index) => {
+        if (command.group !== previousGroup) {
+          const group = document.createElement('div');
+          group.className = 'command-palette-group';
+          group.textContent = command.group;
+          group.setAttribute('role', 'presentation');
+          list.append(group);
+          previousGroup = command.group;
+        }
+        const option = document.createElement('div');
+        option.className = `command-palette-option${index === activeIndex ? ' is-active' : ''}`;
+        option.id = `command-option-${command.id}`;
+        option.setAttribute('role', 'option');
+        option.setAttribute('aria-selected', String(index === activeIndex));
+        const label = document.createElement('span');
+        label.className = 'command-palette-label';
+        label.textContent = command.label;
+        const description = document.createElement('span');
+        description.className = 'command-palette-description';
+        description.textContent = command.description;
+        option.append(label, description);
+        option.addEventListener('pointermove', () => {
+          if (activeIndex === index) return;
+          activeIndex = index;
+          updateActive();
+        });
+        option.addEventListener('mousedown', (event) => event.preventDefault());
+        option.addEventListener('click', () => void invoke(command));
+        list.append(option);
+      });
+      updateActive();
+      status.textContent = `${filtered.length} command${filtered.length === 1 ? '' : 's'} available.`;
+    };
+
+    const invoke = async (command: PaletteCommand | undefined) => {
+      if (!command) return;
+      close();
+      await command.run();
+    };
+
+    input.addEventListener('input', () => { activeIndex = 0; render(); });
+    input.addEventListener('keydown', (event) => {
+      if (event.key === 'ArrowDown') activeIndex = Math.min(filtered.length - 1, activeIndex + 1);
+      else if (event.key === 'ArrowUp') activeIndex = Math.max(0, activeIndex - 1);
+      else if (event.key === 'Home') activeIndex = 0;
+      else if (event.key === 'End') activeIndex = Math.max(0, filtered.length - 1);
+      else if (event.key === 'Enter') {
+        event.preventDefault();
+        void invoke(filtered[activeIndex]);
+        return;
+      } else return;
+      event.preventDefault();
+      updateActive();
+      document.getElementById(input.getAttribute('aria-activedescendant') ?? '')?.scrollIntoView({ block: 'nearest' });
+    });
+
+    dialog = Dialog({
+      title: 'Go to or run a command',
+      className: 'command-palette-overlay',
+      content: [root],
+      initialFocus: input,
+      onClose: () => { dialog = null; },
+    });
+    render();
+    dialog.open();
+  };
+
+  const onGlobalKeydown = (event: KeyboardEvent) => {
+    if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase('en-US') === 'k') {
+      event.preventDefault();
+      open();
+    }
+  };
+  document.addEventListener('keydown', onGlobalKeydown);
+
+  return {
+    open,
+    close,
+    recordRecent(destination) {
+      const index = recent.indexOf(destination);
+      if (index >= 0) recent.splice(index, 1);
+      recent.unshift(destination);
+      recent.splice(6);
+    },
+    destroy() {
+      close();
+      document.removeEventListener('keydown', onGlobalKeydown);
+    },
+  };
+}
diff --git a/src/ui/primitives.ts b/src/ui/primitives.ts
new file mode 100644
index 0000000..47420c9
--- /dev/null
+++ b/src/ui/primitives.ts
@@ -0,0 +1,380 @@
+import type { Provenance } from '../domain/archive-record.js';
+import type { LegacyEntropyProvenance } from '../domain/contracts.js';
+
+export type UiChild = Node | string | number | null | false | UiChild[];
+export type ProvenanceInput = Provenance | LegacyEntropyProvenance | null | undefined;
+
+let generatedId = 0;
+
+function element(
+  tag: K,
+  className: string,
+  children: UiChild[] = [],
+): HTMLElementTagNameMap[K] {
+  const node = document.createElement(tag);
+  if (className) node.className = className;
+  append(node, children);
+  return node;
+}
+
+function append(parent: Node, children: UiChild[]): void {
+  for (const child of children) {
+    if (child == null || child === false) continue;
+    if (Array.isArray(child)) {
+      append(parent, child);
+      continue;
+    }
+    parent.appendChild(child instanceof Node ? child : document.createTextNode(String(child)));
+  }
+}
+
+export function ResultStage(options: {
+  children?: UiChild[];
+  label?: string;
+  status?: 'idle' | 'loading' | 'result' | 'error' | 'live';
+  className?: string;
+} = {}): HTMLElement {
+  const stage = element('section', `result-stage ${options.className ?? ''}`.trim(), options.children ?? []);
+  stage.dataset.status = options.status ?? 'result';
+  stage.setAttribute('aria-label', options.label ?? 'Result');
+  return stage;
+}
+
+export function ActionBar(options: {
+  primary?: UiChild[];
+  secondary?: UiChild[];
+  sticky?: boolean;
+  label?: string;
+} = {}): HTMLElement {
+  const bar = element('div', `action-bar${options.sticky ? ' action-bar-sticky' : ''}`);
+  bar.setAttribute('role', 'group');
+  bar.setAttribute('aria-label', options.label ?? 'Result actions');
+  bar.append(
+    element('div', 'action-bar-primary', options.primary ?? []),
+    element('div', 'action-bar-secondary', options.secondary ?? []),
+  );
+  return bar;
+}
+
+function normalizedProvenance(input: ProvenanceInput) {
+  const modern = input && 'source' in input && typeof input.source === 'object' ? input as Provenance : null;
+  const legacy = modern ? null : input as LegacyEntropyProvenance | null | undefined;
+  const sourceId = modern?.source.id ?? legacy?.source_id ?? 'unknown';
+  const sourceLabel = modern?.source.label ?? legacy?.source_name ?? sourceId;
+  const fetchedAt = modern?.fetched_at ?? legacy?.fetched_at ?? '';
+  const raw = modern?.raw ?? legacy?.raw ?? '';
+  const signature = modern?.signature ?? legacy?.signature ?? null;
+  const details = modern?.details ?? legacy?.extra ?? {};
+  const extra = legacy?.extra ?? (details && typeof details === 'object' && !Array.isArray(details) ? details : {});
+  const importantId = extra && typeof extra === 'object'
+    ? extra.pulse_index ?? extra.event_id ?? extra.chain_index ?? extra.round ?? null
+    : null;
+  const explicitVerification = extra && typeof extra === 'object'
+    ? extra.verification_state ?? extra.verification ?? null
+    : null;
+  const verification = typeof explicitVerification === 'string'
+    ? explicitVerification
+    : signature ? 'signature recorded' : sourceId === 'system' ? 'local source' : 'not verified';
+  return {
+    sourceId,
+    sourceLabel,
+    fetchedAt,
+    raw,
+    signature,
+    description: modern ? '' : legacy?.description ?? '',
+    details,
+    importantId,
+    verification,
+  };
+}
+
+export function SourceBadge(input: ProvenanceInput): HTMLElement {
+  const value = normalizedProvenance(input);
+  const badge = element('span', 'source-badge');
+  const fetchedState = value.fetchedAt ? 'fetched' : 'not fetched';
+  const important = value.importantId == null ? '' : ` · #${String(value.importantId)}`;
+  badge.setAttribute(
+    'aria-label',
+    `${value.sourceLabel}${important}; ${fetchedState}; ${value.verification}; provenance details available`,
+  );
+  badge.append(
+    element('span', 'source-badge-mark', ['◉']),
+    element('span', 'source-badge-name', [value.sourceLabel]),
+  );
+  if (value.importantId != null) badge.append(element('span', 'source-badge-id mono', [`#${String(value.importantId)}`]));
+  badge.append(
+    element('span', `source-badge-state${value.fetchedAt ? ' is-fetched' : ''}`, [fetchedState]),
+    element('span', `source-badge-verification${value.signature ? ' has-signature' : ''}`, [value.verification]),
+  );
+  return badge;
+}
+
+export function ProvenanceDisclosure(input: ProvenanceInput, options: { open?: boolean } = {}): HTMLDetailsElement {
+  const value = normalizedProvenance(input);
+  const disclosure = element('details', 'provenance-disclosure') as HTMLDetailsElement;
+  disclosure.open = options.open ?? false;
+  const summary = element('summary', 'provenance-summary', [SourceBadge(input)]);
+  summary.setAttribute('aria-label', `Details for ${value.sourceLabel} provenance`);
+  const body = element('div', 'provenance-details');
+  const rows: Array<[string, unknown]> = [
+    ['source id', value.sourceId],
+    ['fetched', value.fetchedAt || 'not fetched'],
+    ['verification', value.verification],
+    ['description', value.description || null],
+    ['raw', value.raw || null],
+    ['signature', value.signature],
+    ['details', value.details && Object.keys(value.details as object).length ? JSON.stringify(value.details, null, 2) : null],
+  ];
+  for (const [key, rowValue] of rows) {
+    if (rowValue == null || rowValue === '') continue;
+    body.append(element('div', 'provenance-row', [
+      element('span', 'provenance-key', [key]),
+      element('span', 'provenance-value selectable', [String(rowValue)]),
+    ]));
+  }
+  disclosure.append(summary, body);
+  return disclosure;
+}
+
+export interface DialogController {
+  element: HTMLElement;
+  open(): void;
+  close(): void;
+}
+
+export function Dialog(options: {
+  title: string;
+  content: UiChild[];
+  className?: string;
+  closeLabel?: string;
+  initialFocus?: HTMLElement;
+  onClose?: () => void;
+}): DialogController {
+  const titleId = `dialog-title-${++generatedId}`;
+  const overlay = element('div', `dialog-overlay ${options.className ?? ''}`.trim());
+  const panel = element('section', 'dialog-panel');
+  panel.setAttribute('role', 'dialog');
+  panel.setAttribute('aria-modal', 'true');
+  panel.setAttribute('aria-labelledby', titleId);
+  panel.tabIndex = -1;
+  const heading = element('h2', 'dialog-title', [options.title]);
+  heading.id = titleId;
+  const closeButton = element('button', 'btn btn-ghost btn-icon', ['×']) as HTMLButtonElement;
+  closeButton.type = 'button';
+  closeButton.setAttribute('aria-label', options.closeLabel ?? `Close ${options.title}`);
+  const header = element('header', 'dialog-header', [heading, closeButton]);
+  panel.append(header, element('div', 'dialog-content', options.content));
+  overlay.append(panel);
+
+  let previousFocus: HTMLElement | null = null;
+  const onKeydown = (event: KeyboardEvent) => {
+    if (event.key === 'Escape') {
+      event.preventDefault();
+      controller.close();
+      return;
+    }
+    if (event.key !== 'Tab') return;
+    const focusable = focusableElements(panel);
+    if (!focusable.length) {
+      event.preventDefault();
+      panel.focus();
+      return;
+    }
+    const first = focusable[0]!;
+    const last = focusable.at(-1)!;
+    if (event.shiftKey && document.activeElement === first) {
+      event.preventDefault();
+      last.focus();
+    } else if (!event.shiftKey && document.activeElement === last) {
+      event.preventDefault();
+      first.focus();
+    }
+  };
+  const controller: DialogController = {
+    element: overlay,
+    open() {
+      if (overlay.isConnected) return;
+      previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+      document.body.append(overlay);
+      document.addEventListener('keydown', onKeydown);
+      queueMicrotask(() => (options.initialFocus ?? focusableElements(panel)[0] ?? panel).focus());
+    },
+    close() {
+      if (!overlay.isConnected) return;
+      overlay.remove();
+      document.removeEventListener('keydown', onKeydown);
+      previousFocus?.focus();
+      previousFocus = null;
+      options.onClose?.();
+    },
+  };
+  closeButton.addEventListener('click', () => controller.close());
+  overlay.addEventListener('mousedown', (event) => {
+    if (event.target === overlay) controller.close();
+  });
+  return controller;
+}
+
+export function MenuButton(options: {
+  label: string;
+  items: Array<{ label: string; action: () => void; disabled?: boolean }>;
+}): HTMLElement {
+  const root = element('div', 'menu-button');
+  const button = element('button', 'btn', [options.label]) as HTMLButtonElement;
+  const buttonId = `menu-button-${++generatedId}`;
+  const menuId = `menu-popup-${generatedId}`;
+  button.id = buttonId;
+  button.type = 'button';
+  button.setAttribute('aria-haspopup', 'menu');
+  button.setAttribute('aria-expanded', 'false');
+  button.setAttribute('aria-controls', menuId);
+  const menu = element('div', 'menu-popup');
+  menu.id = menuId;
+  menu.setAttribute('role', 'menu');
+  menu.setAttribute('aria-labelledby', buttonId);
+  menu.hidden = true;
+  for (const item of options.items) {
+    const menuItem = element('button', 'menu-item', [item.label]) as HTMLButtonElement;
+    menuItem.type = 'button';
+    menuItem.setAttribute('role', 'menuitem');
+    menuItem.disabled = item.disabled ?? false;
+    menuItem.addEventListener('click', () => {
+      closeMenu();
+      item.action();
+      button.focus();
+    });
+    menu.append(menuItem);
+  }
+  const openMenu = (focus: 'first' | 'last' = 'first') => {
+    menu.hidden = false;
+    button.setAttribute('aria-expanded', 'true');
+    const items = focusableElements(menu);
+    (focus === 'last' ? items.at(-1) : items[0])?.focus();
+  };
+  const closeMenu = () => {
+    menu.hidden = true;
+    button.setAttribute('aria-expanded', 'false');
+  };
+  button.addEventListener('click', () => {
+    if (menu.hidden) openMenu();
+    else closeMenu();
+  });
+  button.addEventListener('keydown', (event) => {
+    if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
+    event.preventDefault();
+    openMenu(event.key === 'ArrowUp' ? 'last' : 'first');
+  });
+  menu.addEventListener('keydown', (event) => {
+    const items = focusableElements(menu);
+    const current = items.indexOf(document.activeElement as HTMLElement);
+    let next: number | null = null;
+    if (event.key === 'ArrowDown') next = (current + 1) % items.length;
+    else if (event.key === 'ArrowUp') next = (current - 1 + items.length) % items.length;
+    else if (event.key === 'Home') next = 0;
+    else if (event.key === 'End') next = items.length - 1;
+    else if (event.key === 'Escape') {
+      event.preventDefault();
+      closeMenu();
+      button.focus();
+      return;
+    } else if (event.key === 'Tab') {
+      closeMenu();
+      return;
+    } else return;
+    event.preventDefault();
+    if (next != null) items[next]?.focus();
+  });
+  root.addEventListener('focusout', (event) => {
+    if (event.relatedTarget instanceof Node && root.contains(event.relatedTarget)) return;
+    closeMenu();
+  });
+  root.append(button, menu);
+  return root;
+}
+
+export function Toolbar(label: string, children: UiChild[]): HTMLElement {
+  const toolbar = element('div', 'toolbar', children);
+  toolbar.setAttribute('role', 'toolbar');
+  toolbar.setAttribute('aria-label', label);
+  const controls = () => focusableElements(toolbar);
+  const initialize = () => controls().forEach((control, index) => { control.tabIndex = index === 0 ? 0 : -1; });
+  initialize();
+  queueMicrotask(initialize);
+  toolbar.addEventListener('focusin', (event) => {
+    if (!(event.target instanceof HTMLElement)) return;
+    controls().forEach((control) => { control.tabIndex = control === event.target ? 0 : -1; });
+  });
+  toolbar.addEventListener('keydown', (event) => {
+    if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
+    const items = controls();
+    const current = items.indexOf(document.activeElement as HTMLElement);
+    const backwards = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
+    const next = event.key === 'Home' ? 0
+      : event.key === 'End' ? items.length - 1
+        : backwards ? (current - 1 + items.length) % items.length
+          : (current + 1) % items.length;
+    event.preventDefault();
+    items[next]?.focus();
+  });
+  return toolbar;
+}
+
+export function FieldGroup(legend: string, children: UiChild[]): HTMLFieldSetElement {
+  const group = element('fieldset', 'field-group') as HTMLFieldSetElement;
+  group.append(element('legend', 'field-group-legend', [legend]));
+  append(group, children);
+  return group;
+}
+
+export function EmptyState(title: string, description: string, action?: HTMLElement): HTMLElement {
+  return element('section', 'state-panel empty-state', [
+    element('span', 'state-panel-mark', ['○']),
+    element('h3', 'state-panel-title', [title]),
+    element('p', 'state-panel-description', [description]),
+    action ?? null,
+  ]);
+}
+
+export function ErrorState(title: string, description: string, action?: HTMLElement): HTMLElement {
+  const state = element('section', 'state-panel error-state', [
+    element('span', 'state-panel-mark', ['!']),
+    element('h3', 'state-panel-title', [title]),
+    element('p', 'state-panel-description', [description]),
+    action ?? null,
+  ]);
+  state.setAttribute('role', 'alert');
+  return state;
+}
+
+export function Skeleton(lines = 3): HTMLElement {
+  const skeleton = element('div', 'skeleton');
+  skeleton.setAttribute('aria-label', 'Loading');
+  skeleton.setAttribute('role', 'status');
+  for (let line = 0; line < Math.max(1, Math.min(lines, 8)); line++) {
+    skeleton.append(element('span', 'skeleton-line'));
+  }
+  return skeleton;
+}
+
+export function ToastRegion(): HTMLElement {
+  const existing = document.getElementById('toast-region');
+  if (existing) return existing;
+  const region = element('div', 'toast-region');
+  region.id = 'toast-region';
+  region.setAttribute('role', 'status');
+  region.setAttribute('aria-label', 'Notifications');
+  region.setAttribute('aria-live', 'polite');
+  region.setAttribute('aria-relevant', 'additions');
+  document.body.append(region);
+  return region;
+}
+
+function focusableElements(root: ParentNode): HTMLElement[] {
+  return Array.from(root.querySelectorAll(
+    'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
+  )).filter((candidate) => (
+    !candidate.hidden
+    && candidate.getAttribute('aria-hidden') !== 'true'
+    && candidate.getClientRects().length > 0
+  ));
+}
diff --git a/tests/archive-annotations.test.js b/tests/archive-annotations.test.js
new file mode 100644
index 0000000..f46bf8c
--- /dev/null
+++ b/tests/archive-annotations.test.js
@@ -0,0 +1,86 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+  ANNOTATION_PATH,
+  ArchiveAnnotationRepository,
+  emptyAnnotationStore,
+} from '../src/archive/annotations.ts';
+
+const RECORD_ID = '11111111-1111-4111-8111-111111111111';
+
+class MemoryAnnotationTransport {
+  text = null;
+  writes = [];
+
+  async readText(path) {
+    assert.equal(path, ANNOTATION_PATH);
+    return this.text;
+  }
+
+  async writeArchiveAnnotations(bytes) {
+    const text = new TextDecoder().decode(bytes);
+    JSON.parse(text);
+    this.writes.push(text);
+    this.text = text;
+    return ANNOTATION_PATH;
+  }
+}
+
+test('annotation repository creates, validates, and round-trips record metadata', async () => {
+  const transport = new MemoryAnnotationTransport();
+  const repository = new ArchiveAnnotationRepository(transport);
+  const empty = await repository.load();
+  assert.deepEqual(empty.records, {});
+
+  const saved = await repository.update(RECORD_ID, {
+    title: '  Caf\u0065\u0301 moon  ',
+    tags: [' lunar ', 'lunar', 'x'.repeat(80), `x${'x'.repeat(79)}`],
+    favorite: true,
+  });
+  assert.equal(saved.records[RECORD_ID].title, 'Café moon');
+  assert.deepEqual(saved.records[RECORD_ID].tags, ['lunar', 'x'.repeat(64)]);
+  assert.equal(saved.records[RECORD_ID].favorite, true);
+  assert.equal(transport.writes.length, 1);
+  assert.deepEqual(await repository.load(), saved);
+});
+
+test('annotation mutations serialize so concurrent patches cannot lose data', async () => {
+  const transport = new MemoryAnnotationTransport();
+  const repository = new ArchiveAnnotationRepository(transport);
+  await Promise.all([
+    repository.update(RECORD_ID, { favorite: true }),
+    repository.update(RECORD_ID, { tags: ['kept'] }),
+  ]);
+  const stored = await repository.load();
+  assert.equal(stored.records[RECORD_ID].favorite, true);
+  assert.deepEqual(stored.records[RECORD_ID].tags, ['kept']);
+});
+
+test('new collection creation and record assignment use one atomic store write', async () => {
+  const transport = new MemoryAnnotationTransport();
+  const repository = new ArchiveAnnotationRepository(transport);
+  const result = await repository.updateWithNewCollection(RECORD_ID, { tags: ['kept'] }, 'Night signals');
+  assert.equal(transport.writes.length, 1);
+  assert.equal(result.collection.name, 'Night signals');
+  assert.deepEqual(result.store.records[RECORD_ID].collections, [result.collection.id]);
+  assert.equal(result.store.collections[result.collection.id].name, 'Night signals');
+});
+
+test('corrupt annotations fail visibly and are never overwritten during load', async () => {
+  const transport = new MemoryAnnotationTransport();
+  transport.text = '{broken';
+  const repository = new ArchiveAnnotationRepository(transport);
+  await assert.rejects(() => repository.load(), /not valid JSON/);
+  assert.equal(transport.writes.length, 0);
+
+  transport.text = JSON.stringify({ ...emptyAnnotationStore(), unknown: true });
+  await assert.rejects(() => repository.load(), /failed validation/);
+  assert.equal(transport.writes.length, 0);
+});
+
+test('annotation schema rejects invalid record IDs before transport write', async () => {
+  const transport = new MemoryAnnotationTransport();
+  const repository = new ArchiveAnnotationRepository(transport);
+  await assert.rejects(() => repository.update('not-an-id', { favorite: true }), /failed validation/);
+  assert.equal(transport.writes.length, 0);
+});
diff --git a/tests/archive-cache.test.js b/tests/archive-cache.test.js
new file mode 100644
index 0000000..3693e66
--- /dev/null
+++ b/tests/archive-cache.test.js
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { ARCHIVE_CACHE_PATH, ArchiveCacheRepository } from '../src/archive/cache.ts';
+
+const validRecord = {
+  id: '11111111-1111-4111-8111-111111111111',
+  chamber: 'oracle',
+  type: 'oracle-draw',
+  at: '2026-07-13T12:00:00.000Z',
+  summary: 'The Moon',
+  path: 'archive/oracle/moon.json',
+  favorite: false,
+  hidden: false,
+  tags: ['moon'],
+  collections: [],
+  relation_ids: [],
+  relation_kinds: [],
+};
+
+class MemoryCacheTransport {
+  text = null;
+
+  async readText(path) {
+    assert.equal(path, ARCHIVE_CACHE_PATH);
+    return this.text;
+  }
+
+  async writeArchiveCache(name, bytes) {
+    assert.equal(name, 'archive-index-v1.json');
+    this.text = new TextDecoder().decode(bytes);
+    return ARCHIVE_CACHE_PATH;
+  }
+
+  async clearArchiveCache() {
+    this.text = null;
+    return 1;
+  }
+}
+
+function snapshot(record = validRecord) {
+  return {
+    schema: 'sortilune.archive-index-cache',
+    schema_version: 1,
+    generated_at: '2026-07-13T12:00:00.000Z',
+    source_count: 1,
+    records: [record],
+  };
+}
+
+test('cache accepts a bounded compact snapshot and returns a defensive clone', async () => {
+  const transport = new MemoryCacheTransport();
+  transport.text = JSON.stringify(snapshot());
+  const repository = new ArchiveCacheRepository(transport);
+  const loaded = await repository.load();
+  assert.deepEqual(loaded, snapshot());
+  loaded.records[0].summary = 'mutated';
+  assert.equal((await repository.load()).records[0].summary, 'The Moon');
+});
+
+test('cache discards unknown, private, and incorrectly typed compact fields', async () => {
+  const transport = new MemoryCacheTransport();
+  const repository = new ArchiveCacheRepository(transport);
+  for (const record of [
+    { ...validRecord, unknown: true },
+    { ...validRecord, private_search_text: 'never persist this' },
+    { ...validRecord, favorite: 'yes' },
+    { ...validRecord, path: 'archive/../outside.json' },
+    { ...validRecord, at: 'not-a-date' },
+  ]) {
+    transport.text = JSON.stringify(snapshot(record));
+    assert.equal(await repository.load(), null);
+  }
+});
+
+test('cache save strips in-memory private search material before transport', async () => {
+  const transport = new MemoryCacheTransport();
+  const repository = new ArchiveCacheRepository(transport);
+  await repository.save([{ ...validRecord, private_search_text: 'diary secret' }], 1);
+  assert.doesNotMatch(transport.text, /diary secret|private_search_text/u);
+  assert.equal((await repository.load()).records.length, 1);
+});
diff --git a/tests/archive-legacy.test.js b/tests/archive-legacy.test.js
new file mode 100644
index 0000000..bc7d852
--- /dev/null
+++ b/tests/archive-legacy.test.js
@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { normalizeLegacyDiary, normalizeLegacyJson, parseFrontmatter } from '../src/archive/legacy.js';
+import { validateArchiveRecord } from '../src/schemas/validate.ts';
+
+test('legacy Diary frontmatter accepts LF and CRLF without losing nested provenance', () => {
+  const lf = '---\ndate: 2024-01-01\nquestion: "Fixture?"\nprovenance:\n  source_id: fixture\n---\nBody';
+  const crlf = lf.replaceAll('\n', '\r\n');
+  for (const input of [lf, crlf]) {
+    const parsed = parseFrontmatter(input);
+    assert.equal(parsed.meta.question, 'Fixture?');
+    assert.equal(parsed.meta.provenance.source_id, 'fixture');
+    assert.equal(parsed.body, 'Body');
+  }
+});
+
+test('legacy normalizers preserve unknown fields and reject non-object JSON', () => {
+  const data = { type: 'future-record', future_field: { value: 1 } };
+  const item = normalizeLegacyJson('oracle', '2024-01-01.json', 'archive/oracle/2024-01-01.json', data);
+  assert.deepEqual(item.payload.future_field, { value: 1 });
+  assert.equal(validateArchiveRecord(item), true, JSON.stringify(validateArchiveRecord.errors));
+  assert.throws(() => normalizeLegacyJson('oracle', 'bad.json', 'archive/oracle/bad.json', []), /must be an object/);
+  const diary = normalizeLegacyDiary('plain.md', 'archive/diary/plain.md', 'Plain text');
+  assert.equal(diary.payload.body, 'Plain text');
+  assert.equal(validateArchiveRecord(diary), true, JSON.stringify(validateArchiveRecord.errors));
+});
diff --git a/tests/archive-repository.test.js b/tests/archive-repository.test.js
new file mode 100644
index 0000000..473a214
--- /dev/null
+++ b/tests/archive-repository.test.js
@@ -0,0 +1,196 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { ArchiveRepository } from '../src/archive/repository.ts';
+
+class MemoryArchiveTransport {
+  files = new Map();
+  batches = [];
+
+  async writeBatch(files) {
+    if (files.some((file) => this.files.has(file.rel))) throw new Error('archive destination already exists');
+    this.batches.push(files);
+    for (const file of files) this.files.set(file.rel, Uint8Array.from(file.bytes));
+    return files.map((file) => file.rel);
+  }
+
+  async writeBytes(rel, bytes) {
+    this.files.set(rel, Uint8Array.from(bytes));
+    return rel;
+  }
+
+  async readText(path) {
+    const bytes = this.files.get(path);
+    return bytes ? new TextDecoder().decode(bytes) : null;
+  }
+
+  async listDir(path) {
+    const prefix = `${path}/`;
+    return [...this.files.keys()]
+      .filter((candidate) => candidate.startsWith(prefix) && !candidate.slice(prefix.length).includes('/'))
+      .map((candidate) => ({ name: candidate.slice(prefix.length), isFile: true }));
+  }
+
+  async isAvailable() { return true; }
+  async revealArchive() { return 'memory/archive'; }
+}
+
+test('repository writes a validated record and its asset in one batch', async () => {
+  const transport = new MemoryArchiveTransport();
+  const repository = new ArchiveRepository(transport);
+  const saved = await repository.save({
+    chamber: 'canvas',
+    type: 'canvas-work',
+    createdAt: '2026-07-10T12:00:00.000Z',
+    summary: 'A fixture canvas',
+    payload: { title: 'Cafe\u0301', source_result_id: 'legacy-short-id' },
+    provenance: {
+      source_id: 'system',
+      source_name: 'System CSPRNG',
+      fetched_at: '2026-07-10T12:00:00.000Z',
+      raw: '00ff',
+    },
+    assets: [{
+      role: 'artwork',
+      mediaType: 'image/svg+xml',
+      extension: 'svg',
+      content: '',
+      width: 1600,
+      height: 1000,
+    }],
+  });
+
+  assert.equal(transport.batches.length, 1);
+  assert.equal(transport.batches[0].length, 2);
+  assert.match(saved.path, /^archive\/canvas\/2026-07-10T12-00-00-000Z__canvas-work_[0-9a-f-]+\.json$/);
+  assert.equal(saved.record.schema_version, 1);
+  assert.equal(saved.record.payload.title, 'Cafe\u0301', 'repository must not normalize already-stored user text');
+  assert.equal(saved.record.assets.length, 1);
+  assert.match(saved.record.assets[0].sha256, /^[0-9a-f]{64}$/);
+  assert.equal(saved.record.assets[0].path.endsWith('.svg'), true);
+  assert.equal(saved.record.provenance[0].source.kind, 'system');
+});
+
+test('repository rejects invalid drafts before invoking the transport', async () => {
+  const transport = new MemoryArchiveTransport();
+  const repository = new ArchiveRepository(transport);
+  await assert.rejects(() => repository.save({
+    chamber: 'oracle',
+    type: 'Bad Type',
+    summary: 'bad',
+    payload: {},
+  }), /identifier/);
+  await assert.rejects(() => repository.save({
+    chamber: 'canvas',
+    type: 'canvas-work',
+    summary: 'bad asset',
+    payload: {},
+    assets: [{ role: 'art', mediaType: 'image/png', extension: 'svg', content: '' }],
+  }), /does not match/);
+  await assert.rejects(() => repository.save({
+    chamber: 'oracle',
+    type: 'too-many-assets',
+    summary: 'too many assets',
+    payload: {},
+    assets: Array.from({ length: 64 }, (_, index) => ({
+      role: `asset-${index}`,
+      mediaType: 'image/svg+xml',
+      extension: 'svg',
+      content: '',
+    })),
+  }), /at most 63 assets/);
+  assert.equal(transport.batches.length, 0);
+});
+
+test('repository pins imported pack identity and selected content snapshot', async () => {
+  const transport = new MemoryArchiveTransport();
+  const repository = new ArchiveRepository(transport);
+  const pack = {
+    id: 'example.small-oracles',
+    version: '1.0.0',
+    digest: 'a'.repeat(64),
+    item_id: 'threshold',
+    content_snapshot: { id: 'threshold', name: 'Threshold', meaning: 'A frozen meaning.' },
+  };
+  const saved = await repository.save({
+    chamber: 'oracle', type: 'oracle-draw', summary: 'Pinned custom card',
+    payload: { card: 'Threshold' }, pack,
+  });
+  assert.deepEqual(saved.record.pack, pack);
+  const loaded = await repository.readPath(saved.path);
+  assert.equal(loaded.status, 'ok');
+  assert.deepEqual(loaded.record.pack, pack);
+
+  await assert.rejects(() => repository.save({
+    chamber: 'oracle', type: 'oracle-draw', summary: 'Bad pack version', payload: {},
+    pack: { ...pack, version: 'v1' },
+  }), /archive record is invalid/);
+});
+
+test('repository returns malformed files as recoverable errors without mutation', async () => {
+  const transport = new MemoryArchiveTransport();
+  transport.files.set('archive/oracle/broken.json', new TextEncoder().encode('{broken'));
+  transport.files.set('archive/diary/2024-01-01.md', new TextEncoder().encode('Legacy diary bytes'));
+  const before = new Map([...transport.files].map(([path, bytes]) => [path, Uint8Array.from(bytes)]));
+  const repository = new ArchiveRepository(transport);
+
+  const items = await repository.list();
+  const broken = items.find((item) => item.path.endsWith('broken.json'));
+  const diary = items.find((item) => item.path.endsWith('.md'));
+  assert.equal(broken.status, 'error');
+  assert.match(broken.error, /JSON/);
+  assert.equal(diary.status, 'ok');
+  assert.equal(diary.legacy, true);
+  assert.match(diary.id, /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-a[0-9a-f]{3}-[0-9a-f]{12}$/);
+  assert.deepEqual([...transport.files], [...before]);
+});
+
+test('repository treats a malformed directory adapter response as empty', async () => {
+  const transport = new MemoryArchiveTransport();
+  transport.listDir = async () => undefined;
+  const repository = new ArchiveRepository(transport);
+  assert.deepEqual(await repository.list(), []);
+});
+
+test('logical-key saves overwrite one stable versioned record for Diary autosave', async () => {
+  const transport = new MemoryArchiveTransport();
+  const repository = new ArchiveRepository(transport);
+  const base = {
+    chamber: 'diary',
+    type: 'diary-entry',
+    createdAt: '2026-07-10T12:00:00.000Z',
+    summary: 'Diary fixture',
+    logicalKey: '2026-07-10',
+  };
+  const first = await repository.save({ ...base, payload: { body: 'first' } });
+  const second = await repository.save({ ...base, payload: { body: 'second' } });
+  assert.equal(first.path, second.path);
+  assert.equal(first.record.id, second.record.id);
+  assert.equal(transport.files.size, 1);
+  const bytes = transport.files.get(second.path);
+  assert.equal(new TextDecoder().decode(bytes), `${JSON.stringify(second.record, null, 2)}\n`);
+});
+
+test('every current chamber saves and reloads through the versioned repository contract', async () => {
+  const transport = new MemoryArchiveTransport();
+  const repository = new ArchiveRepository(transport);
+  const chambers = ['today', 'oracle', 'decider', 'diary', 'constraint', 'canvas', 'symphony', 'beacon', 'lottery'];
+  for (const chamber of chambers) {
+    await repository.save({
+      chamber,
+      type: `${chamber}-fixture`,
+      createdAt: '2026-07-10T12:00:00.000Z',
+      summary: `${chamber} round-trip`,
+      payload: { chamber, marker: `payload-${chamber}` },
+    });
+  }
+
+  const items = await repository.list();
+  assert.equal(items.length, chambers.length);
+  for (const chamber of chambers) {
+    const item = items.find((candidate) => candidate.chamber === chamber);
+    assert.equal(item.status, 'ok');
+    assert.equal(item.legacy, false);
+    assert.equal(item.record.schema, 'sortilune.archive-record');
+    assert.deepEqual(item.payload, { chamber, marker: `payload-${chamber}` });
+  }
+});
diff --git a/tests/archive-workspace.test.js b/tests/archive-workspace.test.js
new file mode 100644
index 0000000..1527e7e
--- /dev/null
+++ b/tests/archive-workspace.test.js
@@ -0,0 +1,197 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { ArchiveAnnotationRepository } from '../src/archive/annotations.ts';
+import { ArchiveCacheRepository } from '../src/archive/cache.ts';
+import { CompactArchiveIndex } from '../src/archive/compact-index.ts';
+import { ArchiveRepository } from '../src/archive/repository.ts';
+import { ArchiveWorkspace, safePayloadSearchText } from '../src/archive/workspace.ts';
+import { normalizeArchiveWatchPath } from '../src/lib/fs.ts';
+
+class MemoryArchiveTransport {
+  files = new Map();
+
+  async writeBatch(files) {
+    for (const file of files) {
+      if (this.files.has(file.rel)) throw new Error('archive destination already exists');
+      this.files.set(file.rel, Uint8Array.from(file.bytes));
+    }
+    return files.map((file) => file.rel);
+  }
+
+  async writeBytes(rel, bytes) {
+    this.files.set(rel, Uint8Array.from(bytes));
+    return rel;
+  }
+
+  async readText(path) {
+    const bytes = this.files.get(path);
+    return bytes ? new TextDecoder().decode(bytes) : null;
+  }
+
+  async listDir(path) {
+    const prefix = `${path}/`;
+    return [...this.files.keys()]
+      .filter((candidate) => candidate.startsWith(prefix) && !candidate.slice(prefix.length).includes('/'))
+      .map((candidate) => ({ name: candidate.slice(prefix.length), isFile: true }));
+  }
+
+  async exists(path) { return this.files.has(path); }
+  async isAvailable() { return true; }
+  async revealArchive() { return 'memory/archive'; }
+}
+
+class MemoryAnnotationTransport {
+  text = null;
+  async readText() { return this.text; }
+  async writeArchiveAnnotations(bytes) {
+    this.text = new TextDecoder().decode(bytes);
+    return 'archive/_sortilune/annotations.json';
+  }
+}
+
+class MemoryCacheTransport {
+  text = null;
+  clears = 0;
+  writeError = null;
+  async readText() { return this.text; }
+  async writeArchiveCache(name, bytes) {
+    assert.equal(name, 'archive-index-v1.json');
+    if (this.writeError) throw this.writeError;
+    this.text = new TextDecoder().decode(bytes);
+    return `archive/_sortilune/cache/${name}`;
+  }
+  async clearArchiveCache() { this.text = null; this.clears += 1; return 1; }
+}
+
+async function fixtureWorkspace() {
+  const source = new MemoryArchiveTransport();
+  const repository = new ArchiveRepository(source);
+  const diary = await repository.save({
+    chamber: 'diary',
+    type: 'diary-entry',
+    logicalKey: '2026-07-13',
+    createdAt: '2026-07-13T12:00:00.000Z',
+    summary: 'Daily reflection',
+    payload: { prompt: { question: 'What felt luminous?', word: 'moon' }, body: 'ultrasecret private sentence' },
+  });
+  const oracle = await repository.save({
+    chamber: 'oracle',
+    type: 'oracle-draw',
+    createdAt: '2026-07-12T12:00:00.000Z',
+    summary: 'The Moon',
+    payload: { card: { title: 'The Moon' }, unsafe_blob: 'should-never-index' },
+    relations: [{ kind: 'derived-from', target_id: diary.record.id }],
+  });
+  const annotationTransport = new MemoryAnnotationTransport();
+  const cacheTransport = new MemoryCacheTransport();
+  let watcherCallback = null;
+  let unwatchCount = 0;
+  const workspace = new ArchiveWorkspace({
+    repository,
+    annotations: new ArchiveAnnotationRepository(annotationTransport),
+    cache: new ArchiveCacheRepository(cacheTransport),
+    watcher: async (callback) => {
+      watcherCallback = callback;
+      return () => { unwatchCount += 1; };
+    },
+  });
+  return {
+    source, repository, diary, oracle, annotationTransport, cacheTransport, workspace,
+    getWatcher: () => watcherCallback,
+    getUnwatchCount: () => unwatchCount,
+  };
+}
+
+test('workspace keeps Diary body private by default and never persists it in cache', async () => {
+  const fixture = await fixtureWorkspace();
+  await fixture.workspace.load();
+  assert.equal(fixture.workspace.health.status, 'ready');
+  assert.equal(fixture.workspace.health.watcher, 'active');
+  assert.equal(fixture.workspace.search('ultrasecret').length, 0);
+  assert.equal(fixture.workspace.search('luminous').length, 1);
+  assert.doesNotMatch(fixture.cacheTransport.text, /ultrasecret/);
+
+  fixture.workspace.setSearchDiaryBody(true);
+  assert.equal(fixture.workspace.search('ultrasecret').length, 1);
+  await new Promise((resolve) => setTimeout(resolve, 0));
+  assert.doesNotMatch(fixture.cacheTransport.text, /ultrasecret/);
+  fixture.workspace.stop();
+  assert.equal(fixture.getUnwatchCount(), 1);
+});
+
+test('annotations change search and visibility without modifying source records', async () => {
+  const fixture = await fixtureWorkspace();
+  const sourceBefore = new Map([...fixture.source.files].map(([path, bytes]) => [path, Uint8Array.from(bytes)]));
+  await fixture.workspace.load();
+  await fixture.workspace.updateAnnotation(fixture.oracle.record.id, {
+    title: 'Night compass', tags: ['favorite-symbol'], favorite: true, hidden: true,
+  });
+  assert.equal(fixture.workspace.search('favorite-symbol').length, 0);
+  assert.equal(fixture.workspace.search('favorite-symbol', 'all', true)[0].path, fixture.oracle.path);
+  assert.equal(fixture.workspace.annotationFor(fixture.oracle.record.id).title, 'Night compass');
+  assert.deepEqual([...fixture.source.files], [...sourceBefore]);
+  assert.match(fixture.annotationTransport.text, /Night compass/);
+});
+
+test('incremental paths add malformed, restored, changed, and removed records without a reset', async () => {
+  const fixture = await fixtureWorkspace();
+  await fixture.workspace.load();
+  const selectedReference = fixture.workspace.search('The Moon')[0];
+  fixture.source.files.set(fixture.oracle.path, new TextEncoder().encode('{broken'));
+  await fixture.workspace.applyPaths([fixture.oracle.path]);
+  assert.equal(fixture.workspace.items.find((item) => item.path === fixture.oracle.path).status, 'error');
+  assert.equal(fixture.workspace.health.error_count, 1);
+
+  const originalBytes = fixture.source.files.get(fixture.diary.path);
+  const oracleRecord = { ...fixture.oracle.record, summary: 'Restored moon signal' };
+  fixture.source.files.set(fixture.oracle.path, new TextEncoder().encode(JSON.stringify(oracleRecord)));
+  await fixture.workspace.applyPaths([fixture.oracle.path]);
+  assert.equal(fixture.workspace.search('restored signal').length, 1);
+  assert.equal(fixture.workspace.health.error_count, 0);
+  assert.equal(selectedReference.path, fixture.oracle.path, 'existing UI references remain path-stable');
+
+  fixture.source.files.delete(fixture.oracle.path);
+  await fixture.workspace.applyPaths([fixture.oracle.path]);
+  assert.equal(fixture.workspace.items.some((item) => item.path === fixture.oracle.path), false);
+
+  fixture.source.files.set(fixture.diary.path, originalBytes);
+  const watcher = fixture.getWatcher();
+  assert.equal(typeof watcher, 'function');
+  await watcher([fixture.diary.path], { type: 'any', paths: [fixture.diary.path], attrs: {} });
+  assert.equal(fixture.workspace.search('luminous').length, 1);
+});
+
+test('path-based index retains two files that contain the same record ID', async () => {
+  const fixture = await fixtureWorkspace();
+  const copyPath = fixture.oracle.path.replace('.json', '-copy.json');
+  fixture.source.files.set(copyPath, Uint8Array.from(fixture.source.files.get(fixture.oracle.path)));
+  await fixture.workspace.load();
+  assert.equal(fixture.workspace.search('The Moon').filter((item) => item.status === 'ok').length, 2);
+
+  const index = CompactArchiveIndex.fromItems(fixture.workspace.items);
+  assert.equal(index.size, 3);
+  assert.equal(index.getById(fixture.oracle.record.id).length, 2);
+});
+
+test('safe payload extraction and watch path normalization reject private and unrelated material', () => {
+  assert.equal(safePayloadSearchText({ question: 'kept', body: 'secret', raw: 'certificate', nested: { label: 'also kept' } }), 'kept also kept');
+  assert.equal(normalizeArchiveWatchPath('C:\\Users\\me\\AppData\\archive\\oracle\\one.json'), 'archive/oracle/one.json');
+  assert.equal(normalizeArchiveWatchPath('/tmp/unrelated/file.json'), null);
+  assert.equal(normalizeArchiveWatchPath('archive/../outside.json'), null);
+});
+
+test('workspace clears a transient cache error after a successful rebuild', async () => {
+  const fixture = await fixtureWorkspace();
+  await fixture.workspace.load();
+  fixture.cacheTransport.writeError = new Error('disk temporarily busy');
+  await fixture.workspace.updateAnnotation(fixture.oracle.record.id, { favorite: true });
+  assert.equal(fixture.workspace.health.cache, 'error');
+  assert.equal(fixture.workspace.health.status, 'degraded');
+  assert.match(fixture.workspace.health.last_error, /disk temporarily busy/u);
+
+  fixture.cacheTransport.writeError = null;
+  await fixture.workspace.rebuild(false);
+  assert.equal(fixture.workspace.health.cache, 'rebuilt');
+  assert.equal(fixture.workspace.health.status, 'ready');
+  assert.equal(fixture.workspace.health.last_error, null);
+});
diff --git a/tests/beacon.test.js b/tests/beacon.test.js
new file mode 100644
index 0000000..ad6aae1
--- /dev/null
+++ b/tests/beacon.test.js
@@ -0,0 +1,44 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { computeEntryHash, verifyEntry } from '../src/chambers/beacon/verify.js';
+
+const pulse = 'ab'.repeat(64);
+
+test('Beacon checksum recipe is deterministic', async () => {
+  const first = await computeEntryHash('hello', [], pulse);
+  const second = await computeEntryHash('hello', [], pulse);
+  assert.match(first, /^[0-9a-f]{64}$/);
+  assert.equal(first, second);
+});
+
+test('Beacon verifier rejects malformed objects without throwing', async () => {
+  const malformed = await verifyEntry({ entry_hash: 123, pulse: {} });
+  assert.equal(malformed.ok, false);
+  assert.match(malformed.reason, /entry_hash/);
+
+  const badFiles = await verifyEntry({
+    entry_hash: '00'.repeat(32),
+    entry_text: 'hello',
+    file_hashes: {},
+    pulse: { chainIndex: 2, pulseIndex: 1, outputValue: pulse },
+  });
+  assert.equal(badFiles.ok, false);
+  assert.match(badFiles.reason, /file_hashes/);
+
+  const zeroPulse = await verifyEntry({
+    entry_hash: '00'.repeat(32),
+    entry_text: 'hello',
+    file_hashes: [],
+    pulse: { chainIndex: 0, pulseIndex: 0, outputValue: pulse },
+  });
+  assert.equal(zeroPulse.ok, false);
+  assert.match(zeroPulse.reason, /pulse reference/);
+});
+
+test('checksum input types are validated', async () => {
+  await assert.rejects(computeEntryHash({}, [], pulse), /entry text/);
+  await assert.rejects(computeEntryHash('hello', {}, pulse), /file hashes/);
+  await assert.rejects(computeEntryHash('hello', ['not-a-sha256'], pulse), /file hashes/);
+  await assert.rejects(computeEntryHash('hello', [], 'bad'), /pulse output/);
+});
diff --git a/tests/build-artifacts.test.js b/tests/build-artifacts.test.js
new file mode 100644
index 0000000..c5a9932
--- /dev/null
+++ b/tests/build-artifacts.test.js
@@ -0,0 +1,20 @@
+import assert from 'node:assert/strict';
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import test from 'node:test';
+
+import { inspectBuildDirectory } from '../scripts/lib/build-artifacts.mjs';
+
+test('build inspection totals nested artifacts and identifies instrumented source maps', async (context) => {
+  const directory = await mkdtemp(path.join(os.tmpdir(), 'sortilune-build-'));
+  context.after(() => rm(directory, { recursive: true, force: true }));
+  await mkdir(path.join(directory, 'assets'));
+  await writeFile(path.join(directory, 'index.html'), '1234');
+  await writeFile(path.join(directory, 'assets', 'app.js'), '123456');
+  await writeFile(path.join(directory, 'assets', 'app.js.map'), '12');
+
+  const result = await inspectBuildDirectory(directory);
+  assert.equal(result.bytes, 12);
+  assert.deepEqual(result.sourceMapFiles, [path.join(directory, 'assets', 'app.js.map')]);
+});
diff --git a/tests/built-in-content.test.js b/tests/built-in-content.test.js
new file mode 100644
index 0000000..d3e7e23
--- /dev/null
+++ b/tests/built-in-content.test.js
@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import test from 'node:test';
+import {
+  assertCompactTokenCapacity,
+  assertNoCompactTokens,
+  COMPACT_TOKEN_LIMIT,
+  COMPACT_TOKEN_START,
+} from '../scripts/lib/compact-content.mjs';
+import { inflateDeck } from '../src/chambers/oracle/decks/generated/inflate.js';
+import { phrases } from '../src/chambers/oracle/decks/generated/phrases.generated.js';
+
+const deckNames = ['tarot', 'i-ching', 'runes', 'cosmic'];
+
+for (const deckName of deckNames) {
+  test(`generated ${deckName} deck is byte-for-byte data equivalent to its source JSON`, async () => {
+    const sourceUrl = new URL(`../src/chambers/oracle/decks/${deckName}.json`, import.meta.url);
+    const generatedUrl = new URL(`../src/chambers/oracle/decks/generated/${deckName}.generated.js`, import.meta.url);
+    const expected = JSON.parse(await readFile(sourceUrl, 'utf8'));
+    const generated = (await import(generatedUrl.href)).default;
+    assert.deepEqual(generated, expected);
+  });
+}
+
+test('compact content rejects the decoder\'s complete reserved token range', () => {
+  assert.throws(
+    () => assertNoCompactTokens([String.fromCodePoint(COMPACT_TOKEN_START)]),
+    /reserved compact-content token/,
+  );
+  assert.throws(
+    () => assertNoCompactTokens([String.fromCodePoint(COMPACT_TOKEN_LIMIT - 1)]),
+    /reserved compact-content token/,
+  );
+  assert.doesNotThrow(() => assertNoCompactTokens([String.fromCodePoint(COMPACT_TOKEN_LIMIT)]));
+});
+
+test('compact content bounds generated tokens to decoder capacity', () => {
+  assert.doesNotThrow(() => assertCompactTokenCapacity(COMPACT_TOKEN_LIMIT - COMPACT_TOKEN_START));
+  assert.throws(
+    () => assertCompactTokenCapacity((COMPACT_TOKEN_LIMIT - COMPACT_TOKEN_START) + 1),
+    /token count must be an integer/,
+  );
+});
+
+test('deck inflation restores phrase tokens in nested future record fields', () => {
+  const token = String.fromCodePoint(COMPACT_TOKEN_START);
+  const [record] = inflateDeck(['nested'], {}, [[{ copy: `before${token}after`, list: [token] }]]);
+  assert.deepEqual(record, {
+    nested: { copy: `before${phrases[0]}after`, list: [phrases[0]] },
+  });
+});
diff --git a/tests/canvas-generators.test.js b/tests/canvas-generators.test.js
new file mode 100644
index 0000000..6c3acf0
--- /dev/null
+++ b/tests/canvas-generators.test.js
@@ -0,0 +1,28 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import * as constellation from '../src/chambers/canvas/generators/constellation.js';
+import * as spectral from '../src/chambers/canvas/generators/spectral.js';
+import * as interference from '../src/chambers/canvas/generators/interference.js';
+
+const seed = Uint8Array.from({ length: 64 }, (_, index) => (index * 37 + 11) % 256);
+const options = { width: 1600, height: 1000, palette: ['#d4a574', '#7fb3d5'] };
+
+test('strengthened Canvas generators stay deterministic and visibly composed', () => {
+  const cases = [
+    [constellation, 'data-layer="atlas-figures"', /data-layer="constellation-figure"/gu, 4],
+    [spectral, 'data-layer="emission-spectrum"', /data-layer="emission-line"/gu, 12],
+    [interference, 'data-layer="interference-fringes"', /data-layer="wavefronts"/gu, 2],
+  ];
+
+  for (const [generator, requiredLayer, repeatedLayer, minimumCount] of cases) {
+    const first = generator.generate(seed, options);
+    const second = generator.generate(seed, options);
+    assert.equal(first, second, `${generator.id} must replay exactly from its seed`);
+    assert.match(first, /^$/u);
+    assert.ok(first.includes(requiredLayer), `${generator.id} is missing its primary visual layer`);
+    assert.ok((first.match(repeatedLayer) || []).length >= minimumCount, `${generator.id} is under-composed`);
+    assert.ok(first.length > 12_000, `${generator.id} output is unexpectedly sparse`);
+    assert.ok(first.length < 1_000_000, `${generator.id} output is too large for interactive use`);
+  }
+});
diff --git a/tests/content-registry.test.js b/tests/content-registry.test.js
new file mode 100644
index 0000000..83e604c
--- /dev/null
+++ b/tests/content-registry.test.js
@@ -0,0 +1,87 @@
+import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import test from 'node:test';
+import { ContentRegistry } from '../src/packs/content-registry.ts';
+import { PackRepository } from '../src/packs/repository.ts';
+
+class MemoryTransport {
+  registry = null;
+  content = new Map();
+  selected = [];
+  available() { return true; }
+  async selectPackFile() { return this.selected.shift() ?? null; }
+  async readRegistry() { return this.registry; }
+  async writeRegistry(json) { this.registry = json; return 'packs/registry.json'; }
+  async readContent(key) { return this.content.get(key) ?? null; }
+  async writeContent(key, json) { this.content.set(key, json); return key; }
+  async deleteContent(key) { return this.content.delete(key); }
+  async exportPackFile() { return null; }
+}
+
+async function install(repository, transport, filename) {
+  const json = await readFile(path.join(process.cwd(), 'docs', 'packs', 'examples', filename), 'utf8');
+  transport.selected.push({ source_name: filename, json_text: json, bytes: json.length });
+  return repository.importFromDialog();
+}
+
+test('built-in content is exposed through the read-only registry contract', async () => {
+  const repository = new PackRepository(new MemoryTransport());
+  const registry = new ContentRegistry(repository);
+  const decks = registry.oracleDecks();
+  assert.deepEqual(decks.map((deck) => [deck.id, deck.count]), [['tarot', 78], ['i-ching', 64], ['runes', 24], ['cosmic', 36]]);
+  assert.equal((await decks[3].load()).length, 36);
+  const libraries = registry.constraintLibraries();
+  assert.deepEqual(libraries.map((library) => library.id), ['creative', 'behavioral', 'perceptual', 'linguistic', 'whimsical']);
+  assert.equal((await libraries[0].load())[0].category, 'creative');
+  const diary = await registry.diaryLibraries()[0].load();
+  assert.equal(diary.prompts.length, 206);
+  assert.equal(diary.words.length, 658);
+  assert.equal(registry.canvasPalettes()[0].colors.length, 8);
+  registry.dispose();
+});
+
+test('effective imported content appears in every supported registry view', async () => {
+  const transport = new MemoryTransport();
+  const repository = new PackRepository(transport);
+  const registry = new ContentRegistry(repository);
+  await repository.load();
+  await install(repository, transport, 'small-oracles.sortilune-pack.json');
+  await install(repository, transport, 'gentle-constraints.sortilune-pack.json');
+  await install(repository, transport, 'evening-pages.sortilune-pack.json');
+  await install(repository, transport, 'everyday-picks.sortilune-pack.json');
+  await install(repository, transport, 'observatory-palettes.sortilune-pack.json');
+
+  const customDeck = registry.oracleDecks().find((deck) => deck.source.type === 'pack');
+  assert.equal(customDeck.count, 3);
+  assert.equal(customDeck.label, 'Small Oracles 1.0.0');
+  assert.equal((await customDeck.load())[0].name, 'Threshold');
+  const customConstraints = registry.constraintLibraries().find((library) => library.source.type === 'pack');
+  assert.equal((await customConstraints.load()).length, 3);
+  const customDiary = registry.diaryLibraries().find((library) => library.source.type === 'pack');
+  assert.equal((await customDiary.load()).prompts[0].id, 'unexpected-detail');
+  assert.equal(registry.lotteryPresets('wheel')[0].items.length, 4);
+  assert.equal(registry.canvasPalettes().find((palette) => palette.source.type === 'pack').colors[0], '#152238');
+
+  const reference = registry.reference(customDeck.source, 'threshold', (await customDeck.load())[0]);
+  assert.equal(reference.id, 'example.small-oracles');
+  assert.equal(reference.version, '1.0.0');
+  assert.match(reference.digest, /^[0-9a-f]{64}$/);
+  assert.equal(reference.content_snapshot.name, 'Threshold');
+  registry.dispose();
+});
+
+test('disabled and invalid packs disappear without mutating completed snapshots', async () => {
+  const transport = new MemoryTransport();
+  const repository = new PackRepository(transport);
+  const registry = new ContentRegistry(repository);
+  await repository.load();
+  const installed = await install(repository, transport, 'small-oracles.sortilune-pack.json');
+  const deck = registry.oracleDecks().find((item) => item.source.type === 'pack');
+  const selected = (await deck.load())[0];
+  const pinned = registry.reference(deck.source, selected.id, selected);
+  await repository.setEnabled(installed.pack.pack_id, installed.pack.version, false);
+  assert.equal(registry.oracleDecks().some((item) => item.source.type === 'pack'), false);
+  assert.equal(pinned.content_snapshot.name, 'Threshold');
+  registry.dispose();
+});
diff --git a/tests/content.test.js b/tests/content.test.js
new file mode 100644
index 0000000..5844e8e
--- /dev/null
+++ b/tests/content.test.js
@@ -0,0 +1,27 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+
+const read = (path) => JSON.parse(fs.readFileSync(new URL(path, import.meta.url), 'utf8'));
+
+test('Oracle deck sizes and IDs are valid', () => {
+  for (const [name, expected] of Object.entries({ tarot: 78, 'i-ching': 64, runes: 24, cosmic: 36 })) {
+    const deck = read(`../src/chambers/oracle/decks/${name}.json`);
+    assert.equal(deck.length, expected, name);
+    assert.equal(new Set(deck.map((card) => card.id ?? card.name)).size, expected, `${name} IDs`);
+  }
+});
+
+test('Diary and Constraint libraries contain no blanks or duplicates', () => {
+  const paths = [
+    '../src/chambers/diary/prompts.json',
+    '../src/chambers/diary/words.json',
+    ...['creative', 'behavioral', 'perceptual', 'linguistic', 'whimsical']
+      .map((name) => `../src/chambers/constraint/libraries/${name}.json`),
+  ];
+  for (const path of paths) {
+    const values = read(path);
+    assert.ok(values.every((value) => String(value).trim()), path);
+    assert.equal(new Set(values).size, values.length, path);
+  }
+});
diff --git a/tests/domain.test.js b/tests/domain.test.js
new file mode 100644
index 0000000..ecb98f4
--- /dev/null
+++ b/tests/domain.test.js
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+  createStableId,
+  isRfc3339Timestamp,
+  isSha256Hex,
+  isStableId,
+  parseRfc3339Timestamp,
+} from '../src/domain/identifiers.ts';
+import { normalizeImportedText, preserveStoredText } from '../src/domain/text.ts';
+
+test('stable IDs and RFC 3339 timestamps have strict runtime guards', () => {
+  assert.equal(isStableId(createStableId()), true);
+  assert.equal(isStableId('not-an-id'), false);
+  assert.equal(isRfc3339Timestamp('2026-07-11T04:45:00.000Z'), true);
+  assert.equal(isRfc3339Timestamp('2026-07-11 04:45:00'), false);
+  assert.throws(() => parseRfc3339Timestamp('2026-02-30T00:00:00Z'), /RFC 3339/);
+  assert.equal(isSha256Hex('ab'.repeat(32)), true);
+  assert.equal(isSha256Hex('AB'.repeat(32)), false);
+});
+
+test('imported text normalizes to NFC only at the explicit boundary', () => {
+  const decomposed = 'Cafe\u0301';
+  assert.equal(normalizeImportedText(decomposed), 'Café');
+  assert.equal(preserveStoredText(decomposed), decomposed);
+  assert.throws(() => normalizeImportedText('\uD800'), /unpaired/);
+  assert.throws(() => normalizeImportedText('four', { maxCodeUnits: 3 }), /exceeds/);
+});
diff --git a/tests/entropy.test.js b/tests/entropy.test.js
new file mode 100644
index 0000000..799dc69
--- /dev/null
+++ b/tests/entropy.test.js
@@ -0,0 +1,80 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { convert, neededBytes } from '../src/lib/entropy/convert.js';
+
+test('float conversion always remains in [0, 1)', () => {
+  const value = convert(new Uint8Array(8).fill(255), { kind: 'float', count: 1 }).value;
+  assert.ok(value >= 0);
+  assert.ok(value < 1);
+});
+
+test('integer conversion stays inside an inclusive range', () => {
+  const value = convert(Uint8Array.from([42, 7, 9, 11, 13, 15, 17, 19]), {
+    kind: 'integer', count: 1, range: [10, 20],
+  }).value;
+  assert.ok(value >= 10 && value <= 20);
+});
+
+test('permutation conversion preserves every item exactly once', () => {
+  const choices = ['a', 'b', 'c', 'd', 'e'];
+  const bytes = Uint8Array.from({ length: neededBytes({ kind: 'permutation', choices }) }, (_, i) => i * 17 % 256);
+  const value = convert(bytes, { kind: 'permutation', choices }).value;
+  assert.deepEqual([...value].sort(), [...choices].sort());
+});
+
+test('explicit sources do not silently fall back when disabled', async () => {
+  globalThis.window = { fetch: globalThis.fetch };
+  const entropy = await import('../src/lib/entropy/index.js');
+  entropy.setEnabled({
+    'nist-beacon': false,
+    'anu-quantum': false,
+    'random-org': false,
+    'usgs-seismic': false,
+    'open-meteo': false,
+    system: true,
+  });
+  await assert.rejects(
+    entropy.request({ kind: 'integer', range: [0, 1], source: 'nist-beacon' }),
+    /no requested sources are enabled/,
+  );
+  const system = await entropy.request({ kind: 'integer', range: [0, 1], source: 'system' });
+  assert.equal(system.provenance.source_id, 'system');
+});
+
+test('unsafe integer ranges and oversized counts are rejected', async () => {
+  globalThis.window = { fetch: globalThis.fetch };
+  const entropy = await import('../src/lib/entropy/index.js');
+  await assert.rejects(
+    entropy.request({ kind: 'integer', range: [0, Number.MAX_SAFE_INTEGER + 1], source: 'system' }),
+    /safe integers/,
+  );
+  await assert.rejects(
+    entropy.request({ kind: 'bytes', count: 65_537, source: 'system' }),
+    /65,536/,
+  );
+  await assert.rejects(
+    entropy.request({ kind: 'integer', range: '01', source: 'system' }),
+    /requires range/,
+  );
+  await assert.rejects(
+    entropy.request({ kind: 'permutation', source: 'system' }),
+    /choices array/,
+  );
+  await assert.rejects(
+    entropy.request({ kind: 'permutation', choices: ['a', 'b'], count: 2, source: 'system' }),
+    /do not accept count/,
+  );
+});
+
+test('local system randomness remains available during repeated personal use', async () => {
+  globalThis.window = { fetch: globalThis.fetch };
+  const entropy = await import('../src/lib/entropy/index.js');
+  entropy.reset();
+  const draws = await Promise.all(Array.from({ length: 40 }, () => (
+    entropy.request({ kind: 'bytes', count: 64, source: 'system' })
+  )));
+  assert.equal(draws.length, 40);
+  assert.ok(draws.every((draw) => draw.provenance.source_id === 'system'));
+  assert.equal(new Set(draws.map((draw) => draw.provenance.raw)).size, draws.length);
+});
diff --git a/tests/fixtures.test.js b/tests/fixtures.test.js
new file mode 100644
index 0000000..b7c3595
--- /dev/null
+++ b/tests/fixtures.test.js
@@ -0,0 +1,60 @@
+import assert from 'node:assert/strict';
+import { readFile, readdir } from 'node:fs/promises';
+import { join } from 'node:path';
+import test from 'node:test';
+import { generateArchive, syntheticRecord } from '../scripts/archive-fixtures.mjs';
+import { fixtureArchive, inspectCommittedFixtures, verifyBackupRestore } from '../scripts/fixture-integrity.mjs';
+import { normalizeLegacyDiary, normalizeLegacyJson } from '../src/archive/legacy.js';
+
+test('committed legacy fixtures cover valid, malformed, duplicate, paired, and orphan states', async () => {
+  const tree = await inspectCommittedFixtures();
+  assert.equal(tree.entries.length, 13);
+});
+
+test('fixture archive survives a byte-for-byte backup and restore', async () => {
+  const result = await verifyBackupRestore(async (restored) => {
+    let readable = 0;
+    let expectedErrors = 0;
+    for (const chamber of await readdir(restored, { withFileTypes: true })) {
+      if (!chamber.isDirectory()) continue;
+      for (const entry of await readdir(join(restored, chamber.name), { withFileTypes: true })) {
+        if (!entry.isFile()) continue;
+        const full = join(restored, chamber.name, entry.name);
+        try {
+          if (entry.name.endsWith('.json')) {
+            normalizeLegacyJson(chamber.name, entry.name, full, JSON.parse(await readFile(full, 'utf8')));
+            readable += 1;
+          } else if (entry.name.endsWith('.md') && chamber.name === 'diary') {
+            normalizeLegacyDiary(entry.name, full, await readFile(full, 'utf8'));
+            readable += 1;
+          }
+        } catch {
+          expectedErrors += 1;
+        }
+      }
+    }
+    assert.equal(readable, 10);
+    assert.equal(expectedErrors, 2);
+  });
+  assert.equal(result.entries.length, 13);
+  assert.match(result.digest, /^[a-f0-9]{64}$/);
+});
+
+test('synthetic fixture records are deterministic and bounded', async () => {
+  assert.deepEqual(syntheticRecord(42), syntheticRecord(42));
+  assert.notEqual(syntheticRecord(42).value.id, syntheticRecord(43).value.id);
+  await assert.rejects(() => generateArchive(join('.tmp', 'archive-fixtures', 'invalid'), 10_001), /count/);
+  await assert.rejects(() => generateArchive(join('tests', 'unsafe-output'), 1), /must remain inside/);
+});
+
+test('generated archive count and manifest digest are reproducible', async () => {
+  const root = join('.tmp', 'archive-fixtures', `test-100-${process.pid}`);
+  const first = await generateArchive(root, 100);
+  const firstManifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'));
+  const second = await generateArchive(root, 100);
+  assert.deepEqual(first, second);
+  assert.deepEqual(firstManifest, second);
+  assert.equal(second.count, 100);
+  assert.match(second.tree_digest_sha256, /^[a-f0-9]{64}$/);
+  assert.equal(fixtureArchive.endsWith(join('source', 'archive')), true);
+});
diff --git a/tests/fixtures/archive/source/archive/beacon/2024-01-07T00-00-00-000Z__entry_fixture.json b/tests/fixtures/archive/source/archive/beacon/2024-01-07T00-00-00-000Z__entry_fixture.json
new file mode 100644
index 0000000..c18d405
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/beacon/2024-01-07T00-00-00-000Z__entry_fixture.json
@@ -0,0 +1,12 @@
+{
+  "human_summary": "Synthetic Beacon checksum entry",
+  "type": "beacon-entry",
+  "sealed_at": "2024-01-07T00:00:00.000Z",
+  "entry_text": "synthetic fixture",
+  "entry_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+  "pulse": {
+    "chainIndex": 1,
+    "pulseIndex": 1,
+    "outputValue": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
+  }
+}
diff --git a/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__orphan_fixture.json b/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__orphan_fixture.json
new file mode 100644
index 0000000..6d42598
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__orphan_fixture.json
@@ -0,0 +1,9 @@
+{
+  "human_summary": "Synthetic orphaned Canvas metadata",
+  "type": "canvas-work",
+  "id": "fixture-canvas-orphan",
+  "drawn_at": "2024-01-05T00:00:00.000Z",
+  "generator": "constellation",
+  "svg_path": "archive/canvas/missing-sibling.svg",
+  "provenance": {"source_id": "fixture-v1", "raw": "05ff"}
+}
diff --git a/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__work_fixture.json b/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__work_fixture.json
new file mode 100644
index 0000000..40b65b8
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__work_fixture.json
@@ -0,0 +1,9 @@
+{
+  "human_summary": "Synthetic Canvas pair",
+  "type": "canvas-work",
+  "id": "fixture-canvas-0001",
+  "drawn_at": "2024-01-05T00:00:00.000Z",
+  "generator": "constellation",
+  "svg_path": "archive/canvas/2024-01-05T00-00-00-000Z__work_fixture.svg",
+  "provenance": {"source_id": "fixture-v1", "raw": "05"}
+}
diff --git a/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__work_fixture.svg b/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__work_fixture.svg
new file mode 100644
index 0000000..a665c55
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/canvas/2024-01-05T00-00-00-000Z__work_fixture.svg
@@ -0,0 +1 @@
+
diff --git a/tests/fixtures/archive/source/archive/constraint/2024-01-03T00-00-00-000Z__constraint_fixture.json b/tests/fixtures/archive/source/archive/constraint/2024-01-03T00-00-00-000Z__constraint_fixture.json
new file mode 100644
index 0000000..72b6e57
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/constraint/2024-01-03T00-00-00-000Z__constraint_fixture.json
@@ -0,0 +1,12 @@
+{
+  "human_summary": "Constraint accepted (creative): synthetic constraint",
+  "type": "constraint",
+  "id": "fixture-constraint-0001",
+  "drawn_at": "2024-01-03T00:00:00.000Z",
+  "category": "creative",
+  "text": "Use only synthetic fixture material.",
+  "accepted": true,
+  "passed": false,
+  "disposition_at": "2024-01-03T00:00:01.000Z",
+  "provenance": {"source_id": "fixture-v1", "raw": "03"}
+}
diff --git a/tests/fixtures/archive/source/archive/decider/2024-01-02T00-00-00-000Z__decision_duplicate-a.json b/tests/fixtures/archive/source/archive/decider/2024-01-02T00-00-00-000Z__decision_duplicate-a.json
new file mode 100644
index 0000000..8d7ed2d
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/decider/2024-01-02T00-00-00-000Z__decision_duplicate-a.json
@@ -0,0 +1,11 @@
+{
+  "human_summary": "Decision fixture A",
+  "type": "decider-decision",
+  "id": "fixture-duplicate-id",
+  "drawn_at": "2024-01-02T00:00:00.000Z",
+  "question": "Which synthetic path?",
+  "chosen": "A",
+  "chosen_index": 0,
+  "options": ["A", "B"],
+  "provenance": {"source_id": "fixture-v1", "raw": "01"}
+}
diff --git a/tests/fixtures/archive/source/archive/decider/2024-01-02T00-01-00-000Z__decision_duplicate-b.json b/tests/fixtures/archive/source/archive/decider/2024-01-02T00-01-00-000Z__decision_duplicate-b.json
new file mode 100644
index 0000000..ad76e60
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/decider/2024-01-02T00-01-00-000Z__decision_duplicate-b.json
@@ -0,0 +1,11 @@
+{
+  "human_summary": "Decision fixture B with a duplicate legacy ID",
+  "type": "decider-decision",
+  "id": "fixture-duplicate-id",
+  "drawn_at": "2024-01-02T00:01:00.000Z",
+  "question": "Which other synthetic path?",
+  "chosen": "B",
+  "chosen_index": 1,
+  "options": ["A", "B"],
+  "provenance": {"source_id": "fixture-v1", "raw": "02"}
+}
diff --git a/tests/fixtures/archive/source/archive/diary/2024-01-04.md b/tests/fixtures/archive/source/archive/diary/2024-01-04.md
new file mode 100644
index 0000000..27edd1c
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/diary/2024-01-04.md
@@ -0,0 +1,16 @@
+---
+date: 2024-01-04
+drawn_at: 2024-01-04T00:00:00.000Z
+question: "What makes a fixture trustworthy?"
+word: "reproducible"
+number: 4
+color: "#445566"
+direction: east
+provenance:
+  source_id: fixture-v1
+  source_name: "Synthetic fixture"
+  fetched_at: 2024-01-04T00:00:00.000Z
+  raw: 04
+---
+
+This is synthetic test prose. It contains no personal journal content.
diff --git a/tests/fixtures/archive/source/archive/lottery/2024-01-08T00-00-00-000Z__dice_fixture.json b/tests/fixtures/archive/source/archive/lottery/2024-01-08T00-00-00-000Z__dice_fixture.json
new file mode 100644
index 0000000..6ed7b6a
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/lottery/2024-01-08T00-00-00-000Z__dice_fixture.json
@@ -0,0 +1,9 @@
+{
+  "human_summary": "Synthetic d6 roll: 4",
+  "type": "lottery-dice",
+  "id": "fixture-lottery-0001",
+  "drawn_at": "2024-01-08T00:00:00.000Z",
+  "sides": 6,
+  "result": 4,
+  "provenance": {"source_id": "fixture-v1", "raw": "08"}
+}
diff --git a/tests/fixtures/archive/source/archive/oracle/2024-01-01T00-00-00-000Z__draw_fixture.json b/tests/fixtures/archive/source/archive/oracle/2024-01-01T00-00-00-000Z__draw_fixture.json
new file mode 100644
index 0000000..18fc5d0
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/oracle/2024-01-01T00-00-00-000Z__draw_fixture.json
@@ -0,0 +1,25 @@
+{
+  "human_summary": "Oracle (cosmic) draw: past=The Synthetic Moon",
+  "type": "oracle-draw",
+  "id": "fixture-oracle-0001",
+  "deckId": "cosmic",
+  "drawn_at": "2024-01-01T00:00:00.000Z",
+  "cards": [
+    {
+      "position": "past",
+      "card": {
+        "id": "synthetic-moon",
+        "name": "The Synthetic Moon"
+      }
+    }
+  ],
+  "provenance": {
+    "source_id": "fixture-v1",
+    "source_name": "Synthetic fixture",
+    "fetched_at": "2024-01-01T00:00:00.000Z",
+    "raw": "00010203"
+  },
+  "future_unknown_field": {
+    "preserve_on_read": true
+  }
+}
diff --git a/tests/fixtures/archive/source/archive/oracle/corrupted.json b/tests/fixtures/archive/source/archive/oracle/corrupted.json
new file mode 100644
index 0000000..5f53ae7
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/oracle/corrupted.json
@@ -0,0 +1 @@
+{ this is deliberately invalid JSON }
diff --git a/tests/fixtures/archive/source/archive/oracle/truncated.json b/tests/fixtures/archive/source/archive/oracle/truncated.json
new file mode 100644
index 0000000..d2791e9
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/oracle/truncated.json
@@ -0,0 +1 @@
+{"type":"oracle-draw","id":"truncated"
diff --git a/tests/fixtures/archive/source/archive/symphony/2024-01-06T00-00-00-000Z__session_fixture.json b/tests/fixtures/archive/source/archive/symphony/2024-01-06T00-00-00-000Z__session_fixture.json
new file mode 100644
index 0000000..9b03860
--- /dev/null
+++ b/tests/fixtures/archive/source/archive/symphony/2024-01-06T00-00-00-000Z__session_fixture.json
@@ -0,0 +1,8 @@
+{
+  "human_summary": "Synthetic Symphony session",
+  "type": "symphony-session",
+  "id": "fixture-symphony-0001",
+  "started_at": "2024-01-06T00:00:00.000Z",
+  "stopped_at": "2024-01-06T00:01:00.000Z",
+  "events": [{"at": 0, "kind": "fixture", "value": 0.5}]
+}
diff --git a/tests/fixtures/nist/certificate-5501e3d7.pem b/tests/fixtures/nist/certificate-5501e3d7.pem
new file mode 100644
index 0000000..fdd3d70
--- /dev/null
+++ b/tests/fixtures/nist/certificate-5501e3d7.pem
@@ -0,0 +1,42 @@
+-----BEGIN CERTIFICATE-----
+MIIHWzCCBkOgAwIBAgIQCrOnD+Dvk39rGwz3xX6StTANBgkqhkiG9w0BAQsFADBE
+MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMR4wHAYDVQQDExVE
+aWdpQ2VydCBHbG9iYWwgQ0EgRzIwHhcNMTgwMTEwMDAwMDAwWhcNMTkwMTExMTIw
+MDAwWjCBozELMAkGA1UEBhMCVVMxETAPBgNVBAgTCE1hcnlsYW5kMRUwEwYDVQQH
+EwxHYWl0aGVyc2J1cmcxNzA1BgNVBAoTLk5hdGlvbmFsIEluc3RpdHV0ZSBvZiBT
+dGFuZGFyZHMgYW5kIFRlY2hub2xvZ3kxEDAOBgNVBAsTB0lUTC9DU0QxHzAdBgNV
+BAMTFmVuZ2luZS5iZWFjb24ubmlzdC5nb3YwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQDqdbhEDfBOCzCjW6cKBSKkkl8pc7v6VDDI21VAs5fOyZRoSwfW
+tHc9YkVoYBaNTfmUBW5Q8hWbk65VftCznjEFIa05ldME/ABGKSkQKFyC3ELsE4+e
+nkM1I9EJOt9dCSH9dSmzwjFf8C/fxhGqYEatH8GenuQ/FbU7shiigiqHUJU9SSVZ
+trH4qV7szmcIBd/VzVTgLFipF8nl6EoScEIdgOC+ZmRo0LLfB/ulUT7iaXuzB0GP
+ocMjwk4yfJgHNkHitgGMoDNYGVz4sU0QCtQSAXjjvwAMb+EzGBV08Zj2qNMEANKX
+cvdRRA340t3oC6PbmeW+7w+IRo0to8AqhUlSAobmty6pOUzykEdhg/g6FKOowQEz
+JZkhHd1/7Fh7XRHvc6EKz5tjAP+c5MUP9ni6O2N6uzrNbm94p0JmGk6DwJVWS0A1
+l7M/xjON3aZN2f2ZSlezurBh9GBWENniHUsG/iOJdtjb+VE8VCr+J3Ltn62CfbgU
+4aW+XbwHtZq+jtLzf2VKgHpeM4LKCzgQbhSbJNuuNCm2ib8PaS/f+kz+p9D8Rd/q
+Te4/w03a+bqZRBcql8K7n73ysT+r595oos6AQujj4rgOHU7byZDLRGbMHPhox4OD
+Dd7iYGhMHcfUez7FYJF0zNQafEt7iZP6U/PigtPoi3lJNPLcijbNcFNSowIDAQAB
+o4IC5zCCAuMwHwYDVR0jBBgwFoAUJG4rLdBqklFRJWkBqppHponnQCAwHQYDVR0O
+BBYEFB69FZvO+nhCyDuDd1uLZchw4+j3MCEGA1UdEQQaMBiCFmVuZ2luZS5iZWFj
+b24ubmlzdC5nb3YwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMB
+BggrBgEFBQcDAjB3BgNVHR8EcDBuMDWgM6Axhi9odHRwOi8vY3JsMy5kaWdpY2Vy
+dC5jb20vRGlnaUNlcnRHbG9iYWxDQUcyLmNybDA1oDOgMYYvaHR0cDovL2NybDQu
+ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0R2xvYmFsQ0FHMi5jcmwwTAYDVR0gBEUwQzA3
+BglghkgBhv1sAQEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQu
+Y29tL0NQUzAIBgZngQwBAgIwdAYIKwYBBQUHAQEEaDBmMCQGCCsGAQUFBzABhhho
+dHRwOi8vb2NzcC5kaWdpY2VydC5jb20wPgYIKwYBBQUHMAKGMmh0dHA6Ly9jYWNl
+cnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbENBRzIuY3J0MAkGA1UdEwQC
+MAAwggEFBgorBgEEAdZ5AgQCBIH2BIHzAPEAdgC72d+8H4pxtZOUI5eqkntHOFeV
+CqtS6BqQlmQ2jh7RhQAAAWDgpJnPAAAEAwBHMEUCIQC1eW0bNSeOZNYU8Of5/oZr
+fruiiiIYK/QKct/hCsXgdwIgS5/nMOviHGu2/FLTMkWFAva+wBVGNrsRdbD6yLoQ
+BNgAdwCHdb/nWXz4jEOZX73zbv9WjUdWNv9KtWDBtOr/XqCDDwAAAWDgpJm5AAAE
+AwBIMEYCIQCq1AvkODGe/zTX56kishId42HRCiEDa1/Wq8F9/DOabwIhAI5UbEeE
+Q20nuadFVxpJgirXFYpAzjlr6/emIXRc5E1LMA0GCSqGSIb3DQEBCwUAA4IBAQAs
+WjP0Sj4r2nHWLKi45aUwhS+WZzq3cDPa92QDP6LnFTtzUicPpOyLYcWOsc7SKyGi
+BlWgxHp9vmoO+25gCXSbet42Yl1PXFhTpZcPHPxO/BknRGe9CY1pmOOyjsxwMZ8a
+qH2He7anCpHk5AEfLX0F+WHjEtnYBrRhMM6GtftHXXxuAhGuH0zmzbwakREOoWNO
+Q2iTFVBb9UybxHbl9r0rVD3x5FpRrTf90l5dhrERzhtjone/DtQU/5wRagRTKjeQ
+VaCu59An0vNCJYVWPbOypZCRdbcKlcd3GoMx2DosfFcdgSLR1h9+O0y3DsonJgiT
+Q2l+vxzeaIsZqUEYrLaq
+-----END CERTIFICATE-----
diff --git a/tests/fixtures/nist/chain-1-pulse-220394.json b/tests/fixtures/nist/chain-1-pulse-220394.json
new file mode 100644
index 0000000..fabc4f7
--- /dev/null
+++ b/tests/fixtures/nist/chain-1-pulse-220394.json
@@ -0,0 +1,29 @@
+{
+  "pulse": {
+    "uri": "https://beacon.nist.gov/beacon/2.0/chain/1/pulse/220394",
+    "version": "Version 2.0",
+    "cipherSuite": 0,
+    "period": 60000,
+    "certificateId": "5501e3d72bc42f3b96e16de4dcadcb16768e109662bd16d667d5fd9aee585af31bbdc5dd4f53592276064b53dddd76c8f3604b2a41db6e09f78f82bb5d6569e7",
+    "chainIndex": 1,
+    "pulseIndex": 220394,
+    "timeStamp": "2018-12-26T16:07:00.000Z",
+    "localRandomValue": "5FF1E44E70C019C42C77FA72D5228A2E663416D0778BFAC826F6B4757B634B076C50ED2D5A3975CBAF237C211A027EDAFF3E241A885D69EAA7237E2744E6C1E2",
+    "external": {
+      "sourceId": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
+      "statusCode": 0,
+      "value": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
+    },
+    "listValues": [
+      { "uri": "https://beacon.nist.gov/beacon/2.0/chain/1/pulse/220393", "type": "previous", "value": "BA646CC4E7AE195D2C85E9D3AE9C9722B974F2134699D2493FA9E296C34995E8E471B329CB5F63235982CEE3395A749C618E61466847951D543ADC2FBAD23ECB" },
+      { "uri": "https://beacon.nist.gov/beacon/2.0/chain/1/pulse/220387", "type": "hour", "value": "E75A5877169CC15220BCB11C8DA18159F14B880D85C5F3E9E462D010DC49BFCFE36D116D72C1D32A95AE8FFD9F0B6CE20DC073ED881BA36D5EF4687DC5B12328" },
+      { "uri": "https://beacon.nist.gov/beacon/2.0/chain/1/pulse/219427", "type": "day", "value": "CDD24473B4427C3D3C856C66DF669444CE79D1262F94F4CC745E037AB781245A560E722514A62BEFF9ABE3B72EAFDF5EAE5A43EA806F5571B05EA04B8E7B02B7" },
+      { "uri": "https://beacon.nist.gov/beacon/2.0/chain/1/pulse/183558", "type": "month", "value": "A9EDA202336C7DB1F05DE3BB24AAC1B54E98C9BD46CDF3D193FAE2BF4E0CD696AD6A743DFDF4DC48E5985BE329652E0A74816C7B69BBAF644FF0ACA352207FFB" },
+      { "uri": "https://beacon.nist.gov/beacon/2.0/chain/1/pulse/1", "type": "year", "value": "7665F054F21B50DF62CD3E50AF8EB783E30D271B091DE051212D301E0E3D17FFCF0367DB41CFFD3C51E88BDE0B0621F49EB03435BC373D5D49480941A8B3547E" }
+    ],
+    "precommitmentValue": "269908B840E79BE71991FFE62CEC4EBAEB3C050E93D71248CBB3E4358445FF0858D1D2CCF899A19B861C0C11CECCF16A0859AFD68E58481D4ADB1BE61F30E419",
+    "statusCode": 0,
+    "signatureValue": "17943D886DA8C7C24B9244BE5BD5DF281983D28CFFC8928846BC26529309C9724F6849F039591361DAF6B8DDAB6BC275CF86F448AF1800996889508D08D8AAAD19586E7A4B04FC4C97F1DA6D619EFAE2332150328C79C23BB9FE6A03E8FABDFF1AD66C5A8789D28AED4D25FF0FC5E88BE366280D7516A504EFD63706641828DDBF3C7082524F36E77EE9E07A9801D0C3BAD0646AA89DDDD8E2B4C0D7F8ED67664864B598E59ABF20CA8D761BB7B32B9A32698A22935D2C7127952625BB5580B2847FBBC8DFEF9039C4F5ACF12877E11121D031AED58217286F8DCF291C6E315773B42FF470B1AB587F787D44381F6E655DB903F1601B65AAC86BC2B7083AEEA9B3A27A5A208674056EFBB3C44629F333C810ADAD00E4FCFCE48E54F8FB7FC700598EA3E6497821736D24E5DA801A8B9DEC28A2B68E50FE13752270EB9CA7912B21EB6C104E78D105D0C0A635686B9A8CA26F87A1E63F0E411FD228F21B08BCD24660B305A4A42A9229154DE364FAB4DFF257A59DEA814034BF65C38A4C7AAEE79FEE5CC69010B1FE9759E23F192E218A19D9B8E95F6DD37D5D2F672E6CF0A0D457D9C619B1808274C2B0B2D3A3A7A8D8B1BB423FDDC56110784F2E0B7A23F065B56EC6E40234786DAB8C840E47811950331CBCFADEAD2EEE901D1C0A3A7E18D18A93089FC4E1CEFBA7571D2E47F10893D24BAD967FCA9DAEA67AD6B7F390AFC0",
+    "outputValue": "0A8863E03E200F694CBA50F0F9A009B078555FE637B07CA2C0A0E4D564080173787B26376C4762377A139D1BCAA916A10419504850EB7CF91552A17FDCAA0463"
+  }
+}
diff --git a/tests/fixtures/packs/invalid/canvas-color.sortilune-pack.json b/tests/fixtures/packs/invalid/canvas-color.sortilune-pack.json
new file mode 100644
index 0000000..1b175d7
--- /dev/null
+++ b/tests/fixtures/packs/invalid/canvas-color.sortilune-pack.json
@@ -0,0 +1,6 @@
+{
+  "schema": "sortilune.pack", "schema_version": 1,
+  "pack_id": "invalid.canvas", "version": "1.0.0", "kind": "canvas-palettes", "name": "Invalid canvas",
+  "author": { "name": "Fixture" }, "attribution": "Fixture", "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "palettes": [{ "id": "bad", "name": "Bad", "colors": ["red", "url(example)", "#000000"] }] }
+}
diff --git a/tests/fixtures/packs/invalid/constraints-duplicate.sortilune-pack.json b/tests/fixtures/packs/invalid/constraints-duplicate.sortilune-pack.json
new file mode 100644
index 0000000..c628e51
--- /dev/null
+++ b/tests/fixtures/packs/invalid/constraints-duplicate.sortilune-pack.json
@@ -0,0 +1,6 @@
+{
+  "schema": "sortilune.pack", "schema_version": 1,
+  "pack_id": "invalid.constraints", "version": "1.0.0", "kind": "constraints", "name": "Invalid constraints",
+  "author": { "name": "Fixture" }, "attribution": "Fixture", "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "items": [{ "id": "same", "text": "One" }, { "id": "same", "text": "Two" }] }
+}
diff --git a/tests/fixtures/packs/invalid/diary-empty.sortilune-pack.json b/tests/fixtures/packs/invalid/diary-empty.sortilune-pack.json
new file mode 100644
index 0000000..b6f3c75
--- /dev/null
+++ b/tests/fixtures/packs/invalid/diary-empty.sortilune-pack.json
@@ -0,0 +1,6 @@
+{
+  "schema": "sortilune.pack", "schema_version": 1,
+  "pack_id": "invalid.diary", "version": "1.0.0", "kind": "diary-prompts", "name": "Invalid diary",
+  "author": { "name": "Fixture" }, "attribution": "Fixture", "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "prompts": [], "words": [] }
+}
diff --git a/tests/fixtures/packs/invalid/duplicate-key.sortilune-pack.json b/tests/fixtures/packs/invalid/duplicate-key.sortilune-pack.json
new file mode 100644
index 0000000..ca980c9
--- /dev/null
+++ b/tests/fixtures/packs/invalid/duplicate-key.sortilune-pack.json
@@ -0,0 +1,13 @@
+{
+  "schema": "sortilune.pack",
+  "schema": "sortilune.pack",
+  "schema_version": 1,
+  "pack_id": "invalid.duplicate-key",
+  "version": "1.0.0",
+  "kind": "constraints",
+  "name": "Duplicate key",
+  "author": { "name": "Fixture" },
+  "attribution": "Fixture",
+  "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "items": [{ "id": "one", "text": "One" }] }
+}
diff --git a/tests/fixtures/packs/invalid/lottery-range.sortilune-pack.json b/tests/fixtures/packs/invalid/lottery-range.sortilune-pack.json
new file mode 100644
index 0000000..3e3752a
--- /dev/null
+++ b/tests/fixtures/packs/invalid/lottery-range.sortilune-pack.json
@@ -0,0 +1,6 @@
+{
+  "schema": "sortilune.pack", "schema_version": 1,
+  "pack_id": "invalid.lottery", "version": "1.0.0", "kind": "lottery-presets", "name": "Invalid lottery",
+  "author": { "name": "Fixture" }, "attribution": "Fixture", "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "presets": [{ "id": "backward", "name": "Backward", "tool": "number", "minimum": 10, "maximum": 1, "integer": true }] }
+}
diff --git a/tests/fixtures/packs/invalid/malformed-utf8.hex b/tests/fixtures/packs/invalid/malformed-utf8.hex
new file mode 100644
index 0000000..0d8ff82
--- /dev/null
+++ b/tests/fixtures/packs/invalid/malformed-utf8.hex
@@ -0,0 +1 @@
+7b22736368656d61223a22ff227d
diff --git a/tests/fixtures/packs/invalid/oracle-active-field.sortilune-pack.json b/tests/fixtures/packs/invalid/oracle-active-field.sortilune-pack.json
new file mode 100644
index 0000000..091d2cc
--- /dev/null
+++ b/tests/fixtures/packs/invalid/oracle-active-field.sortilune-pack.json
@@ -0,0 +1,6 @@
+{
+  "schema": "sortilune.pack", "schema_version": 1,
+  "pack_id": "invalid.oracle", "version": "1.0.0", "kind": "oracle-deck", "name": "Invalid oracle",
+  "author": { "name": "Fixture" }, "attribution": "Fixture", "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "cards": [{ "id": "one", "name": "One", "meaning": "Text", "html": "" }] }
+}
diff --git a/tests/fixtures/packs/invalid/path-shaped-id.sortilune-pack.json b/tests/fixtures/packs/invalid/path-shaped-id.sortilune-pack.json
new file mode 100644
index 0000000..3541791
--- /dev/null
+++ b/tests/fixtures/packs/invalid/path-shaped-id.sortilune-pack.json
@@ -0,0 +1,6 @@
+{
+  "schema": "sortilune.pack", "schema_version": 1,
+  "pack_id": "../../outside", "version": "1.0.0", "kind": "constraints", "name": "Path-shaped ID",
+  "author": { "name": "Fixture" }, "attribution": "Fixture", "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "items": [{ "id": "one", "text": "One" }] }
+}
diff --git a/tests/fixtures/packs/invalid/redos-shaped-text.sortilune-pack.json b/tests/fixtures/packs/invalid/redos-shaped-text.sortilune-pack.json
new file mode 100644
index 0000000..e04f525
--- /dev/null
+++ b/tests/fixtures/packs/invalid/redos-shaped-text.sortilune-pack.json
@@ -0,0 +1,6 @@
+{
+  "schema": "sortilune.pack", "schema_version": 1,
+  "pack_id": "invalid.redos", "version": "1.0.0", "kind": "constraints", "name": "Regex-shaped text",
+  "author": { "name": "Fixture" }, "attribution": "Fixture", "license": { "type": "spdx", "expression": "MIT" },
+  "content": { "items": [{ "id": "one", "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" }] }
+}
diff --git a/tests/fixtures/schemas/invalid/archive-record.json b/tests/fixtures/schemas/invalid/archive-record.json
new file mode 100644
index 0000000..3de831f
--- /dev/null
+++ b/tests/fixtures/schemas/invalid/archive-record.json
@@ -0,0 +1,14 @@
+{
+  "schema": "sortilune.archive-record",
+  "schema_version": 1,
+  "id": "not-a-stable-id",
+  "chamber": "canvas",
+  "type": "canvas-work",
+  "created_at": "not-a-timestamp",
+  "summary": "Invalid fixture",
+  "payload": {},
+  "provenance": [],
+  "relations": [],
+  "assets": [],
+  "unexpected": true
+}
diff --git a/tests/fixtures/schemas/invalid/settings-v2.json b/tests/fixtures/schemas/invalid/settings-v2.json
new file mode 100644
index 0000000..aa356f8
--- /dev/null
+++ b/tests/fixtures/schemas/invalid/settings-v2.json
@@ -0,0 +1,20 @@
+{
+  "schema": "sortilune.settings",
+  "schema_version": 2,
+  "route": {
+    "destination": "../oracle",
+    "params": {}
+  },
+  "theme": "neon",
+  "entropy": {
+    "preferred_source": "preferred",
+    "enabled_sources": {}
+  },
+  "visual": {
+    "starfield": "yes",
+    "reduce_motion": "sometimes"
+  },
+  "chambers": {
+    "last_used_deck": "../escape"
+  }
+}
diff --git a/tests/fixtures/schemas/valid/archive-record.json b/tests/fixtures/schemas/valid/archive-record.json
new file mode 100644
index 0000000..5f079b4
--- /dev/null
+++ b/tests/fixtures/schemas/valid/archive-record.json
@@ -0,0 +1,54 @@
+{
+  "schema": "sortilune.archive-record",
+  "schema_version": 1,
+  "id": "550e8400-e29b-41d4-a716-446655440000",
+  "chamber": "canvas",
+  "type": "canvas-work",
+  "created_at": "2026-07-11T04:45:00.000Z",
+  "summary": "A deterministic constellation fixture",
+  "payload": {
+    "generator": "constellation",
+    "seed": "fixture"
+  },
+  "provenance": [
+    {
+      "source": {
+        "id": "system",
+        "label": "Local system randomness",
+        "kind": "system"
+      },
+      "fetched_at": "2026-07-11T04:45:00.000Z",
+      "raw": "00112233",
+      "signature": null,
+      "details": {
+        "generator": "crypto.getRandomValues"
+      }
+    }
+  ],
+  "relations": [
+    {
+      "kind": "derived-from",
+      "target_id": "550e8400-e29b-41d4-a716-446655440001"
+    }
+  ],
+  "assets": [
+    {
+      "id": "550e8400-e29b-41d4-a716-446655440002",
+      "role": "primary-artwork",
+      "media_type": "image/svg+xml",
+      "path": "archive/canvas/fixture.svg",
+      "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
+      "bytes": 512,
+      "width": 1600,
+      "height": 1000
+    }
+  ],
+  "algorithm": {
+    "id": "canvas.constellation",
+    "version": 1,
+    "parameters": {
+      "width": 1600,
+      "height": 1000
+    }
+  }
+}
diff --git a/tests/fixtures/schemas/valid/settings-v2.json b/tests/fixtures/schemas/valid/settings-v2.json
new file mode 100644
index 0000000..a520554
--- /dev/null
+++ b/tests/fixtures/schemas/valid/settings-v2.json
@@ -0,0 +1,30 @@
+{
+  "schema": "sortilune.settings",
+  "schema_version": 2,
+  "route": {
+    "destination": "oracle",
+    "params": {}
+  },
+  "navigation": {
+    "rail_mode": "auto"
+  },
+  "theme": "cosmic-dark",
+  "entropy": {
+    "preferred_source": "preferred",
+    "enabled_sources": {
+      "nist-beacon": true,
+      "system": true
+    }
+  },
+  "visual": {
+    "starfield": true,
+    "reduce_motion": "system"
+  },
+  "archive": {
+    "search_diary_body": false,
+    "on_this_day": true
+  },
+  "chambers": {
+    "last_used_deck": "cosmic"
+  }
+}
diff --git a/tests/fixtures/today/golden-v1.json b/tests/fixtures/today/golden-v1.json
new file mode 100644
index 0000000..a057f8a
--- /dev/null
+++ b/tests/fixtures/today/golden-v1.json
@@ -0,0 +1,42 @@
+[
+  {
+    "name": "spring-new-york",
+    "local_date": "2024-03-10",
+    "time_zone": "America/New_York",
+    "seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f",
+    "generated_at": "2024-03-10T05:00:00.000Z",
+    "expected_id": "55d6a9cd-27f0-51bb-8400-5aba371d4431",
+    "expected_streams_sha256": "82f1e0c987eb1577b5ad962104fba35b57b2a416422f2d7da8523a2e4c60c1ef",
+    "expected_outputs_sha256": "92b3862fd185d069625dffa6a2093b5fbf542b79a8048d2b61dd5bddadec5a52"
+  },
+  {
+    "name": "fall-new-york",
+    "local_date": "2024-11-03",
+    "time_zone": "America/New_York",
+    "seed_hex": "a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5",
+    "generated_at": "2024-11-03T04:00:00.000Z",
+    "expected_id": "d70d2f5c-06d4-5cb2-8ae4-a96999f4afe3",
+    "expected_streams_sha256": "78d70ce9eecb01704a11f4a8eeb752c2c0a6a1d61b57327715f1bf6fea239634",
+    "expected_outputs_sha256": "2de65259706478c4f21b5882d4148feffeebcc4dbb20c6524ddb57adbe7b9b18"
+  },
+  {
+    "name": "fall-berlin",
+    "local_date": "2024-10-27",
+    "time_zone": "Europe/Berlin",
+    "seed_hex": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff00112233445566778899aabbccddeef00112233445566778899aabbccddeeff102132",
+    "generated_at": "2024-10-26T22:00:00.000Z",
+    "expected_id": "c3fa532a-fb4e-5c46-86aa-34ed9a592dca",
+    "expected_streams_sha256": "46b486e04d1e0feccebf4770a7fde0bf708265be617c92be456dea2a1ea18125",
+    "expected_outputs_sha256": "a7ebb0ffffe7324ee825d3bd12a1b3c1f29c3482fc12a99236827e485eceec26"
+  },
+  {
+    "name": "plain-kolkata",
+    "local_date": "2026-07-13",
+    "time_zone": "Asia/Kolkata",
+    "seed_hex": "fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1d0cfcecdcccbcac9c8c7c6c5c4c3c2c1c0",
+    "generated_at": "2026-07-12T18:30:00.000Z",
+    "expected_id": "54ae654d-a5e5-5c84-8dc1-e16cc5766db8",
+    "expected_streams_sha256": "50dff2fb703c45d0970ec1095f5ed951db53f28c925a16d0dabef884b9975fe6",
+    "expected_outputs_sha256": "f509c410d0eac029528d46967270bc9927989e4964f6a200711cdcf465c8504b"
+  }
+]
diff --git a/tests/http.test.js b/tests/http.test.js
new file mode 100644
index 0000000..5a385f6
--- /dev/null
+++ b/tests/http.test.js
@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+
+test('HTTP wrapper aborts the underlying operation when its timeout expires', async () => {
+  globalThis.window = {
+    fetch: (_url, options) => new Promise((_resolve, reject) => {
+      options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true });
+    }),
+  };
+  const { fetchText } = await import(`../src/lib/http.js?timeout=${Date.now()}`);
+  await assert.rejects(fetchText('https://example.invalid', { timeoutMs: 10 }), /timed out/);
+});
+
+test('HTTP wrapper times out even when the transport ignores AbortSignal', async () => {
+  globalThis.window = {
+    fetch: () => new Promise(() => {}),
+  };
+  const { fetchText } = await import(`../src/lib/http.js?noncooperative=${Date.now()}`);
+  await assert.rejects(fetchText('https://example.invalid', { timeoutMs: 10 }), /timed out/);
+});
+
+test('HTTP wrapper rejects invalid timeout configuration before fetching', async () => {
+  let called = false;
+  globalThis.window = { fetch: async () => { called = true; return new Response('ok'); } };
+  const { fetchText, withTimeout } = await import(`../src/lib/http.js?bounds=${Date.now()}`);
+  await assert.rejects(fetchText('https://example.invalid', { timeoutMs: Infinity }), /timeoutMs/);
+  await assert.rejects(withTimeout(Promise.resolve('ok'), 0), /timeout/);
+  assert.equal(called, false);
+});
diff --git a/tests/journal.test.js b/tests/journal.test.js
new file mode 100644
index 0000000..47c0f1e
--- /dev/null
+++ b/tests/journal.test.js
@@ -0,0 +1,105 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import { createJournalDocument } from '../src/domain/journal.ts';
+import { journalFilename, renderJournalHtml } from '../src/journal/html.ts';
+
+const options = {
+  title: 'Moon & Memory ',
+  cover: true,
+  theme: 'paper',
+  order: 'oldest',
+  provenance: 'summary',
+  include_private_writing: false,
+};
+
+test('journal adapters exclude private writing by default and keep useful Today details', () => {
+  const document = createJournalDocument({
+    records: [diaryRecord(), todayRecord()],
+    options,
+    createdAt: '2026-07-13T12:00:00.000Z',
+  });
+  assert.deepEqual(document.manifest.selected_record_ids, [diaryRecord().id, todayRecord().id]);
+  assert.equal(document.sections[0].paragraphs.includes('private words '), false);
+  assert.equal(document.sections[0].paragraphs.includes('Private diary writing excluded.'), true);
+  assert.equal(document.sections[1].facts.some((fact) => fact.label === 'Oracle' && fact.value === 'The Moon'), true);
+  assert.equal(document.sections[1].paragraphs.includes('Use only two colors.'), true);
+  assert.deepEqual(document.sections[0].provenance, ['Local fixture · 2026-07-12T00:00:00.000Z']);
+});
+
+test('private writing is included only when explicitly selected', () => {
+  const document = createJournalDocument({
+    records: [diaryRecord()],
+    options: { ...options, include_private_writing: true },
+    createdAt: '2026-07-13T12:00:00.000Z',
+  });
+  assert.equal(document.sections[0].paragraphs.includes('private words '), true);
+});
+
+test('self-contained HTML escapes content, embeds print CSS and a manifest, and is stable', () => {
+  const document = createJournalDocument({
+    records: [diaryRecord(), todayRecord()],
+    options: { ...options, include_private_writing: true },
+    createdAt: '2026-07-13T12:00:00.000Z',
+  });
+  const html = renderJournalHtml(document);
+  assert.match(html, /^/u);
+  assert.match(html, //u);
+  assert.match(html, /@media print/u);
+  assert.match(html, /
/u); + assert.match(html, /id="sortilune-export-manifest"/u); + assert.match(html, /private words <script>alert\(1\)<\/script>/u); + assert.doesNotMatch(html, /