aex/ARCHITECTURE.md
yaroslav k 42b106c793 roadmap: add Phase 1.5 — undo & redo (snapshot-based)
- effort S (~0.5-1 day): rides on Phase 1 machinery (serde snapshots,
  replace_state, the store.track() autosave trigger) — no component
  changes needed, page-agnostic
- coalesced snapshots (one per ~1 s window), capped stacks, Ctrl+Z /
  Ctrl+Shift+Z with focus guard for text fields
- sequencing note: lands before the edit-heavy Phases 2-7 so their edits
  are undoable from day one; nothing depends on it
- Phase 8 e2e item: drop the persistence round trip (already covered by
  persistence.spec.ts), add undo shortcuts
- ARCHITECTURE gaps list: note the missing undo/redo
2026-08-16 11:57:31 +03:00

7.6 KiB
Raw Blame History

ARCHITECTURE

This document describes how aex works today. It is a snapshot, not a design spec: several pieces are stubs or unwired (see "Current gaps" at the end and the phased plan in ROADMAP.md).

What it is

A D&D 5e character sheet and campaign dashboard for one party's play-by-text game. The UI is in Russian; code and comments are in English. The app tracks the campaign (name, in-game date, session number), one player character (identity, class, HP, money, spell slots, inventory, hit dice, death saves), a party-shared stash, and — in the data model only, no UI yet — quests, notes, and contacts.

Stack

  • Rust, edition 2024, nightly toolchain (rust-toolchain.toml, channel pinned to nightly without a date)
  • Leptos 0.8.20 (SSR + hydration), reactive_stores 0.4.3 for state
  • leptos_actix 0.8.7 + actix-web 4 for the server
  • nucleo-matcher for fuzzy item search, num-format for number display, chrono for dates, std::sync::LazyLock for one-time data loading
  • SCSS sources in style/ (compiled by cargo-leptos)
  • Bundled game data in data/ (5e.tools-format JSON, ~3.9 MB total)

Features: ssr (server binary), hydrate (WASM), csr (optional pure-client mode for trunk). Cargo.lock is tracked for reproducible builds.

Directory layout

