let claude create the monorepo layout

This commit is contained in:
2026-03-09 16:45:29 +01:00
commit 40cb03cb0b
71 changed files with 5346 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -0,0 +1,25 @@
# Node
node_modules/
dist/
.svelte-kit/
.env
.env.*
!.env.example
*.pem
# pnpm
pnpm-debug.log*
# Rust
target/
Cargo.lock
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/settings.json
.idea/
*.swp
*.swo

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
approve-builds=true

247
CLAUDE.md Normal file
View File

@@ -0,0 +1,247 @@
# Campaign Manager — CLAUDE.md
This project is in the **architecture/design phase**. No implementation code exists yet. The source of truth is `campaign_manager_design_v7.md`. `Symbiotes Documentation.md` is the TaleSpire platform API reference.
---
## What We're Building
A TaleSpire **Campaign Manager Symbiote** — a mod embedded inside TaleSpire (Chromium-based) that provides authenticated multi-group campaign management with ruleset-agnostic character sheets and native dice integration.
Three clients, one backend:
1. **TaleSpire Symbiote** — primary interface, runs inside the game
2. **Companion Web App** — for out-of-game character/group management
3. **Backend API** — shared by both clients
---
## Technology Stack
| Layer | Technology | Notes |
|---|---|---|
| Symbiote UI | Svelte + Vite (no Kit) | Plain Svelte for minimal bundle size; TaleSpire runs locally and the Symbiote shares resources with the game. Programmatic view-switching via a `$currentView` store — no router. |
| Web App | SvelteKit | Full SvelteKit with `adapter-static` for CDN deploy; SvelteKit's router and SSR are appropriate here since it runs in the user's own browser. Shares ruleset plugin code with the Symbiote. |
| Backend | Rust + Axum + Tokio | Async, memory-safe, high-throughput |
| DB queries | sqlx | Compile-time checked SQL, no ORM |
| Database | PostgreSQL 16 | JSONB for sheet_data; strong relational integrity |
| Auth | JWT + argon2id | argon2id preferred over bcrypt for new code |
| Object Storage | S3-compatible (Cloudflare R2) | Portrait images and .cmchar exports; keeps k8s pods stateless |
| Hosting | Kubernetes (no PVC) | Stateless pods; all persistence in Postgres + S3 |
---
## Architecture
### Three-Layer System
```
TaleSpire Symbiote (Chromium) ←→ Backend (Rust/Axum) ←→ PostgreSQL
Companion Web App ←→ (same API)
```
- Symbiote communicates over HTTPS; upgrades to WebSocket for live session features
- Web app and Symbiote share the same `/auth/*` endpoints and user accounts — no separate auth
### Symbiote Constraints (from TaleSpire API)
- Single-page application — no real page navigation; views are swapped programmatically to preserve the injected TS API
- **No SvelteKit for the Symbiote** — the Symbiote runs inside TaleSpire's local Chromium process and shares system resources with the game. SvelteKit's router/hydration bundle is unnecessary overhead. Use plain Svelte + Vite: one `main.ts` entry point, a `$currentView` store drives which component is rendered.
- All persistence via `TS.storage` (not `localStorage`, which is unreliable across restarts)
- Must suppress API calls during board transitions (client leave/join events)
- `manifest.json` extras: `colorStyles`, `fonts`, `diceFinder`
### Views (Symbiote)
`Login/Register``Group List``Group Detail``Character Sheet``Roll History panel`
### Web App Routes
| Route | Access |
|---|---|
| `/groups` | All members |
| `/groups/:id` | Members |
| `/groups/:id/settings` | DM only |
| `/characters` | Owner |
| `/characters/:id` | Owner |
---
## Data Model
### Key Schema Decisions
- `sheet_data` is **opaque JSONB** — the backend never reads it; only ruleset plugins do. Adding a new ruleset requires **zero DB migrations**.
- Characters are **portable**: one character can belong to multiple groups via `group_characters`.
- Exactly **one DM per group** (`groups.dm_user_id` + `group_members.role`).
- `email_verified = true` is required before joining or creating groups.
### Core Tables
```
users id (UUID PK), email, password_hash, display_name, email_verified, created_at
groups id (UUID PK), name, dm_user_id (FK), ruleset_id, description, created_at
group_members group_id + user_id (composite PK), role ENUM(player, dm), joined_at
characters id (UUID PK), owner_user_id (FK), ruleset_id, name, sheet_data (JSONB),
portrait_url, created_at, updated_at
group_characters group_id + character_id (composite PK), user_id (FK), is_active, assigned_at
```
---
## REST API
Base URL: `https://api.campaign-manager.example.com/v1`
All endpoints require `Authorization: Bearer <accessToken>` unless marked public.
### Auth
| Method + Path | Auth | Description |
|---|---|---|
| `POST /auth/register` | Public | Create account |
| `POST /auth/login` | Public | Returns JWT pair |
| `POST /auth/refresh` | Refresh token | Rotate refresh token |
| `POST /auth/logout` | Bearer | Revoke refresh token |
### Groups
`GET /groups` · `POST /groups` · `GET /groups/:id` · `PATCH /groups/:id` · `DELETE /groups/:id`
`POST /groups/:id/invite` · `POST /groups/:id/join/:token` · `DELETE /groups/:id/members/:userId`
### Characters
`GET /characters` · `POST /characters` · `GET /characters/:id`
`PUT /characters/:id` · `PATCH /characters/:id` · `DELETE /characters/:id`
`POST /groups/:id/characters` · `DELETE /groups/:gid/characters/:cid` · `GET /groups/:id/characters`
`GET /characters/:id/history` (last 10 versions of sheet_data)
### Dice / Sessions
`POST /groups/:id/rolls` · `GET /groups/:id/rolls`
### WebSocket
One room per group ID, authenticated with the same JWT. Used for HP updates and roll history push.
---
## Ruleset Plugin Interface
Game logic is fully isolated behind a `Ruleset` interface. The core app never touches system-specific rules.
```typescript
interface Ruleset {
id: string // e.g. 'dsa5e'
name: string
version: string // semver
defaultSheet(): SheetData
validate(sheet: SheetData): ValidationResult
getStatBlocks(sheet: SheetData): StatBlock[]
getDiceActions(sheet: SheetData): DiceAction[]
renderSheet(sheet: SheetData): VNode
}
interface DiceAction {
label: string // 'Attack Roll', 'Perception Check', etc.
diceString: string // TaleSpire format: '1d20+5', '2d6'
category: string // 'attack' | 'skill' | 'save' | 'damage' | 'custom'
}
```
Plugins live in a `rulesets/` directory. Adding one = dropping in a file + registering its id. No DB changes.
**Bundled rulesets (v1):**
- `dsa5e` — Das Schwarze Auge 5e (The Dark Eye): attributes, derived values, talents, combat techniques, special abilities, spells/liturgies
- `generic` — Minimal fallback: name, HP, arbitrary key-value pairs
---
## Key Conventions
### Auth Flow
- **Symbiote**: tokens stored in `TS.storage`. On boot, check stored tokens; silently refresh via `POST /auth/refresh` if access token is expired. On any 401, attempt silent refresh before surfacing an error.
- **Web App**: access token in memory only; refresh token in `HttpOnly; Secure; SameSite=Strict` cookie. On hard refresh, silently call `POST /auth/refresh` via cookie before rendering protected routes.
- JWT access tokens: 15-minute lifetime. Refresh tokens: 30-day lifetime, **single-use with rotation**.
- Rate limit on auth endpoints: 10 req/min per IP.
### Sync Strategy
- **`TS.sync`** — lightweight broadcast to all clients on the same board running the same Symbiote. Use for transient state (e.g. initiative announcements). No backend needed.
- **WebSocket room (group-keyed)** — for persistent data (HP changes, saved rolls). Backend pushes updates to other connected clients.
- **Conflict resolution**: last-write-wins for most fields. HP uses **delta updates** (`+5 HP` not `set HP to 45`) to reduce conflicts during combat.
### Character Sheet Saves (Web App)
- Optimistic UI: changes applied locally immediately; rollback with toast on failure.
- Auto-save draft to backend every 30 seconds.
### Dice Integration
```javascript
// Programmatic roll from character sheet button
await TS.dice.putDiceInHand(action.diceString);
// Listen for resolved results and log to backend
TS.dice.onDiceResult.addListener(async (result) => {
await api.post(`/groups/${currentGroup.id}/rolls`, { ... });
});
```
`diceFinder` extra automatically makes dice-notation text clickable on any page loaded in the Symbiote — no extra code.
### Character Import (.cmchar format)
```json
{
"cm_version": "1.0",
"ruleset_id": "dsa5e",
"name": "Character Name",
"sheet_data": { }
}
```
Importing runs `ruleset.validate(sheet_data)` before creating any record. Field-level errors are surfaced in the UI.
### Portrait Upload
Client validates MIME type and size (max 2 MB) before upload. Backend issues a presigned S3 URL; client uploads directly to object storage. Backend stores only the resulting URL. k8s pods remain stateless.
### Styling
Use TaleSpire CSS variables (`--ts-background-primary`, `--ts-color-primary`, etc.) via the `colorStyles` extra. Use `OptimusPrinceps` font (via `fonts` extra) for headings. Use `ts-icon-*` classes for action buttons.
---
## Project Structure
```
campaign-manager/
symbiote/ # Plain Svelte + Vite — TaleSpire Symbiote (lean bundle)
vite.config.ts
src/
main.ts # mounts <App />, waits for TS.hasInitialized
App.svelte # $currentView store drives view switching
views/ # Login, GroupList, GroupDetail, CharacterSheet, RollHistory
lib/
api.ts # shared fetch client (auto-refresh on 401)
store.ts # auth + session state
dist/ # Symbiote file root (index.html + manifest.json go here)
web/ # SvelteKit — Companion web app
svelte.config.js # adapter-static for CDN deploy
src/
routes/
lib/
backend/ # Rust + Axum
rulesets/ # Shared ruleset plugins (plain TS — imported by both frontends)
```
---
## Out of Scope (v1)
- Board state editing (tiles, minis) — TaleSpire API doesn't expose this
- Real-time collaborative editing of the same sheet simultaneously
- Mobile app
- PDF / D&D Beyond imports

3
Cargo.toml Normal file
View File

@@ -0,0 +1,3 @@
[workspace]
members = ["backend"]
resolver = "2"

287
Symbiotes Documentation.md Normal file
View File

@@ -0,0 +1,287 @@
# Symbiotes Documentation
1. [What are Symbiotes?](#symbiotes-intro)
2. [Getting Started](#getting-started)
- [Installing a Symbiote](#installing)
- [Development Workflow](#dev-workflow)
3. [Manifest Docs](#manifest-docs)
- [Manifest Parser](#manifest-parser)
4. [API Docs](#api-docs)
- [API reverse domain name](#reverse-domain-name)
- [Rate limiting](#rate-limiting)
- [Permissions](#permissions)
- [Injection](#injection)
- [Symbiote lifetime](#symbiote-lifetime)
5. [Extras](#extras-docs)
- [TS Theme](#ts-theme)
- [TS Fonts](#ts-fonts)
- [TS Icons](#ts-icons)
- [Dice Finder](#dice-finder)
6. [FAQ](#faq)
7. [Upgrade Guides](#upgrade)
## What are Symbiotes?
Symbiotes are our first supported way of modding, allowing you, the community, to make character sheets, fancy dice rollers, hand-out notes, and so much more!
Thanks to the [API](#api-docs), these Symbiotes can load data directly from the game enabling tight integrations. Additionally, you can load regular websites if you prefer, to just use your favorite online resource without having to Alt+Tab out all the time.
## Getting Started
If you are not a developer or just want a simple introduction into what Symbiotes are and how to use them, visit [this page](https://symbiote-docs.talespire.com/user_docs.html) or watch this [video guide](https://www.youtube.com/watch?v=znOcQKJFFpU). If you are a developer, read on.
Some parts of the documentation contain operating system specific information like file paths. Select the operating system you plan on developing on from the following selector to show the relevant documentation.
To understand how web view based Symbiotes work and create your own, check out the examples provided on our [GitHub repository](https://github.com/Bouncyrock/symbiotes-examples). The very first step is to activate the feature in the settings: ![](../../_resources/enable_symbiotes_0bf2f1600a7f442da4cbdcaeb3b6ac2c.png)
If you just want to use other people's Symbiotes you're now done and can [install](#installing) the ones you want to use. If you want to make your own Symbiotes, you need to create a [manifest](#manifest-docs) that describes the Symbiote you want to make. For some Symbiotes this may be sufficient, but for most, you will also need to create an HTML file to be loaded.
Additionally, we provide templated creation within TaleSpire to set up a mostly blank Symbiote for you to work with. To use it, enable the development mode and then press "Create new Symbiote" in the bottom of the Symbiotes panel:
![](../../_resources/dev_mode_6cdd1a2477554097a2a13443748cb431.png) <img width="395" height="821" src="../../_resources/create_new_ffa6a1d1217e4614b66b18df5597c975.png"/>
If you need additional help after reading through the documentation, check out the `#talespire-modding` channel on our [Discord](https://discord.gg/talespire).
### Installing a Symbiote
To install a Symbiote, simply open the library and open the "Community Mods" tab.
![](../../_resources/mod_browser_ed550074b1fb484f9dd7f75869cc6053.png)
In there, open the "Symbiotes" section. This shows all Symbiotes available on the [mod.io](https://mod.io/g/talespire) repository, together with a search box. There, search for the Symbiote you want to install and then simply click on it to download and install. All installed mods will be shown in the "Installed" section where they can be removed and updated.
Whenever you want to release your Symbiote to the public you can use the [mod.io](https://mod.io/g/talespire) repository. All mods uploaded ([video guide](https://www.youtube.com/watch?v=UDLcQw9DIhA)) there will be searchable and downloadable through the in-game mod browser. In the future we want to support third party community repositories in addition to mod.io for the in-game downloader.
Manual Install
Symbiotes that aren't uploaded to mod.io can be installed manually. To do this, simply open the Symbiote directory located at `%AppData%\..\LocalLow\BouncyRock Entertainment\TaleSpire\Symbiotes\`. Instead of navigating there manually you can click the "Open Symbiotes Directory" button in either the modding page in the game settings or the Symbiote panel itself. Simply add a folder in there for each Symbiote and drop the Symbiote files into that directory directly. Make sure that the manifest.json is in the root of your Symbiote's folder like so: `[...]\Symbiotes\my_cool_symbiote\manifest.json`. All folder and Symbiote creation/editing is detected live by TaleSpire and will cause an automatic reload on changes meaning TaleSpire does not need to be restarted for any of this.
#### Mod Downloader vs Manual Install
When downloading with the in-game mod downloader, Symbiotes are installed to TaleSpire's app cache: `%AppData%\..\LocalLow\BouncyRock Entertainment\TaleSpire\primary\Mods\Symbiotes\`, which is different to the manual install location described above. This means there are two entirely separate locations where Symbiotes can be installed to, one handled by TaleSpire itself and one manually by users - this can of course result in the same Symbiote being installed once manually and once through the mod downloader. For this case keep in mind that the log and storage locations are separate for both installs and that this will cause conflicts in case both use the same interop ID!
### Development Workflow
While you can develop the basic structure of your Symbiote in any external browser, features that rely on data or interactions with TaleSpire cannot be tested outside of TaleSpire. When loading a Symbiote in TaleSpire you can connect to it with an external browser by loading [localhost:8080](localhost:8080). For this to work you must first enable Symbiotes debugging in the TaleSpire settings:
![](../../_resources/dev_mode_6cdd1a2477554097a2a13443748cb431.png)
The embedded browser is Chromium-based, so you can target that as your platform if you want to just support your project running as a Symbiote in TaleSpire.
Any changes you make to files within the Symbiote directory (excluding files/folders starting with a period: '.') will cause the Symbiote to automatically reload. This means you can have the code open in your favorite IDE and and have the Symbiote reload with each saved change.
#### Creating a Symbiote
To create a new Symbiote either use the [templated creation](#getting-started) or create a [manifest.json](#manifest-docs) yourself and add it to the [Symbiotes directory](#manual-install).
## Manifest Docs
The list links to the various manifest versions. Note that while deprecated versions are still supported for now, we are phasing them out and you should [update your existing Symbiotes](#upgrade) to a newer version and not create new ones with those versions.
- [v1](https://symbiote-docs.talespire.com/manifest_doc_v1.html)
Additional Info
### Manifest Parser
When a manifest (= any file called `manifest.json`) gets parsed it can either succeed, which adds the Symbiote to the list of all others, or it can fail due to various reasons. If parsing the manifest fails, the Symbiote will still be shown in the list, but opening it doesn't load the Symbiote, instead it loads an error page. Parsing can fail for a number of obvious reasons: Incorrect manifest version, required fields being missing, the JSON being corrupted and not parseable, etc.
Additionally to all the ways a manifest could contain invalid data, parsing also fails if two installed Symbiotes have the same interop ID. This is true for both Symbiotes, not just one of them.
## API Docs
The list links to the various API versions. Note that while deprecated versions are still supported for now, we are phasing them out and you should [update your existing Symbiotes](#upgrade) to a newer version and not create new ones with those versions. API updates that are exclusively additional (eg: a completely new API call) will not bump the version number, this will only be done on changes that potentially change the behavior of existing calls to be able to retain compatibility.
For practical examples of the API in use, see our [GitHub repository](https://github.com/Bouncyrock/symbiotes-examples) of Symbiote examples.
- [v0.1](https://symbiote-docs.talespire.com/api_doc_v0_1.md.html)
Additional Info
### API reverse domain name
By default the API functions are accessible under the `TS` object, which is an alias for `com.bouncyrock.talespire`. It may be that this short alias has name conflicts on an existing webpage not made with TaleSpire/Symbiotes in mind, which is why it can be disabled in the [manifest](#manifest-docs). All functions can be accessed by calling `com.bouncyrock.talespire.functionCategory.theFunctionYouWantToCall` instead.
### Rate limiting
We reserve the right to block any API call if the Symbiote has sent too many in short succession to preserve performance for TaleSpire. Any blocked calls will return the `rateLimited` error.
### Permissions
The API only allows access to information that is accessible through the game normally. This means for example that a Symbiote running on a player's client will only be able to query stats for a mini controlled by them, not for other minis. Similarly a slab cannot be put into the hand of a player, as they don't have permissions to build. There is a distinction between users who *can* be a GM (= they have permission to be GM) and users who *currently are* a GM. Most calls that require a certain permission need the user (= player) to be able to switch to GM mode (`canGM` is set to true), while they don't care about which mode the client is currently in.
### Injection
If an API version is specified in the [manifest](#manifest-docs), it is injected by TaleSpire. After this, each extra specified in the extras array in the [manifest](#manifest-docs) is injected individually in order of the array. Finally, if the API was injected the API initialization starts. This guarantees that:
- you can rely on the API existing (but not initialized) in your injected code,
- you can rely on your injected code to be able to listen to and receive the `hasInitialized` event, and
- you can rely on injections later in the extras array being able to see and edit previous injections like for example: Adding extra regexes to the Dice Finder extra by injecting a .js file after injecting dice finder.
Scripts loaded by the HTML file itself are loaded according to normal browser behavior which can be before DOMContentLoaded and before API injection. [Deferring](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script) or setting an [event listener for onLoad](https://developer.mozilla.org/en-US/docs/Web/API/Window/load_event) can be used to ensure they are loaded at the appropriate time. The `hasInitialized` event is fired as soon as the API has finished loading and the communication between TaleSpire and the embedded browser is running.
The entire injection procedure is repeated for every page load. This includes the user navigating away from the entry point by clicking a link or a programmatic page switch using eg: [window.open](https://developer.mozilla.org/en-US/docs/Web/API/Window/open). Because of this it is often best to create Symbiotes as one-pagers that switch out content programmatically if necessary instead of navigating to other pages.
### Symbiote lifetime
#### Symbiote Detection
TaleSpire scans the top-level directories under `%AppData%\..\LocalLow\BouncyRock Entertainment\TaleSpire\Symbiotes\` looking for directories containing a manifest.json file. The manifest is then read, and the Symbiote is validated. The Symbiote is then available in the symbiotes panel. If the validation fails, then the entry in the panel will have a little warning marker indicating that fact. TaleSpire continues watching the Symbiotes directory for newly added or removed Symbiotes.
#### Starting Symbiote
When a Symbiote starts, TaleSpire loads the entryPoint specified in the [manifest](#manifest-docs). By default, any additional code [injection](#injection) (such as for the TaleSpire API) begins once the [DOMContentLoaded](https://developer.mozilla.org/en-US/docs/Web/API/Window/DOMContentLoaded_event) event is emitted by the embedded browser, guaranteeing the `hasInitialized` event triggers only once the entire DOM is available. If this is unimportant to you, you can override this by setting the `initializeEarly` option to true in the [manifest](#manifest-docs). Note that `initializeEarly` doesn't guarantee the `hasInitialized` event to trigger before DOMContentLoaded; it just makes it possible that it can happen depending on timing.
#### In-use lifetime
If your Symbiote is focused and the Symbiotes panel is closed, your Symbiote will be delivered an event via the `onVisibilityEvent` event source.
If your Symbiote was focused, but then a different Symbiote becomes the focus, the behavior depends on the capabilities of your Symbiote. If the `runInBackground` capability is specified, then the Symbiote will be informed that it `willEnterBackground`, and the Symbiote will continue running. If the `runInBackground` capability is not specified, then the Symbiote is shut down until it becomes focused again. We recommend not using the `runInBackground` capability whenever possible, as it allows TaleSpire to free up resources for the player. Instead, we prefer saving to Symbiote storage after all notable data changes and reloading them on Symbiote startup.
Symbiotes survive board switches, but some API calls won't work during the switch. For example, `TS.players.getPlayersInThisBoard` doesn't make sense if the Symbiote is not in a board. This should only take a short time but should be handled by the Symbiote by listening for client leave/join events for your own client and suppressing API calls.
If dev-mode is enabled, TaleSpire watches the symbiote directory for changes and will live-reload the Symbiote when modified. The folders are watched for file changes to see updates to files and detect new Symbiotes being added (or existing ones removed). Note the exceptions to file watching mentioned in [Development Workflow](https://symbiote-docs.talespire.com/dev-workflow).
#### Shutdown Symbiote
On shutdown of a Symbiote, we attempt to deliver the `willShutdown` event from the event source `onStateChangeEvent`. This can give the Symbiote a short time to do any last-minute cleanup you want to do before the Symbiote is shut down. However, delivery of the shutdown event is made on a "best effort" and is not guaranteed. Therefore don't leave important tasks for this event. We reserve the right to shut down any Symbiote without event/warning for reasons such as performance or stability.
## Extras
While all of the features of the API are documented above, there are some other miscellaneous extras we provide to make creating your own Symbiotes more convenient and make them look more at home in TaleSpire. They can be added by adding their respective name to the "extras" list in the [manifest](#manifest-docs).
### TS Theme
If the string `colorStyles` is added to extras, TaleSpire injects a number of CSS variables defining the TaleSpire color scheme, which we highly recommend if you're trying to create a Symbiote that is supposed to fit in with the rest of the UI. This provides easy access to the exact colors TaleSpire's UI uses and they'll be updated with TaleSpire to keep your Symbiote fitting in at no extra work for you. This also allows users to change the UI theme to for example switch to a high contrast theme and have all the Symbiotes adapt to that.
The theme switching UI is not completed yet and will be released at a later time. This means Symbiotes will only load with the default theme for now. If you want to test the high contrast theme you can edit the CSS vars to point to that theme in the browser dev console manually.
Detailed Description & Color Name List
The color descriptions provide info which colors should be used together - you can of course mix and match them in other ways, but please always check any combination with your favorite contrast checker tool. You should aim for a contrast of at least 4.5, though 7 or more is preferable as it helps with legibility, especially for visually impaired people. We don't always achieve this ourselves, but we'll try our best to improve on that front.
While you can use colors in "any way you want", do not completely misuse a color against its name or description, like using a background color in the foreground. Even if it might look fine now, if we update the color palette it may lead to bad looking or even unreadable Symbiotes. If there is a color missing for what you want to do it is better to create your own color than to "misuse" an existing theme color for something it is not intended for.
We want to have as few variables as possible, but the goal is that the entirety of a Symbiote's color palette is defined by these colors (if they are intending to replicate TaleSpire's theme that is), without any manually defined color codes. We are happy to receive [feedback](https://feedback.talespire.com/b/Feature-request) about which situations there may be a color missing.
- `--ts-color-primary`: The primary color for elements like text. Provides high contrast to `--ts-background-primary`
Example
- `--ts-color-secondary`: The secondary color for elements like text. Less contrast than the primary color and can be used for disabled elements or less prominent text like descriptions or captions.
Example
- `--ts-background-primary`: The primary color for the background. Provides high contrast to `--ts-color-primary`.
Example
- `--ts-background-secondary`: The secondary color for the background. Can be used for example for cards that should be visually distinct from the normal background, for alternating color on long lists or tables or for low contrast borders. If visual distinction is essential to be able to use the UI comfortably, consider giving any elements that use this background color on top of `--ts-background-primary` a border with color `--ts-accessibility-border` because the secondary and primary background colors don't provide good contrast with each other.
Example
- `--ts-background-tertiary`: The primary color for the background. Even less contrast with `--ts-color-primary` than the other two background colors. Used for similar cases as `--ts-background-secondary`, as well as button backgrounds, but care should be put into contrast in conjunction with `--ts-color-secondary`. For button backgrounds, see also `--ts-button-background`.
Example
- `--ts-accessibility-border`: Defines the border color for the high contrast theme for better accessibility. Is transparent (= invisible) in the default theme, but should be used on input elements to create a more accessible experience when needed. When a border is desired in the normal theme use other colors, but to not impede the usefulness of the high contrast theme, consider using one of the foreground colors as border for input elements to provide a high contrast and only using a background colors as border for non-interactive elements (eg: table borders).
Example
- `--ts-accessibility-focus`: Defines a focus color for the high contrast theme for better accessibility. This allows keyboard navigation in Symbiotes by providing a visible highlight around the currently focused element. Is transparent (= invisible) in the default theme, but should be set for all focusable elements (usually input elements). Focus should be set using the "outline" css property. See [examples](https://github.com/Bouncyrock/symbiotes-examples) for guidance.
Example
- `--ts-color-danger`: An accent color for depicting something potentially dangerous like deleting/clearing data or similar.
Example
- `--ts-accent-primary`: The primary TaleSpire accent color. Can be used for background, text and accents like borders. Be mindful of using it (and the accompanying `--ts-accent-hover`) as background color, because it doesn't provide great contrast with `--ts-color-primary`.
Example
- `--ts-accent-hover`: An on hover color for `--ts-accent-primary`. Used wherever there should be a highlight on mouse hover (or similar actions) and `--ts-accent-primary` is used. Similar considerations regarding contrast as with `--ts-accent-primary`.
Example
- `--ts-accent-background`: A variant of the accent color to serve better as a background. Can be used for example as background color for an element that already has the primary accent color as border. Provides more contrast to `--ts-color-primary` when used in the background than `--ts-accent-primary`.
Example
- `--ts-button-background`: The default button background color. Should be used for most buttons, but to provide contrast between important and less important actions in the UI, `--ts-background-tertiary` can be used for those lower priority elements instead. Does not provide good contrast to `--ts-color-secondary`.
Example
- `--ts-button-hover`: An on hover color for `--ts-button-background`. Used wherever there should be a highlight on mouse hover (or similar actions) and `--ts-button-background` is used. Similar considerations regarding contrast as with `--ts-button-background`
Example
- `--ts-link`: Text color for hyperlinks.
Example
- `--ts-link-hover`: An on hover color for `--ts-link`. Used wherever there should be a highlight on mouse hover (or similar actions) and `--ts-link` is used.
Example
### TS Fonts
If the string `fonts` is added to extras, TaleSpire injects the TaleSpire fonts to be used in CSS. Available fonts:
- Optimus Princeps: `font-family: OptimusPrinceps;`
This is an example text with the font
### TS Icons
If the string `icons` is added to extras, TaleSpire injects CSS classes for each UI icon and some control classes to adjust sizing and behavior.
<br/>An icon can be inserted by providing an icon element and adding the respective class for the icon you want:
`<i class="ts-icon-sword-crossed"></i>`
<br/>Additionally, the sizing of the icon can be set to one of the following by inserting their respective classes:
- `ts-icon-xsmall`: 16px
- `ts-icon-small`: 24px
- `ts-icon-medium`: 32px - if no size is specified, it will default to this size
- `ts-icon-large`: 64px
- `ts-icon-xlarge`: 128px
and a black border can be added by providing `ts-icon-border`. Keep in mind, that the icons are usually pure white, so without a border they will be invisible on a white background.
An icon using all of these would look like this: `<i class="ts-icon-book ts-icon-small ts-icon-border"></i>`
It's possible to invert the icon colors to have black icons (with white borders) by adding the `ts-icon-black` class.
See here for a list of icons: [Available Icons](https://symbiote-docs.talespire.com/icons.html)
### Dice Finder
If the string `diceFinder` is added to extras, the dice finder functionality will be added to the Symbiote that's being loaded. The dice finder can identify whether text under the mouse cursor looks like a dice roll that TaleSpire can understand. This is meant to work robustly on as many websites as possible, but due to the vast amount of websites and different approaches it may not work on some. For it to work, it also needs to have the [API](#api-docs) injected.
We have tested the dice finder on a number of different websites to see if it behaves correctly, but it's likely that the dice finder may be broken on some websites that work differently under the hood. If you encounter any problems with dice finder, please [let us know](https://feedback.talespire.com/b/Bug-Reports) which website is being problematic so we can investigate it.
Because the dice finder is meant to work on as many pages as possible, the depth of integration is limited. For example it will never be able to extract roll names from pages as that information is presented very differently for every page. This means that while the dice finder provides an easy baseline of support for pages that don't support TaleSpire out of the box, it will never be as good as support specifically made for a certain website.
## FAQ
### Will there be more features down the road?
We absolutely want to expand on the Symbiotes API! This is just the first step and we're hoping on vastly expanding the capabilities exposed to Symbiotes via the API
### Can Symbiotes change things on the board like changing stats of a mini or place tiles?
Not yet, but this is something we definitely want to tackle for future updates of the API. For now we opted to not include anything that edits "board state", that is Symbiotes can't do anything that gets saved to the board. We chose this to be able to get Symbiotes into your hands sooner as allowing persisting changes through the API needs careful consideration and planning to make it both safe and nice to use.
### Why does logging in on some websites not work?
Some websites open popups/new tabs during login, especially when they're using third party authentication like logging in with your Google account. Having this in different tabs is necessary for the login flow to work, however the default behavior of Symbiotes loads all page navigations in the same tab (equivalent to changing the link target from `_blank` to `_self`). If you are having issues with login you can set the `loadTargetBehavior` field in the [manifest](#manifest-docs) to "popup" to allow links to be opened in popups instead of in the main tab.
### Can I make a Symbiote that shows different UI for players and GMs?
Yes! You can either query `TS.players` to see whether the player running the Symbiote has GM permission (`canGM` is set to true), or `TS.clients` to see if the game client is currently in GM view or player view (regardless of the permission the player has) and adapt the displayed UI accordingly.
### Can two Symbiotes talk to each other?
There's two cases here:
1. The same Symbiote across two different clients (= two players having the same Symbiote open) - Yes, these can communicate. They just need to have an interop ID set and then they can use `TS.sync` calls
2. Two different Symbiotes on the same machine - No they cannot communicate directly. The Symbiotes API doesn't allow for direct communication between two Symbiotes with different interop IDs (and there mustn't be two Symbiotes with the same interop ID on the same client). If you have a use case for this do let us know about it!
### What version of Chromium are you using?
As of right now it's version 111, but don't depend on this being stable and unchanging for certain behaviors in your Symbiotes as we will be updating this without prior notice for security and compatibility reasons.
## Upgrade Guides
There are no versions to upgrade to or from yet.

18
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "campaign-manager-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
argon2 = "0.5"
uuid = { version = "1", features = ["v4", "serde"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"

1
backend/src/lib.rs Normal file
View File

@@ -0,0 +1 @@
// Module entrypoint — reserved for integration tests and shared types.

43
backend/src/main.rs Normal file
View File

@@ -0,0 +1,43 @@
use axum::{
http::StatusCode,
response::IntoResponse,
routing::any,
Router,
};
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() {
// Load .env if present (dev convenience)
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "campaign_manager_backend=debug,tower_http=debug".into()))
.with(tracing_subscriber::fmt::layer())
.init();
let _database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let _jwt_secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set");
// TODO: initialize sqlx connection pool
// let pool = sqlx::PgPool::connect(&database_url).await.expect("failed to connect to DB");
let app = Router::new()
.route("/v1/*path", any(not_implemented))
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("failed to bind");
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.expect("server error");
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "not implemented")
}

File diff suppressed because it is too large Load Diff

14
package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "campaign-manager",
"private": true,
"pnpm": {
"onlyBuiltDependencies": ["esbuild"]
},
"scripts": {
"dev:symbiote": "pnpm --filter symbiote dev",
"dev:web": "pnpm --filter web dev",
"build:symbiote": "pnpm --filter symbiote build",
"build:web": "pnpm --filter web build",
"build:backend": "cargo build --release"
}
}

1013
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
packages:
- 'symbiote'
- 'web'
- 'rulesets'

7
rulesets/package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@campaign-manager/rulesets",
"private": true,
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts"
}

View File

@@ -0,0 +1,62 @@
import type { Ruleset, SheetData, ValidationResult, StatBlock, DiceAction, VNode } from '../types';
const dsa5e: Ruleset = {
id: 'dsa5e',
name: 'Das Schwarze Auge 5e',
version: '0.1.0',
defaultSheet(): SheetData {
return {
name: '',
race: '',
culture: '',
profession: '',
attributes: {
courage: 8,
sagacity: 8,
intuition: 8,
charisma: 8,
dexterity: 8,
agility: 8,
constitution: 8,
strength: 8,
},
derivedValues: {
lifePoints: 0,
arcaneEnergy: null,
karmaPoints: null,
initiative: 0,
dodge: 0,
woundThreshold: 0,
},
talents: [],
combatTechniques: [],
specialAbilities: [],
spells: [],
liturgies: [],
equipment: [],
notes: '',
};
},
validate(_sheet: SheetData): ValidationResult {
// TODO: implement DSA5e validation rules
return { valid: true, errors: [] };
},
getStatBlocks(_sheet: SheetData): StatBlock[] {
// TODO: extract stat blocks from sheet
return [];
},
getDiceActions(_sheet: SheetData): DiceAction[] {
// TODO: derive dice actions from talents and combat techniques
return [];
},
renderSheet(_sheet: SheetData): VNode {
throw new Error('not implemented');
},
};
export default dsa5e;

View File

@@ -0,0 +1,34 @@
import type { Ruleset, SheetData, ValidationResult, StatBlock, DiceAction, VNode } from '../types';
const generic: Ruleset = {
id: 'generic',
name: 'Generic',
version: '0.1.0',
defaultSheet(): SheetData {
return {
name: '',
hp: 0,
maxHp: 0,
notes: '',
};
},
validate(_sheet: SheetData): ValidationResult {
return { valid: true, errors: [] };
},
getStatBlocks(_sheet: SheetData): StatBlock[] {
return [];
},
getDiceActions(_sheet: SheetData): DiceAction[] {
return [];
},
renderSheet(_sheet: SheetData): VNode {
throw new Error('not implemented');
},
};
export default generic;

21
rulesets/src/index.ts Normal file
View File

@@ -0,0 +1,21 @@
export type {
SheetData,
FieldError,
ValidationResult,
StatBlock,
DiceAction,
VNode,
Ruleset,
} from './types';
export { default as dsa5e } from './dsa5e/index';
export { default as generic } from './generic/index';
import type { Ruleset } from './types';
import dsa5e from './dsa5e/index';
import generic from './generic/index';
export const RULESETS: Record<string, Ruleset> = {
[dsa5e.id]: dsa5e,
[generic.id]: generic,
};

38
rulesets/src/types.ts Normal file
View File

@@ -0,0 +1,38 @@
export type SheetData = Record<string, unknown>;
export interface FieldError {
field: string;
message: string;
}
export interface ValidationResult {
valid: boolean;
errors: FieldError[];
}
export interface StatBlock {
label: string;
value: string | number;
}
export interface DiceAction {
label: string;
/** TaleSpire dice notation, e.g. '1d20+5', '2d6' */
diceString: string;
category: 'attack' | 'skill' | 'save' | 'damage' | 'custom';
}
/** Opaque render output — resolved by Svelte, not a VDOM */
export type VNode = unknown;
export interface Ruleset {
id: string;
name: string;
/** semver */
version: string;
defaultSheet(): SheetData;
validate(sheet: SheetData): ValidationResult;
getStatBlocks(sheet: SheetData): StatBlock[];
getDiceActions(sheet: SheetData): DiceAction[];
renderSheet(sheet: SheetData): VNode;
}

11
rulesets/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}

12
symbiote/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Campaign Manager</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts" defer></script>
</body>
</html>

20
symbiote/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@campaign-manager/symbiote",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"svelte": "^5.0.0",
"@campaign-manager/rulesets": "workspace:*"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,11 @@
{
"apiVersion": "v0.1",
"name": "Campaign Manager",
"version": "0.1.0",
"author": "",
"description": "Authenticated campaign and character management for TaleSpire",
"interopId": "com.campaign-manager.symbiote",
"entryPoint": "index.html",
"extras": ["colorStyles", "fonts", "diceFinder", "icons"],
"runInBackground": false
}

24
symbiote/src/App.svelte Normal file
View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { writable } from 'svelte/store';
import Login from './views/Login.svelte';
import GroupList from './views/GroupList.svelte';
import GroupDetail from './views/GroupDetail.svelte';
import CharacterSheet from './views/CharacterSheet.svelte';
import RollHistory from './views/RollHistory.svelte';
export type View = 'login' | 'group-list' | 'group-detail' | 'character-sheet' | 'roll-history';
export const currentView = writable<View>('login');
</script>
{#if $currentView === 'login'}
<Login />
{:else if $currentView === 'group-list'}
<GroupList />
{:else if $currentView === 'group-detail'}
<GroupDetail />
{:else if $currentView === 'character-sheet'}
<CharacterSheet />
{:else if $currentView === 'roll-history'}
<RollHistory />
{/if}

76
symbiote/src/lib/api.ts Normal file
View File

@@ -0,0 +1,76 @@
import { get } from 'svelte/store';
import { accessToken, refreshToken, saveTokens, clearTokens, isBoardTransition } from './store';
export const BASE_URL = 'https://api.campaign-manager.example.com/v1';
interface RequestOptions extends RequestInit {
skipAuth?: boolean;
}
async function silentRefresh(): Promise<boolean> {
const rt = get(refreshToken);
if (!rt) return false;
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: rt }),
});
if (!res.ok) {
await clearTokens();
return false;
}
const data = await res.json() as { accessToken: string; refreshToken: string };
await saveTokens(data.accessToken, data.refreshToken);
return true;
}
async function apiFetch(path: string, options: RequestOptions = {}): Promise<Response> {
if (get(isBoardTransition)) {
throw new Error('API call suppressed during board transition');
}
const { skipAuth = false, ...fetchOptions } = options;
const headers = new Headers(fetchOptions.headers);
if (!skipAuth) {
const token = get(accessToken);
if (token) headers.set('Authorization', `Bearer ${token}`);
}
if (fetchOptions.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
let res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers });
if (res.status === 401 && !skipAuth) {
const refreshed = await silentRefresh();
if (refreshed) {
const newToken = get(accessToken);
if (newToken) headers.set('Authorization', `Bearer ${newToken}`);
res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers });
}
}
return res;
}
export const api = {
get: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'GET' }),
post: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'DELETE' }),
};

56
symbiote/src/lib/store.ts Normal file
View File

@@ -0,0 +1,56 @@
import { writable } from 'svelte/store';
export interface User {
id: string;
email: string;
displayName: string;
emailVerified: boolean;
}
export interface Group {
id: string;
name: string;
dmUserId: string;
rulesetId: string;
description: string;
createdAt: string;
}
export const accessToken = writable<string | null>(null);
export const refreshToken = writable<string | null>(null);
export const currentUser = writable<User | null>(null);
export const currentGroup = writable<Group | null>(null);
/** True while the board is transitioning (client leave/join); suppresses API calls */
export const isBoardTransition = writable<boolean>(false);
const STORAGE_KEY_ACCESS = 'cm_access_token';
const STORAGE_KEY_REFRESH = 'cm_refresh_token';
export async function loadTokens(): Promise<void> {
const [access, refresh] = await Promise.all([
TS.storage.getItem(STORAGE_KEY_ACCESS),
TS.storage.getItem(STORAGE_KEY_REFRESH),
]);
accessToken.set(access);
refreshToken.set(refresh);
}
export async function saveTokens(access: string, refresh: string): Promise<void> {
await Promise.all([
TS.storage.setItem(STORAGE_KEY_ACCESS, access),
TS.storage.setItem(STORAGE_KEY_REFRESH, refresh),
]);
accessToken.set(access);
refreshToken.set(refresh);
}
export async function clearTokens(): Promise<void> {
await Promise.all([
TS.storage.removeItem(STORAGE_KEY_ACCESS),
TS.storage.removeItem(STORAGE_KEY_REFRESH),
]);
accessToken.set(null);
refreshToken.set(null);
currentUser.set(null);
currentGroup.set(null);
}

41
symbiote/src/lib/ts-api.d.ts vendored Normal file
View File

@@ -0,0 +1,41 @@
/** Ambient declarations for the TaleSpire Symbiote API injected into the page globally */
interface DiceResult {
notation: string;
results: number[];
total: number;
}
declare const TS: {
/** Resolves once the TaleSpire API is ready */
hasInitialized: Promise<void>;
storage: {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
};
dice: {
/** Place dice in the player's hand for rolling */
putDiceInHand(diceString: string): Promise<void>;
onDiceResult: {
addListener(cb: (result: DiceResult) => void): void;
};
};
players: {
onClientJoinEvent: {
addListener(cb: () => void): void;
};
onClientLeaveEvent: {
addListener(cb: () => void): void;
};
};
sync: {
/** Broadcast transient state to all clients on the same board */
send(data: unknown): void;
addListener(cb: (data: unknown) => void): void;
};
};

11
symbiote/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import App from './App.svelte';
import { loadTokens, isBoardTransition } from './lib/store';
// Suppress API calls while the board is transitioning between scenes
TS.players.onClientLeaveEvent.addListener(() => isBoardTransition.set(true));
TS.players.onClientJoinEvent.addListener(() => isBoardTransition.set(false));
await TS.hasInitialized;
await loadTokens();
new App({ target: document.getElementById('app')! });

View File

@@ -0,0 +1,8 @@
<script lang="ts">
// TODO: render character sheet via ruleset plugin
</script>
<!-- TODO: Character sheet -->
<div class="view-character-sheet">
<h2>Character Sheet</h2>
</div>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
// TODO: display group members and characters
</script>
<!-- TODO: Group detail -->
<div class="view-group-detail">
<h2>Group Detail</h2>
</div>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
// TODO: fetch and display user's groups
</script>
<!-- TODO: Group list -->
<div class="view-group-list">
<h2>Your Groups</h2>
</div>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
// TODO: Login/Register form
</script>
<!-- TODO: Login/Register form -->
<div class="view-login">
<h1>Campaign Manager</h1>
<p>Login / Register — coming soon</p>
</div>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
// TODO: display dice roll history for current group
</script>
<!-- TODO: Roll history panel -->
<div class="view-roll-history">
<h2>Roll History</h2>
</div>

13
symbiote/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}

11
symbiote/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
target: 'es2022',
},
});

38
web/build/404.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" />
<link href="/_app/immutable/entry/start.Pj34kLt-.js" rel="modulepreload">
<link href="/_app/immutable/chunks/YzYuob9f.js" rel="modulepreload">
<link href="/_app/immutable/chunks/CUCwB180.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DNqN6DmX.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.H3SWXino.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Db9w--lA.js" rel="modulepreload">
<link href="/_app/immutable/chunks/pTMRHjpX.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Bzak7iHL.js" rel="modulepreload">
<link href="/_app/immutable/chunks/C5YqYP7P.js" rel="modulepreload">
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">
<script>
{
__sveltekit_1smak34 = {
base: ""
};
const element = document.currentScript.parentElement;
Promise.all([
import("/_app/immutable/entry/start.Pj34kLt-.js"),
import("/_app/immutable/entry/app.H3SWXino.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
}
</script>
</div>
</body>
</html>

1
web/build/_app/env.js Normal file
View File

@@ -0,0 +1 @@
export const env={}

View File

@@ -0,0 +1 @@
var e;typeof window<"u"&&((e=window.__svelte??(window.__svelte={})).v??(e.v=new Set)).add("5");

View File

@@ -0,0 +1 @@
import{s as c,g as l}from"./DNqN6DmX.js";import{b as o,d as b,n as a,m as d,g as p,e as g}from"./CUCwB180.js";let s=!1,i=Symbol();function m(e,u,r){const n=r[u]??(r[u]={store:null,source:d(void 0),unsubscribe:a});if(n.store!==e&&!(i in r))if(n.unsubscribe(),n.store=e??null,e==null)n.source.v=void 0,n.unsubscribe=a;else{var t=!0;n.unsubscribe=c(e,f=>{t?n.source.v=f:g(n.source,f)}),t=!1}return e&&i in r?l(e):p(n.source)}function y(){const e={};function u(){o(()=>{for(var r in e)e[r].unsubscribe();b(e,i,{enumerable:!1,value:!0})})}return[e,u]}function N(e){var u=s;try{return s=!1,[e(),s]}finally{s=u}}export{m as a,N as c,y as s};

View File

@@ -0,0 +1 @@
import{w as a}from"./CUCwB180.js";a();

View File

@@ -0,0 +1 @@
import{s as e}from"./YzYuob9f.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{n as c,j as a,J as p,i as d,h as l,K as m}from"./CUCwB180.js";function h(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function g(e,n,s){if(e==null)return n(void 0),c;const u=a(()=>e.subscribe(n,s));return u.unsubscribe?()=>u.unsubscribe():u}const i=[];function q(e,n=c){let s=null;const u=new Set;function r(o){if(p(e,o)&&(e=o,s)){const f=!i.length;for(const t of u)t[1](),i.push(t,e);if(f){for(let t=0;t<i.length;t+=2)i[t][0](i[t+1]);i.length=0}}}function b(o){r(o(e))}function _(o,f=c){const t=[o,f];return u.add(t),u.size===1&&(s=n(r,b)||c),o(e),()=>{u.delete(t),u.size===0&&s&&(s(),s=null)}}return{set:r,update:b,subscribe:_}}function k(e){let n;return g(e,s=>n=s)(),n}function x(e){l===null&&h(),m&&l.l!==null?w(l).m.push(e):d(()=>{const n=a(e);if(typeof n=="function")return n})}function w(e){var n=e.l;return n.u??(n.u={a:[],b:[],m:[]})}export{k as g,x as o,g as s,q as w};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{h as d,u as g,i as c,j as m,k as l,l as b,g as p,o as h,q as k}from"./CUCwB180.js";function x(n=!1){const s=d,e=s.l.u;if(!e)return;let r=()=>h(s.s);if(n){let o=0,t={};const _=k(()=>{let i=!1;const a=s.s;for(const f in a)a[f]!==t[f]&&(t[f]=a[f],i=!0);return i&&o++,o});r=()=>p(_)}e.b.length&&g(()=>{u(s,r),l(e.b)}),c(()=>{const o=m(()=>e.m.map(b));return()=>{for(const t of o)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,r),l(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}export{x as i};

View File

@@ -0,0 +1 @@
import{x as h,y as d,z as _,A as l,B as p,T as E,C as g,D as u,E as s,R as y,F as x,G as A,H as M,I as N}from"./CUCwB180.js";var f;const i=((f=globalThis==null?void 0:globalThis.window)==null?void 0:f.trustedTypes)&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function b(t){return(i==null?void 0:i.createHTML(t))??t}function L(t){var r=h("template");return r.innerHTML=b(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=_;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function w(t,r){var e=(r&E)!==0,c=(r&g)!==0,a,m=!t.startsWith("<!>");return()=>{if(u)return n(s,null),s;a===void 0&&(a=L(m?t:"<!>"+t),e||(a=l(a)));var o=c||p?document.importNode(a,!0):a.cloneNode(!0);if(e){var T=l(o),v=o.lastChild;n(T,v)}else n(o,o);return o}}function C(t=""){if(!u){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==A?(e.before(e=d()),M(e)):N(e),n(e,e),e}function D(){if(u)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function H(t,r){if(u){var e=_;((e.f&y)===0||e.nodes.end===null)&&(e.nodes.end=s),x();return}t!==null&&t.before(r)}export{H as a,n as b,D as c,w as f,C as t};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{l as o,a as r}from"../chunks/YzYuob9f.js";export{o as load_css,r as start};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{o as h}from"../chunks/DNqN6DmX.js";import{D as m,F as g,p as y,f as T,a as $}from"../chunks/CUCwB180.js";import{s as _,a as k}from"../chunks/C5YqYP7P.js";import{c as v,a as w}from"../chunks/pTMRHjpX.js";import{i as A}from"../chunks/eiK12uJk.js";import{g as u}from"../chunks/YzYuob9f.js";import{p as O}from"../chunks/CGowzvwH.js";function S(t,e,a,n,o){var f;m&&g();var s=(f=e.$$slots)==null?void 0:f[a],r=!1;s===!0&&(s=e.children,r=!0),s===void 0||s(t,r?()=>n:n)}const d="https://api.campaign-manager.example.com/v1";let i=null;function E(t){i=t}async function P(){const t=await fetch(`${d}/auth/refresh`,{method:"POST",credentials:"include"});return t.ok?(i=(await t.json()).accessToken,!0):(i=null,!1)}async function c(t,e={}){const{skipAuth:a=!1,...n}=e,o=new Headers(n.headers);!a&&i&&o.set("Authorization",`Bearer ${i}`),n.body&&!o.has("Content-Type")&&o.set("Content-Type","application/json");let s=await fetch(`${d}${t}`,{...n,headers:o,credentials:"include"});return s.status===401&&!a&&await P()&&i&&(o.set("Authorization",`Bearer ${i}`),s=await fetch(`${d}${t}`,{...n,headers:o,credentials:"include"})),s}const B={get:(t,e)=>c(t,{...e,method:"GET"}),post:(t,e,a)=>c(t,{...a,method:"POST",body:e?JSON.stringify(e):void 0}),put:(t,e,a)=>c(t,{...a,method:"PUT",body:e?JSON.stringify(e):void 0}),patch:(t,e,a)=>c(t,{...a,method:"PATCH",body:e?JSON.stringify(e):void 0}),delete:(t,e)=>c(t,{...e,method:"DELETE"})};function z(t,e){y(e,!1);const a=()=>k(O,"$page",n),[n,o]=_(),s=["/login"];h(async()=>{if(!s.includes(a().url.pathname))try{const l=await B.post("/auth/refresh",void 0,{skipAuth:!0});if(l.ok){const p=await l.json();E(p.accessToken)}else u("/login")}catch{u("/login")}}),A();var r=v(),f=T(r);S(f,e,"default",{}),w(t,r),$(),o()}export{z as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as h,f as g,t as l,a as v,c as e,r as o,s as d}from"../chunks/CUCwB180.js";import{s as p}from"../chunks/Db9w--lA.js";import{a as _,f as x}from"../chunks/pTMRHjpX.js";import{i as $}from"../chunks/eiK12uJk.js";import{s as k,p as m}from"../chunks/YzYuob9f.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=x("<h1> </h1> <p> </p>",1);function C(f,c){h(c,!1),$();var t=E(),r=g(t),n=e(r,!0);o(r);var s=d(r,2),u=e(s,!0);o(s),l(()=>{var a;p(n,i.status),p(u,(a=i.error)==null?void 0:a.message)}),_(f,t),v()}export{C as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{o as p}from"../chunks/DNqN6DmX.js";import{p as r,a as t}from"../chunks/CUCwB180.js";import{i as m}from"../chunks/eiK12uJk.js";import{g as a}from"../chunks/YzYuob9f.js";function c(i,o){r(o,!1),p(()=>a("/groups")),m(),t()}export{c as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{a,f as t}from"../chunks/pTMRHjpX.js";var p=t("<h1>Your Characters</h1>");function n(o){var r=p();a(o,r)}export{n as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as c,t as n,a as h,c as g,r as l}from"../chunks/CUCwB180.js";import{s as _,a as $}from"../chunks/C5YqYP7P.js";import{s as d}from"../chunks/Db9w--lA.js";import{a as u,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as C}from"../chunks/CGowzvwH.js";var b=v("<h1>Character Sheet</h1> <p> </p>",1);function z(r,s){i(s,!1);const e=()=>$(C,"$page",p),[p,o]=_();x();var a=b(),t=f(c(a),2),m=g(t);l(t),n(()=>d(m,`Character ID: ${e().params.id??""}`)),u(r,a),h(),o()}export{z as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{a as p,f as a}from"../chunks/pTMRHjpX.js";var t=a("<h1>Your Groups</h1>");function f(o){var r=t();p(o,r)}export{f as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as n,t as c,a as l,c as g,r as _}from"../chunks/CUCwB180.js";import{s as $,a as h}from"../chunks/C5YqYP7P.js";import{s as u}from"../chunks/Db9w--lA.js";import{a as d,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as D}from"../chunks/CGowzvwH.js";var G=v("<h1>Group Detail</h1> <p> </p>",1);function A(s,r){i(r,!1);const p=()=>h(D,"$page",o),[o,e]=$();x();var a=G(),t=f(n(a),2),m=g(t);_(t),c(()=>u(m,`Group ID: ${p().params.id??""}`)),d(s,a),l(),e()}export{A as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as n,t as c,a as g,c as l,r as _}from"../chunks/CUCwB180.js";import{s as $,a as h}from"../chunks/C5YqYP7P.js";import{s as u}from"../chunks/Db9w--lA.js";import{a as d,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as G}from"../chunks/CGowzvwH.js";var b=v("<h1>Group Settings</h1> <p> </p>",1);function z(a,r){i(r,!1);const p=()=>h(G,"$page",o),[o,e]=$();x();var t=b(),s=f(n(t),2),m=l(s);_(s),c(()=>u(m,`Group ID: ${p().params.id??""}`)),d(a,t),g(),e()}export{z as component};

View File

@@ -0,0 +1 @@
import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{v as r}from"../chunks/CUCwB180.js";import{a as m,f as n}from"../chunks/pTMRHjpX.js";var p=n("<h1>Campaign Manager</h1> <p>Login — coming soon</p>",1);function g(o){var a=p();r(2),m(o,a)}export{g as component};

View File

@@ -0,0 +1 @@
{"version":"1773071061170"}

22
web/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "@campaign-manager/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"svelte": "^5.0.0",
"@campaign-manager/rulesets": "workspace:*"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
}
}

12
web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

79
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,79 @@
export const BASE_URL = 'https://api.campaign-manager.example.com/v1';
// Access token held in memory only — never persisted to localStorage
let _accessToken: string | null = null;
export function setAccessToken(token: string | null): void {
_accessToken = token;
}
export function getAccessToken(): string | null {
return _accessToken;
}
interface RequestOptions extends RequestInit {
skipAuth?: boolean;
}
async function silentRefresh(): Promise<boolean> {
// Refresh token is in an HttpOnly cookie; no manual credential needed
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) {
_accessToken = null;
return false;
}
const data = await res.json() as { accessToken: string };
_accessToken = data.accessToken;
return true;
}
async function apiFetch(path: string, options: RequestOptions = {}): Promise<Response> {
const { skipAuth = false, ...fetchOptions } = options;
const headers = new Headers(fetchOptions.headers);
if (!skipAuth && _accessToken) {
headers.set('Authorization', `Bearer ${_accessToken}`);
}
if (fetchOptions.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
let res = await fetch(`${BASE_URL}${path}`, {
...fetchOptions,
headers,
credentials: 'include',
});
if (res.status === 401 && !skipAuth) {
const refreshed = await silentRefresh();
if (refreshed && _accessToken) {
headers.set('Authorization', `Bearer ${_accessToken}`);
res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers, credentials: 'include' });
}
}
return res;
}
export const api = {
get: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'GET' }),
post: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }),
put: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }),
patch: (path: string, body?: unknown, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }),
delete: (path: string, options?: RequestOptions) =>
apiFetch(path, { ...options, method: 'DELETE' }),
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { api, setAccessToken } from '$lib/api';
const PUBLIC_ROUTES = ['/login'];
onMount(async () => {
if (PUBLIC_ROUTES.includes($page.url.pathname)) return;
// Attempt silent token refresh via HttpOnly cookie before rendering
try {
const res = await api.post('/auth/refresh', undefined, { skipAuth: true });
if (res.ok) {
const data = await res.json() as { accessToken: string };
setAccessToken(data.accessToken);
} else {
goto('/login');
}
} catch {
goto('/login');
}
});
</script>
<slot />

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => goto('/groups'));
</script>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
// TODO: fetch and display owner's characters
</script>
<!-- TODO: Character list -->
<h1>Your Characters</h1>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
// TODO: fetch character by $page.params.id and render via ruleset plugin
</script>
<!-- TODO: Character sheet (web) -->
<h1>Character Sheet</h1>
<p>Character ID: {$page.params.id}</p>

View File

@@ -0,0 +1,6 @@
<script lang="ts">
// TODO: fetch and display user's groups
</script>
<!-- TODO: Group list -->
<h1>Your Groups</h1>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
// TODO: fetch group by $page.params.id
</script>
<!-- TODO: Group detail -->
<h1>Group Detail</h1>
<p>Group ID: {$page.params.id}</p>

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { page } from '$app/stores';
// TODO: DM-only group settings; enforce authorization in layout or server load
</script>
<!-- TODO: Group settings (DM only) -->
<h1>Group Settings</h1>
<p>Group ID: {$page.params.id}</p>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
// TODO: Login/Register form
</script>
<!-- TODO: Login / Register form -->
<h1>Campaign Manager</h1>
<p>Login — coming soon</p>

8
web/svelte.config.js Normal file
View File

@@ -0,0 +1,8 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
export default {
kit: {
adapter: adapter({ fallback: '404.html' }),
},
};

6
web/tsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"strict": true
}
}

6
web/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
});