src/
  main.rs            SSR entry point: actix-web server, favicon
  lib.rs             crate root, module declarations, WASM hydrate()
  app.rs             App component: context, shell, routes
  prelude.rs         crate-wide re-exports and the Context alias
  state/             App state (one Store<Dashboard> in context)
  entities/          Domain model (Store-derived structs) + mock data
  statics/           Bundled item data: ITEMS_REFS, ITEMS_NAMES
  utils/             read_items, adjust_checked, BasisPoints trait
  components/
    header.rs        Campaign header (name, date, session, Save/Load/Clear)
    nav.rs           Route links
    sidebar.rs       Character sidebar, composes the seven panels below
    sidebar/*.rs     image, names, about, hit_points, hit_dice,
                     death_saving_throws, spell_slots
    inventory.rs     One inventory section (rows + money)
    item.rs          One inventory row
    numput.rs        Numeric input components (Numput, NumputChange)
    mod.rs           Pages (Character, Inventories, Wiki) + item search

State model

The whole dashboard is one reactive tree:

  • entities::Dashboard is the root struct: campaign fields, player: PlayerData, common: CommonData, plus quest/note/contact books and settings.
  • Every entity struct derives reactive_stores::Store, so every nested field is independently reactive (copy-on-write paths).
  • App creates Store::new(Dashboard::mock()) and provides it as Leptos context. Components read it with expect_context::<Context>() and grab field handles, e.g. let hp = state.player().hp();.
  • Writes go through field.update(|v| ...), field.set(...), or field.write(). All arithmetic is saturating (saturating_add/sub, checked_add_signed) so values never underflow.

The state is mock data only — Dashboard::mock() runs on every load and nothing is persisted. The header's Save/Load/Clear buttons do nothing yet (ROADMAP phase 1).

Data layer (items)

  • statics::ITEMS_REFS is a LazyLock map of name → BookItem, built once by utils::read_items over the bundled JSON files (items.json, base.json, foundry.json, magic.json), roughly 5k items.
  • BookItem keeps name, weight, rarity, and rest, which holds all other JSON fields verbatim for future detail views.
  • Duplicate names merge: the later entry fills a missing weight and appends its rest fields.
  • ITEMS_NAMES is the flat name list fed to the fuzzy matcher.

Weights are stored as u32 basis points (100 = 1.0) in inventory items and formatted with the utils::BasisPoints trait; book items use f64 from the JSON as-is.

Rendering pipeline

  1. main.rs (feature ssr) starts an actix-web server, generates the route list from App, and renders each route server-side through leptos_routes. It serves the WASM/CSS from /pkg and static assets from /assets.
  2. The WASM bundle (hydrate feature) calls hydrate() in lib.rs, which mounts App with hydrate_body over the server-rendered DOM.
  3. Routing: / → Character page, /inv → Inventories, /wiki → Wiki, anything else → NotFound (sets HTTP 404 during SSR).

Component tree

App ├─ Header name, image, date steppers, session steppers, │ Save (download)/Load (file import)/Clear (wipe) ├─ Modal overlay dialog: confirm (action) or error (close) ├─ Sidebar │ ├─ Image portrait │ ├─ Names first / alias «» / last │ ├─ About class, level ±, XP │ ├─ HitPoints current/max with bar, temp HP │ ├─ HitDice die-kind select, per-level clickable dice │ ├─ DeathSavingThrows fail/success pips + reset │ └─ SpellSlots 9 levels of clickable pips ├─ Nav links to the three routes └─ Routes ├─ Character stub text ├─ Inventories two Inventory sections (Personal/Common) │ └─ Inventory balance (5 × Numput), rows (ForEnumerate) │ └─ Item name, quantity ±, weight ±, more/swap/remove └─ Wiki stub text


The inventories page reads `Context`, splits it into `Field<Inventory>` and
`Field<Balance>` handles, and passes them down. Item rows get move/remove
closures by index; "move" transfers a row between the personal and common
inventories, "remove" deletes it. The "…" (details) button and the search
"+" button are unwired.

Persistence lives in `state::persist`: on hydration a deferred timer loads
the saved dashboard from localStorage (SSR markup stays authoritative),
then a debounced (500 ms) effect writes on any store change via
`store.track()`. `parse_dashboard` version-checks the JSON against
`Settings::VERSION` (the migration hook). `ModalState` holds title/message/
action as signals; the confirm button reads the action at click time.

`Numput` is the shared numeric input: `+n`/`-n` adjust relative to the
current value, a plain number sets absolutely, invalid input is reverted,
and the value renders with thousands separators. `NumputChange` is the same
pattern with caller-supplied display and parse functions (used for weights
in basis points).

Item search (`ItemSearchField`) re-runs a nucleo fuzzy match on every
keystroke over `ITEMS_NAMES` and renders `ItemSearchCard`s. It is not
currently mounted on any page.

## Conventions

- UI strings are Russian and hardcoded in the views; ids like `id="hp"`
  exist for e2e tests.
- Collections come from `std::collections` via the prelude (`HashMap`,
  `HashSet`).
- `For` lists are keyed: inventories use `#[store(key)]` on `row.id`,
  sidebar pips key by index.
- Logging goes through the `log` macros re-exported in the prelude
  (`info!`, `warn!`, ...).

## Current gaps

- Quests, notes, contacts: models and mock data only
- No undo/redo (planned as Phase 1.5; snapshot-based, rides on the
  persist machinery)
- `Character` and `Wiki` pages render placeholders
- Prepared spells list is empty; `Spell` is a name-only stub; attunements
  have no UI; `Settings` has only `version`
- The item "+"/"…" buttons are dead
- `foundry.json` was loaded twice (fixed); ~3.9 MB of JSON is bundled
  into the WASM and parsed on startup
- Several unused dependencies (see `MODERNIZE.md`); kept for now by
  decision
- `Cargo.lock` is tracked since 2026-08-04; builds are reproducible

See [ROADMAP.md](./ROADMAP.md) for the ordered plan to fix these.