chore: bootstrap agent-ready docs, rules and Bun workspace
Adds the approved v1 design spec, UI mockups, AGENTS.md (with CLAUDE.md and GEMINI.md pointers), architecture/decisions/todo/permissions docs, agent permission files, and an empty Bun workspaces skeleton. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)",
|
||||
"Bash(git branch --list:*)", "Bash(git rev-parse:*)", "Bash(git add:*)", "Bash(git commit:*)",
|
||||
"Bash(bun install --frozen-lockfile:*)", "Bash(bun test:*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(git push:*)", "Bash(git merge:*)", "Bash(git rebase:*)", "Bash(git switch:*)",
|
||||
"Bash(git restore:*)", "Bash(git commit --amend:*)", "Bash(rm:*)",
|
||||
"Bash(bun add:*)", "Bash(bun remove:*)", "Bash(bun update:*)", "Bash(bun upgrade:*)",
|
||||
"Bash(bunx:*)", "Bash(npx:*)", "Bash(docker:*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force:*)", "Bash(git push -f:*)", "Bash(git push --force-with-lease:*)",
|
||||
"Bash(git reset --hard:*)", "Bash(git clean:*)", "Bash(git add -f:*)", "Bash(git add --force:*)",
|
||||
"Bash(git commit --no-verify:*)", "Bash(git commit -n:*)",
|
||||
"Bash(rm -r:*)", "Bash(rm -R:*)", "Bash(rm -rf:*)", "Bash(rm -fr:*)", "Bash(rm -Rf:*)",
|
||||
"Bash(rm -fR:*)", "Bash(rm --recursive:*)", "Bash(sudo:*)",
|
||||
"Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
sandbox_mode = "workspace-write"
|
||||
approval_policy = "on-request"
|
||||
@@ -0,0 +1,19 @@
|
||||
# Deny
|
||||
prefix_rule(pattern = ["git", "push", ["--force", "-f", "--force-with-lease"]], decision = "forbidden",
|
||||
justification = "Force-push rewrites shared history.")
|
||||
prefix_rule(pattern = ["git", "reset", "--hard"], decision = "forbidden", justification = "Discards work.")
|
||||
prefix_rule(pattern = ["git", "clean"], decision = "forbidden", justification = "Deletes untracked files.")
|
||||
prefix_rule(pattern = ["git", "add", ["-f", "--force"]], decision = "forbidden",
|
||||
justification = "Stages ignored files, e.g. secrets.")
|
||||
prefix_rule(pattern = ["git", "commit", ["--no-verify", "-n"]], decision = "forbidden", justification = "Skips hooks.")
|
||||
prefix_rule(pattern = ["rm", ["-r", "-R", "-rf", "-fr", "-Rf", "-fR", "--recursive"]], decision = "forbidden",
|
||||
justification = "Recursive delete.")
|
||||
prefix_rule(pattern = ["sudo"], decision = "forbidden", justification = "No privilege escalation.")
|
||||
# Ask
|
||||
prefix_rule(pattern = ["git", ["push", "merge", "rebase", "switch", "restore"]], decision = "prompt")
|
||||
prefix_rule(pattern = ["git", "commit", "--amend"], decision = "prompt")
|
||||
prefix_rule(pattern = ["rm"], decision = "prompt")
|
||||
prefix_rule(pattern = ["bun", ["add", "remove", "update", "upgrade"]], decision = "prompt")
|
||||
prefix_rule(pattern = ["bunx"], decision = "prompt")
|
||||
prefix_rule(pattern = ["npx"], decision = "prompt")
|
||||
prefix_rule(pattern = ["docker"], decision = "prompt")
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"tools": {
|
||||
"allowed": [
|
||||
"run_shell_command(git status)", "run_shell_command(git diff)", "run_shell_command(git log)",
|
||||
"run_shell_command(git show)", "run_shell_command(git branch --list)",
|
||||
"run_shell_command(git add)", "run_shell_command(git commit)",
|
||||
"run_shell_command(bun install --frozen-lockfile)", "run_shell_command(bun test)"
|
||||
],
|
||||
"exclude": [
|
||||
"run_shell_command(git push --force)", "run_shell_command(git push -f)",
|
||||
"run_shell_command(git push --force-with-lease)", "run_shell_command(git reset --hard)",
|
||||
"run_shell_command(git clean)", "run_shell_command(git add -f)", "run_shell_command(git add --force)",
|
||||
"run_shell_command(git commit --no-verify)", "run_shell_command(git commit -n)",
|
||||
"run_shell_command(rm -r)", "run_shell_command(rm -R)", "run_shell_command(rm -rf)",
|
||||
"run_shell_command(rm -fr)", "run_shell_command(rm -Rf)", "run_shell_command(sudo)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
coverage/
|
||||
|
||||
# local data (SQLite db, attachments, backups, snapshots)
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# agent scratch
|
||||
CLAUDE.local.md
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
.claude/settings.local.json
|
||||
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,99 @@
|
||||
# STTracker
|
||||
|
||||
Self-hosted meta media tracker: the user's own permanent record of books, games, film/TV, manga, podcasts
|
||||
and music, pulled from Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm and more. Bun 1.3, TypeScript 5,
|
||||
React, Kysely over SQLite (`bun:sqlite`) or Postgres (`Bun.sql`). Bun workspaces.
|
||||
|
||||
`AGENTS.md` is the single source of truth for agents here. `CLAUDE.md` and `GEMINI.md` only point here.
|
||||
Deeper context on demand: `docs/ARCHITECTURE.md`, `docs/DECISIONS.md`, `docs/TODO.md`.
|
||||
Product spec: `docs/specs/001-sttracker-v1-design.md`. Read it before any feature work.
|
||||
|
||||
## Commands
|
||||
|
||||
Install: `bun install --frozen-lockfile`
|
||||
Test: `bun test` | Single test: TODO(maintainer): confirm `bun test <path>` once tests exist
|
||||
Typecheck: TODO(maintainer): confirm `bun run typecheck` (fails until source files exist)
|
||||
Lint: TODO(maintainer): confirm once a linter is chosen
|
||||
Dev / Build: TODO(maintainer): confirm once `apps/server` exists
|
||||
|
||||
## Workflow
|
||||
|
||||
Required skills: `superpowers`, `impeccable`. Check at session start; install if missing.
|
||||
Specs → `docs/specs/NNN-slug.md` (permanent). Plans → `docs/plans/NNN-slug.md` while in progress;
|
||||
`git rm` them before opening a merge request. The spec is the record.
|
||||
(Overrides the superpowers default paths.)
|
||||
|
||||
Anything beyond a trivial fix: brainstorm → spec (sign-off) → plan (sign-off) → TDD → review.
|
||||
TDD means failing test first, watch it fail, minimal code, watch it pass, commit.
|
||||
Tick plan checkboxes as you go so an interrupted session is resumable.
|
||||
Trivial changes (typo, version bump, one-line config): say "trivial, skipping spec" and proceed.
|
||||
|
||||
Use impeccable for user-facing UI: `craft` for new UI, `audit`/`polish` on existing UI,
|
||||
`harden` before shipping. Design context: `PRODUCT.md`, `DESIGN.md`, `docs/design/mockups/`.
|
||||
|
||||
## Rules
|
||||
|
||||
- Bun-native only:
|
||||
- `Bun.serve()` with HTML imports for the web app. No Express, no Vite.
|
||||
- `bun:sqlite` and `Bun.sql`. No `better-sqlite3`, `pg` or `postgres.js`.
|
||||
- `bun test`. No Jest or Vitest.
|
||||
- `Bun.password`, `Bun.file`.
|
||||
- Bun loads `.env` itself. No dotenv.
|
||||
- Domain code queries through Kysely from `packages/db`. Dialect-specific SQL lives only in `packages/db`.
|
||||
Every DB-touching test must pass on both SQLite and Postgres.
|
||||
- Syncs never overwrite or delete user data directly. Source records go through the change engine.
|
||||
It applies a value only if `field_sources` says the user never edited the field; anything else
|
||||
becomes a Changes-inbox event. Deletes are soft (`deleted_at`).
|
||||
- Plugins (`plugins/*`) import only `@sttracker/plugin-sdk`. They never import `packages/db` or core
|
||||
internals, and they return `SourceRecord`s instead of writing.
|
||||
- `packages/*` and `plugins/*` never import from `apps/*`.
|
||||
- IDs are ULIDs generated in app code. Ratings are integers 0–100. Dates carry a precision
|
||||
(`day | month | year | unknown`).
|
||||
- Tests never call live APIs. Plugin tests use recorded fixtures through the SDK test harness.
|
||||
- Write-back (`push`) is opt-in per connection and off by default. No plugin pushes in Phase 1.
|
||||
|
||||
## Architecture
|
||||
|
||||
apps/server -> Bun.serve: REST API `/api/v1`, `/ingest/<plugin>`, job worker, serves the web app
|
||||
apps/web -> React app via HTML imports
|
||||
packages/core -> domain services: entries, matching, change engine, history, export
|
||||
packages/db -> Kysely setup, SQLite/Postgres dialects, migrations
|
||||
packages/plugin-sdk -> plugin contract, SourceRecord types, http/rate-limit helpers, test harness
|
||||
plugins/<id> -> one source integration each
|
||||
|
||||
Full picture: `docs/ARCHITECTURE.md`. Read it before your first change in a session.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Never touch: `docs/design/mockups/` (reference only), `node_modules/`, `data/`.
|
||||
Never commit `.env`, secrets, API keys, or recorded fixtures containing real tokens.
|
||||
Never force-push, rewrite pushed history, or commit directly to `main`.
|
||||
|
||||
Ask first: new runtime dependencies · public API, export schema (`sttracker-export/v1`) or
|
||||
migration changes after release · auth, token and credential-encryption code · anything destructive
|
||||
(`rm -rf`, `git reset --hard`, DB resets) · work the approved plan does not cover.
|
||||
|
||||
If the task turns out much larger than the plan assumed: stop, report, re-plan.
|
||||
|
||||
## Docs duties
|
||||
|
||||
Same commit as the code, not "later":
|
||||
- `docs/ARCHITECTURE.md`: when a module, boundary, or data flow changes.
|
||||
- `docs/DECISIONS.md`: append-only; any choice a future maintainer would reverse-engineer.
|
||||
- `docs/TODO.md`: anything noticed and deliberately not fixed. No `TODO` comments in source.
|
||||
- `docs/PERMISSIONS.md`: together with `.claude/settings.json`, `.codex/` and `.gemini/settings.json`, in one commit.
|
||||
|
||||
## Git
|
||||
|
||||
Branches: `feat|fix|chore/<slug>`. Commits: Conventional Commits. Keep them small, each leaving tests green.
|
||||
|
||||
## Done means verified
|
||||
|
||||
Do not report success on work you have not run. Before saying done, all of these must hold:
|
||||
- tests run *in this session* and seen passing, on both databases for DB code;
|
||||
- typecheck clean;
|
||||
- matches the approved spec;
|
||||
- docs duties complete;
|
||||
- `git status` clean and the diff is only what you intended.
|
||||
|
||||
"It should work" is not verification.
|
||||
@@ -0,0 +1,9 @@
|
||||
# CLAUDE.md
|
||||
|
||||
**`AGENTS.md` is the single source of truth for this repository.** Read it now and follow it exactly.
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
This file exists only so Claude Code finds the rules, and for Claude-specific configuration that
|
||||
AGENTS.md cannot express. It contains no project rules of its own. Add those to `AGENTS.md`.
|
||||
If anything here ever conflicts with `AGENTS.md`, `AGENTS.md` wins.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Design
|
||||
|
||||
Reference mockups: `docs/design/mockups/round2-app-scrapbook-river.html`. These were approved by the owner on 2026-10-30.
|
||||
Parked theme ideas (shelf, card catalogue): `round1-seven-directions.html`.
|
||||
|
||||
## App (everyday use): "Hardcover-style"
|
||||
Dark, calm, dense. Three panes on desktop (nav · library list · entry detail); stacks on mobile.
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--surface-0` | `#17152b` | sidebar |
|
||||
| `--surface-1` | `#1e1b36` | page |
|
||||
| `--surface-2` | `#262247` | boxes in detail |
|
||||
| `--surface-3` | `#2d2852` | selected row, pills, tabs |
|
||||
| `--text` | `#ece9ff` | primary text |
|
||||
| `--text-muted` | `#a9a3d6` | meta text |
|
||||
| `--text-faint` | `#8d87c2` | box headings, placeholders |
|
||||
| `--accent` | `#6d5dfc` | active tab, in-progress status |
|
||||
| `--rating` | `#fbbf24` | stars, logo, draft badge |
|
||||
| `--warn` | `#f97316` | source-change warnings and the Changes count only |
|
||||
| `--ok` | `#34d399` | source synced |
|
||||
|
||||
- Type: Inter for the UI; Gelasio for long-form review text.
|
||||
- Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers.
|
||||
- Orange means "a platform changed something". Never use it decoratively.
|
||||
- Drafts (entries with no catalogue link) get a small amber outlined "Draft" badge and a striped placeholder cover.
|
||||
|
||||
## Media type colours (shared by both surfaces)
|
||||
| Type | Colour |
|
||||
|---|---|
|
||||
| Books | `#6b3fa0` |
|
||||
| Audio/podcasts | `#2f6b58` |
|
||||
| Games | `#c1652f` |
|
||||
| Film & TV | `#344b7a` |
|
||||
| Music | `#a33b62` |
|
||||
|
||||
## Public page: "scrapbook + river" (Phase 2)
|
||||
- **Scrapbook:**
|
||||
- Kraft background `#d8c3a0`. Polaroid cards `#fffdf7` with tape, captions in Caveat, body in Gelasio.
|
||||
- Note cards `#fffbe6`, ticket cards `#efe0bd` with a dashed border.
|
||||
- A grid underneath, with slight rotations of ±2° at most. Must work on phones.
|
||||
- **River:**
|
||||
- Navy `#142033`. Instrument Serif headings, Instrument Sans body.
|
||||
- Streams per media type in soft tones: books `#b9a6f2`, audio `#ffd27a`, games `#f2a1a1`, film/TV `#9ad0c2`. Stream width is time spent.
|
||||
- Covers sit on streams. Threads are dotted white curves with an italic serif label.
|
||||
@@ -0,0 +1,7 @@
|
||||
# GEMINI.md
|
||||
|
||||
**`AGENTS.md` is the single source of truth for this repository.** Read `./AGENTS.md` before doing
|
||||
anything else and follow it exactly.
|
||||
|
||||
This file exists only so Gemini CLI finds the rules. It contains no project rules of its own. Add
|
||||
those to `AGENTS.md`. If anything here ever conflicts with `AGENTS.md`, `AGENTS.md` wins.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Product
|
||||
|
||||
**STTracker** is a self-hosted meta tracker for everything a person reads, watches, plays and listens to.
|
||||
It is the user's own permanent record. Platforms (Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm…) are sources it watches, not the source of truth.
|
||||
Full spec: `docs/specs/001-sttracker-v1-design.md`.
|
||||
|
||||
## Who it's for
|
||||
1. The owner, self-hosting for themselves: currently juggling several trackers, spreadsheets and Obsidian notes.
|
||||
2. A few friends on the same instance.
|
||||
3. Maybe, later, a small public instance.
|
||||
|
||||
## What it must feel like
|
||||
- **The user is in control.** They never conform to the data. A title alone is a valid entry. No sync silently overwrites what they wrote.
|
||||
- **One interface for all media.** Books, games, shows and music use the same features; a media type is just a label.
|
||||
- **About stories and things you love,** not an admin stats dashboard.
|
||||
|
||||
## Two surfaces
|
||||
- **The app:** everyday library, logging, sources, and a Changes inbox. Calm, dense, Hardcover-like.
|
||||
- **The public page:** a shareable, personal page with a scrapbook layout and a river timeline view. Phase 2.
|
||||
|
||||
## Voice
|
||||
Plain, warm, direct. Sentence case. Name things by what users understand: "Changes", "Sources", "Owned", "Quick add", "Draft". Never "records", "connectors" or "webhooks" in user-facing copy.
|
||||
Errors say what happened and how to fix it, without apologising.
|
||||
@@ -0,0 +1,6 @@
|
||||
# STTracker
|
||||
|
||||
A self-hosted meta tracker for everything you read, watch, play and listen to. It keeps your own permanent copy of your data from Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm and more.
|
||||
|
||||
- Product spec: [`docs/specs/001-sttracker-v1-design.md`](docs/specs/001-sttracker-v1-design.md)
|
||||
- Contributing (humans and agents): [`AGENTS.md`](AGENTS.md)
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sttracker",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
||||
|
||||
"@types/node/undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Architecture
|
||||
|
||||
How STTracker is built today. Update in the same commit as any structural change.
|
||||
|
||||
## Overview
|
||||
Status 2026-10-30: only the workspace skeleton exists (root `package.json`, `tsconfig.json`).
|
||||
The target design is in `docs/specs/001-sttracker-v1-design.md` §11. Replace each "planned" marker below as the code lands.
|
||||
|
||||
One Bun process serves the REST API, the React web app and plugin ingest endpoints, and runs a
|
||||
database-backed job worker. Source plugins pull data from platforms and return normalised `SourceRecord`s.
|
||||
The core snapshots them, diffs against the previous run, matches them to works, and either applies them to the
|
||||
user layer or files a Changes-inbox event.
|
||||
|
||||
## Map
|
||||
| Path | Responsibility | State |
|
||||
|---|---|---|
|
||||
| `apps/server` | `Bun.serve`: `/api/v1`, `/ingest/<plugin>`, job worker, static web | planned |
|
||||
| `apps/web` | React app (HTML imports) | planned |
|
||||
| `packages/core` | domain services: entries, matching, change engine, history, export | planned |
|
||||
| `packages/db` | Kysely, SQLite/Postgres dialects, migrations | planned |
|
||||
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
|
||||
| `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
|
||||
| `docs/design/mockups` | approved UI mockups (reference only) | exists |
|
||||
|
||||
## Data flow
|
||||
Planned sync lifecycle:
|
||||
1. The scheduler enqueues a job.
|
||||
2. The worker loads the plugin and calls `pull` or `pullChanges` with an SDK context.
|
||||
3. The plugin returns `SourceRecord[]`.
|
||||
4. The core stores a new `source_record_versions` row for each changed hash and diffs the records into `change_events`.
|
||||
5. Each event is either auto-applied (new record, or a field never edited by the user) or sent to the inbox.
|
||||
6. Every user-layer write appends to `user_history`.
|
||||
|
||||
## External dependencies
|
||||
- Database: SQLite file under `DATA_DIR` (default) or Postgres via `DATABASE_URL`. If unavailable, nothing works.
|
||||
- Platform APIs (Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm). If unavailable, only that source's sync fails; the run is logged and retried.
|
||||
|
||||
## Invariants
|
||||
- A sync never overwrites or hard-deletes user-entered data (spec §8.3).
|
||||
- Plugins never touch the database.
|
||||
- All domain behaviour is identical on SQLite and Postgres.
|
||||
|
||||
## Known rough edges
|
||||
None yet. See `docs/TODO.md`.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Decisions
|
||||
|
||||
Append-only. Never edit or delete an entry. Supersede it with a new one that links back.
|
||||
One entry per decision a future maintainer would otherwise have to reverse-engineer.
|
||||
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
|
||||
|
||||
## 2026-10-30: Product design decisions recorded in spec 001
|
||||
**Status:** accepted
|
||||
**Context:** Brainstorming with the owner produced the v1 design.
|
||||
**Decision:** See `docs/specs/001-sttracker-v1-design.md` §13 for the designer's calls. Owner decisions:
|
||||
- Phase 1 is read-only.
|
||||
- Write-back is opt-in per connection.
|
||||
- Conflicts between sources go to the Changes inbox.
|
||||
- Backloggd waits for its official export.
|
||||
- The app UI is Hardcover-style; the public page is scrapbook + river.
|
||||
**Alternatives:** Recorded in the spec.
|
||||
**Consequences:** Phase 1 has no `push` implementations.
|
||||
|
||||
## 2026-10-30: Bun workspaces monorepo with Kysely for both databases
|
||||
**Status:** accepted
|
||||
**Context:** The owner requires Bun with SQLite and Postgres options.
|
||||
**Decision:**
|
||||
- Bun workspaces (`apps/*`, `packages/*`, `plugins/*`).
|
||||
- Kysely is the single query layer, with portable migrations.
|
||||
- SQLite runs through `bun:sqlite` and Postgres through `Bun.sql`.
|
||||
|
||||
**Alternatives:**
|
||||
- Drizzle needs a separate schema per dialect.
|
||||
- Prisma does not fit Bun-native drivers.
|
||||
- Raw SQL would duplicate every query per dialect.
|
||||
|
||||
**Consequences:** We may need a small custom Kysely dialect for `Bun.sql` or `bun:sqlite`. Every DB test runs on both databases.
|
||||
|
||||
## 2026-10-30: Adopt AGENTS.md as single source of truth
|
||||
**Status:** accepted
|
||||
**Context:** Agents had no durable project context and no consistent workflow between sessions.
|
||||
**Decision:**
|
||||
- `AGENTS.md` is canonical; `CLAUDE.md` and `GEMINI.md` are pointers.
|
||||
- Specs are permanent in `docs/specs/`.
|
||||
- Plans live in `docs/plans/` only while work is in progress and are removed before the merge request.
|
||||
- Both locations override the superpowers default paths.
|
||||
|
||||
**Alternatives:**
|
||||
- Per-tool rule files: they drift and create synchronisation debt.
|
||||
- No rules file: agents re-explore the repo every session, which is measurably slower and costs more tokens.
|
||||
|
||||
**Consequences:** Rule changes happen in one file, and the pointer files must stay thin. Plans are working
|
||||
artefacts: kept after merge, they go stale and contradict the code, so the spec is the record.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Agent permissions
|
||||
|
||||
Source of truth for what AI coding agents may run here. The tool files are renderings of this page:
|
||||
`.claude/settings.json`, `.codex/config.toml`, `.codex/rules/project.rules`, `.gemini/settings.json`.
|
||||
Change this page and every tool file in the same commit.
|
||||
|
||||
## Verified commands
|
||||
|
||||
Run on 2026-10-30. Only `worked` rows may appear in a tier, a tool file, or `AGENTS.md` Commands.
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `bun install --frozen-lockfile` | worked |
|
||||
| `bun test` | worked (runner starts; reports "0 test files" until tests exist) |
|
||||
| `bun run typecheck` | failed: TS18003, no source files yet |
|
||||
| `bunx tsc --noEmit -p tsconfig.json` | failed: same as above |
|
||||
|
||||
TODO(maintainer): confirm every row above that is not `worked` once Phase 1 code lands.
|
||||
|
||||
## Tiers
|
||||
|
||||
| Tier | Commands | Why |
|
||||
|---|---|---|
|
||||
| Allow | `git status/diff/log/show/branch --list/rev-parse/add/commit`, `bun install --frozen-lockfile`, `bun test` | Inspection and the verified dev loop; no prompt needed. |
|
||||
| Ask | `git push/merge/rebase/switch/restore`, `git commit --amend`, `rm`, `bun add/remove/update/upgrade`, `bunx`, `npx`, `docker` | Leaves the machine, rewrites work, changes dependencies, or downloads and runs code. A human decides. |
|
||||
| Deny | force pushes, `git reset --hard`, `git clean`, `git add -f`, `git commit --no-verify/-n`, recursive `rm`, `sudo`; reading `.env`, `.env.*`, `secrets/**` | Destroys work, skips checks, or exposes secrets. |
|
||||
|
||||
## Known gaps
|
||||
- Allowlists save prompts; they are not a security boundary. Use the tool's sandbox for isolation.
|
||||
- Prefix rules are bypassable: `git -C dir push`, flags placed later (`git push origin main --force`),
|
||||
and `+refspec` pushes. Claude Code's `:*` matches at a word boundary, so each flag cluster must be
|
||||
listed. That is why `git clean` is denied whole and `rm` always asks.
|
||||
- Allowed test commands run code from the repo. An agent that can edit `package.json` or the tests
|
||||
can run anything through them.
|
||||
- Denying reads of `.env` does not stop a script that opens it itself, and git can print staged or
|
||||
committed secrets. `git add -f` is denied so an agent cannot stage an ignored secret.
|
||||
- Codex: `approval_policy` in `.codex/config.toml` can loosen a stricter user setting. Codex has no `allow` rules here,
|
||||
because in Codex `allow` means "run outside the sandbox". Codex cannot deny file reads.
|
||||
- Gemini: `tools.exclude` is deprecated but is the only project-level deny. Gemini has no Ask tier and cannot deny file reads.
|
||||
@@ -0,0 +1,26 @@
|
||||
# TODO
|
||||
|
||||
Durable backlog. Agents append here instead of leaving `TODO` comments in source.
|
||||
Small, specific, independently actionable. Remove only when actually done.
|
||||
|
||||
## Now
|
||||
- [ ] Choose a linter/formatter (e.g. Biome), then add `lint` to `package.json`, `AGENTS.md` and `docs/PERMISSIONS.md`.
|
||||
- [ ] Add CI on Gitea Actions: install, typecheck, and test on SQLite and Postgres. Add a guard that fails if `AGENTS.md` is over 150 lines.
|
||||
|
||||
## Next
|
||||
- [ ] Phase 2 and Phase 3 plans (spec §12).
|
||||
|
||||
## Someday
|
||||
|
||||
## Recurring
|
||||
- [ ] Maintain `AGENTS.md`:
|
||||
- **Add a rule only in response to a real failure.** When an agent repeatedly gets something wrong,
|
||||
that is a gap; add one line, ideally with a code example. Do not pre-empt hypothetical mistakes.
|
||||
- **Update in the same PR as the convention change.** A stale rules file is worse than none, because
|
||||
it actively misleads.
|
||||
- **Prune quarterly.** Delete rules whose convention has changed or whose mistake no longer occurs.
|
||||
- **Optional, cheap validation:** run the same task twice in clean sessions, with and without the
|
||||
file, and compare steps and token use.
|
||||
|
||||
## Known issues
|
||||
- [ ] `bun run typecheck` fails with TS18003 until source files exist under `apps/`, `packages/` or `plugins/`.
|
||||
@@ -0,0 +1,293 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>STTracker — style round 1</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&family=Archivo+Narrow:wght@500;700&family=Caveat:wght@500;700&family=Cutive+Mono&family=Gelasio:ital,wght@0,400;0,600;1,400&family=Instrument+Sans:wght@400;600&family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;700&family=Libre+Caslon+Text:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;600;800&family=Work+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #e7e9ec; font-family: system-ui, sans-serif; color: #222; padding: 32px 24px 80px; }
|
||||
.intro { max-width: 1100px; margin: 0 auto 28px; }
|
||||
.intro h1 { font-size: 1.5rem; margin-bottom: .4rem; }
|
||||
.intro p { color: #555; max-width: 70ch; line-height: 1.5; }
|
||||
.style { max-width: 1100px; margin: 0 auto 40px; }
|
||||
.style-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
|
||||
.style-head h2 { font-size: 1.1rem; }
|
||||
.style-head p { color: #555; font-size: .9rem; flex: 1; }
|
||||
.pick { border: 1px solid #999; background: #fff; border-radius: 6px; padding: 6px 14px; cursor: pointer; font: inherit; font-size: .85rem; }
|
||||
.pick.selected { background: #2b5cd6; color: #fff; border-color: #2b5cd6; }
|
||||
.frame { border-radius: 10px; overflow: hidden; box-shadow: 0 1px 0 #0002, 0 8px 30px #0001; }
|
||||
|
||||
/* ---------- A: Card catalogue ---------- */
|
||||
.a { background: #41566b; padding: 28px; font-family: "Libre Caslon Text", serif; }
|
||||
.a-top { display: flex; justify-content: space-between; align-items: end; color: #dfe7ee; margin-bottom: 20px; }
|
||||
.a-top h3 { font-size: 1.9rem; font-weight: 400; }
|
||||
.a-top h3 i { opacity: .7; }
|
||||
.a-top span { font-size: .85rem; opacity: .8; }
|
||||
.a-drawer { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
.a-card { background: #fdfdfb; padding: 14px 16px 18px; min-height: 190px; position: relative;
|
||||
background-image: linear-gradient(#c7d3e6 1px, transparent 1px); background-size: 100% 22px; background-position: 0 44px;
|
||||
border-top: 3px solid #c0392b; box-shadow: 0 3px 0 #0003; }
|
||||
.a-card .call { font-family: "Cutive Mono", monospace; font-size: .75rem; color: #7a1f14; }
|
||||
.a-card h4 { font-size: 1.02rem; margin: 6px 0 4px; line-height: 22px; }
|
||||
.a-card .by { font-style: italic; font-size: .85rem; line-height: 22px; color: #444; }
|
||||
.a-card .note { font-family: "Cutive Mono", monospace; font-size: .78rem; line-height: 22px; color: #234; margin-top: 4px; }
|
||||
.a-card .stamp { position: absolute; right: 12px; bottom: 10px; border: 2px solid #2d6a4f; color: #2d6a4f; font-family: "Cutive Mono", monospace; font-size: .72rem; padding: 2px 6px; transform: rotate(-6deg); }
|
||||
.a-log { margin-top: 18px; background: #fdfdfb; padding: 12px 16px; font-family: "Cutive Mono", monospace; font-size: .82rem; color: #223; }
|
||||
.a-log div { display: grid; grid-template-columns: 110px 1fr 90px 80px; padding: 5px 0; border-bottom: 1px dotted #b8c4d4; }
|
||||
.a-log div:last-child { border: 0; }
|
||||
|
||||
/* ---------- B: The shelf ---------- */
|
||||
.b { background: #d9ddd2; padding: 28px 32px 0; font-family: "Archivo", sans-serif; color: #1d2b22; }
|
||||
.b-top { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.b-top h3 { font-weight: 800; font-size: 2.2rem; letter-spacing: -.02em; }
|
||||
.b-top p { font-size: .9rem; max-width: 36ch; text-align: right; }
|
||||
.b-shelf { display: flex; align-items: flex-end; gap: 4px; height: 270px; margin-top: 18px; padding: 0 8px; border-bottom: 14px solid #5b4632; }
|
||||
.b-sp { writing-mode: vertical-rl; transform: rotate(180deg); font-family: "Archivo Narrow", sans-serif; font-weight: 700; display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-radius: 2px 2px 0 0; color: #fff; font-size: .95rem; }
|
||||
.b-sp small { font-weight: 500; font-size: .7rem; opacity: .85; }
|
||||
.b-sp.leaning { transform: rotate(180deg) translateX(-6px) skewY(-6deg); }
|
||||
.b-box { width: 150px; height: 190px; background: #8a2c2c; color: #fff; padding: 12px; display: flex; flex-direction: column; justify-content: space-between; margin-left: 10px; font-family: "Archivo Narrow"; font-weight: 700; }
|
||||
.b-legend { display: flex; gap: 22px; padding: 14px 8px 22px; font-size: .82rem; }
|
||||
.b-legend span::before { content: ""; display: inline-block; width: 10px; height: 10px; margin-right: 6px; vertical-align: -1px; background: var(--c); }
|
||||
|
||||
/* ---------- C: Scrapbook ---------- */
|
||||
.c { background: #c9a97c url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='.9'/%3E%3C/filter%3E%3Crect width='80' height='80' filter='url(%23n)' opacity='.08'/%3E%3C/svg%3E"); padding: 30px 36px 40px; font-family: "Gelasio", serif; color: #2a1f14; position: relative; min-height: 470px; }
|
||||
.c h3 { font-family: "Caveat", cursive; font-size: 2.8rem; font-weight: 700; line-height: 1; }
|
||||
.c .sub { font-style: italic; margin-bottom: 18px; }
|
||||
.c-board { position: relative; height: 340px; }
|
||||
.c-pol { position: absolute; background: #fff; padding: 10px 10px 34px; width: 190px; box-shadow: 0 4px 10px #0003; }
|
||||
.c-pol .img { height: 150px; }
|
||||
.c-pol .cap { font-family: "Caveat", cursive; font-size: 1.35rem; position: absolute; bottom: 4px; left: 12px; right: 10px; line-height: 1; }
|
||||
.c-tape { position: absolute; width: 70px; height: 22px; background: #f3eec3cc; top: -10px; left: 60px; transform: rotate(-4deg); }
|
||||
.c-note { position: absolute; background: #fffbe0; padding: 14px 16px; width: 250px; font-family: "Caveat", cursive; font-size: 1.3rem; line-height: 1.15; box-shadow: 0 3px 8px #0002; }
|
||||
.c-ticket { position: absolute; background: #e9d3a6; border: 2px dashed #8a6b3d; padding: 10px 14px; font-size: .85rem; width: 220px; }
|
||||
|
||||
/* ---------- D: River (timeline across media) ---------- */
|
||||
.d { background: #172338; color: #e9edf5; padding: 30px 36px; font-family: "Instrument Sans", sans-serif; }
|
||||
.d h3 { font-family: "Instrument Serif", serif; font-weight: 400; font-size: 2.6rem; line-height: 1.05; }
|
||||
.d .sub { color: #9fb0cc; margin: 6px 0 22px; }
|
||||
.d-river { display: grid; grid-template-columns: 70px 1fr; gap: 0 18px; }
|
||||
.d-month { font-family: "Instrument Serif"; font-size: 1.3rem; color: #9fb0cc; padding-top: 8px; }
|
||||
.d-row { display: flex; gap: 10px; flex-wrap: wrap; padding: 8px 0 16px; border-left: 1px solid #33476a; padding-left: 18px; position: relative; }
|
||||
.d-chip { border-radius: 999px; padding: 8px 14px 8px 10px; display: flex; gap: 10px; align-items: center; background: #22314d; font-size: .9rem; }
|
||||
.d-chip b { font-weight: 600; }
|
||||
.d-dot { width: 26px; height: 26px; border-radius: 50%; background: var(--c); display: grid; place-items: center; color: #172338; font-size: .7rem; font-weight: 700; }
|
||||
.d-chip em { font-style: normal; color: #ffd27a; }
|
||||
.d-thread { margin-top: 18px; background: #22314d; border-radius: 14px; padding: 16px 18px; display: flex; gap: 14px; align-items: center; }
|
||||
.d-thread p { color: #9fb0cc; font-size: .9rem; }
|
||||
.d-thread .line { flex: 1; height: 2px; background: linear-gradient(90deg, #f2a1a1, #9ad0c2, #b9a6f2, #ffd27a); }
|
||||
|
||||
/* ---------- E: Hardcover-inspired ---------- */
|
||||
.e { background: #1e1b36; color: #ece9ff; font-family: "Inter", sans-serif; display: grid; grid-template-columns: 200px 1fr; min-height: 470px; }
|
||||
.e-side { background: #17152b; padding: 22px 16px; font-size: .9rem; }
|
||||
.e-side .logo { font-weight: 700; font-size: 1.1rem; margin-bottom: 22px; color: #fbbf24; }
|
||||
.e-side div { padding: 8px 10px; border-radius: 8px; color: #b8b3e0; }
|
||||
.e-side div.on { background: #2d2852; color: #fff; }
|
||||
.e-main { padding: 24px 28px; }
|
||||
.e-main h3 { font-size: 1.4rem; margin-bottom: 4px; }
|
||||
.e-main .sub { color: #a9a3d6; font-size: .9rem; margin-bottom: 18px; }
|
||||
.e-tabs { display: flex; gap: 8px; margin-bottom: 18px; }
|
||||
.e-tabs span { padding: 6px 12px; border-radius: 999px; background: #2d2852; font-size: .82rem; color: #c9c4f0; }
|
||||
.e-tabs span.on { background: #6d5dfc; color: #fff; }
|
||||
.e-row { display: grid; grid-template-columns: 56px 1fr 120px 110px; gap: 16px; align-items: center; padding: 10px 0; border-bottom: 1px solid #2d2852; }
|
||||
.e-cover { width: 56px; height: 80px; border-radius: 4px; }
|
||||
.e-row h4 { font-size: .98rem; }
|
||||
.e-row .meta { color: #a9a3d6; font-size: .82rem; margin-top: 3px; }
|
||||
.e-stars { color: #fbbf24; letter-spacing: 2px; }
|
||||
.e-status { font-size: .8rem; background: #2d2852; padding: 4px 10px; border-radius: 999px; text-align: center; }
|
||||
.e-series { background: #2d2852; border-radius: 10px; padding: 12px 14px; margin-bottom: 14px; display: flex; justify-content: space-between; align-items: center; font-size: .9rem; }
|
||||
|
||||
/* ---------- F: Letterboxd-inspired ---------- */
|
||||
.f { background: #14181c; color: #9ab; font-family: "Work Sans", sans-serif; padding: 0 0 26px; }
|
||||
.f-nav { background: #0f1215; padding: 14px 32px; display: flex; gap: 22px; align-items: center; font-size: .85rem; }
|
||||
.f-nav .logo { display: flex; gap: 4px; margin-right: 10px; }
|
||||
.f-nav .logo i { width: 14px; height: 14px; border-radius: 50%; display: block; }
|
||||
.f-prof { display: flex; gap: 18px; align-items: center; padding: 22px 32px; }
|
||||
.f-av { width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(135deg, #556677, #2c3440); }
|
||||
.f-prof h3 { color: #fff; font-size: 1.5rem; }
|
||||
.f-stats { margin-left: auto; display: flex; gap: 22px; text-align: center; }
|
||||
.f-stats b { display: block; color: #fff; font-size: 1.3rem; }
|
||||
.f-stats span { font-size: .75rem; }
|
||||
.f-sec { padding: 0 32px; }
|
||||
.f-sec h4 { font-weight: 500; font-size: .85rem; border-bottom: 1px solid #2c3440; padding-bottom: 6px; margin: 10px 0 12px; }
|
||||
.f-posters { display: flex; gap: 10px; }
|
||||
.f-p { width: 128px; height: 190px; border-radius: 4px; box-shadow: inset 0 0 0 1px #fff2; position: relative; display: flex; align-items: end; padding: 8px; color: #fff; font-size: .78rem; font-weight: 700; }
|
||||
.f-p small { position: absolute; top: 6px; left: 6px; background: #000a; padding: 2px 6px; border-radius: 3px; font-weight: 500; font-size: .65rem; }
|
||||
.f-diary { display: grid; grid-template-columns: 70px 1fr 110px 40px; gap: 10px; font-size: .85rem; padding: 6px 0; border-bottom: 1px solid #2c3440; align-items: center; }
|
||||
.f-diary b { color: #fff; }
|
||||
.f-g { color: #00e054; }
|
||||
|
||||
/* ---------- G: Trakt-inspired ---------- */
|
||||
.g { background: #161616; color: #eee; font-family: "Montserrat", sans-serif; }
|
||||
.g-hero { height: 150px; background: linear-gradient(120deg, #9f1239cc, #6d28d9cc), linear-gradient(0deg, #222, #444); display: flex; align-items: end; padding: 18px 28px; gap: 18px; }
|
||||
.g-hero .av { width: 78px; height: 78px; border-radius: 50%; border: 3px solid #fff; background: #333; }
|
||||
.g-hero h3 { font-size: 1.6rem; font-weight: 800; }
|
||||
.g-hero p { font-size: .82rem; opacity: .9; }
|
||||
.g-body { display: grid; grid-template-columns: 1.3fr 1fr; gap: 22px; padding: 22px 28px 28px; }
|
||||
.g-card { background: #222; border-radius: 6px; padding: 14px 16px; }
|
||||
.g-card h4 { font-size: .8rem; color: #ed2224; margin-bottom: 10px; font-weight: 800; }
|
||||
.g-stat { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
.g-stat div b { font-size: 1.5rem; display: block; }
|
||||
.g-stat div span { font-size: .72rem; color: #aaa; }
|
||||
.g-prog { margin-top: 10px; }
|
||||
.g-prog .t { display: flex; justify-content: space-between; font-size: .82rem; margin-bottom: 4px; }
|
||||
.g-bar { height: 6px; background: #333; border-radius: 3px; overflow: hidden; margin-bottom: 10px; }
|
||||
.g-bar i { display: block; height: 100%; background: linear-gradient(90deg, #ed2224, #9f42c6); }
|
||||
.g-hist { display: grid; grid-template-columns: 46px 1fr 70px; gap: 10px; align-items: center; padding: 7px 0; border-bottom: 1px solid #2d2d2d; font-size: .82rem; }
|
||||
.g-hist .th { width: 46px; height: 30px; border-radius: 3px; }
|
||||
.g-hist span { color: #aaa; font-size: .75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="intro">
|
||||
<h1>Style round 1: seven directions</h1>
|
||||
<p>Each mockup shows the same content: your shareable page with favourites from four kinds of media, plus recent activity. A–D are new looks. E–G borrow from Hardcover, Letterboxd and Trakt. Mark the ones you like (more than one is fine), then tell me in the terminal what works and what doesn't in each.</p>
|
||||
</div>
|
||||
|
||||
<!-- A -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>A. Card catalogue</h2><p>Every entry is an index card in your own library. Personal, archival, a little bookish.</p><button class="pick" data-choice="a-catalogue" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame a">
|
||||
<div class="a-top"><h3>The collection of <i>Lucas W.</i></h3><span>1,284 entries · catalogued since 2014</span></div>
|
||||
<div class="a-drawer">
|
||||
<div class="a-card"><div class="call">BK · 823.92 LEI</div><h4>From the Library of Jurgen Leitner</h4><div class="by">book, read twice</div><div class="note">"Don't read it in the dark."</div><div class="stamp">★ 5 / 5</div></div>
|
||||
<div class="a-card"><div class="call">AU · 791.44 MAG</div><h4>The Magnus Archives</h4><div class="by">podcast, 200 episodes</div><div class="note">Series rated separately</div><div class="stamp">★ 5 / 5</div></div>
|
||||
<div class="a-card"><div class="call">GM · 794.8 VOI</div><h4>Voices of the Void</h4><div class="by">game, 41 h played</div><div class="note">Bought: itch.io, 2023</div><div class="stamp">★ 4 / 5</div></div>
|
||||
<div class="a-card"><div class="call">TV · 791.45 TWI</div><h4>The Twilight Zone</h4><div class="by">series, 1959</div><div class="note">Watching s3, slowly</div><div class="stamp" style="border-color:#7a5b10;color:#7a5b10">IN PROGRESS</div></div>
|
||||
</div>
|
||||
<div class="a-log">
|
||||
<div><span>28 Oct 2026</span><span>Annihilation — finished</span><span>book</span><span>★★★★½</span></div>
|
||||
<div><span>26 Oct 2026</span><span>Outer Wilds — 3rd playthrough</span><span>game</span><span>★★★★★</span></div>
|
||||
<div><span>25 Oct 2026</span><span>Severance s2e04</span><span>tv</span><span>—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>B. The shelf</h2><p>Everything sits as a spine on one shelf, whatever the medium. Colour shows the kind of media; width shows how long it took.</p><button class="pick" data-choice="b-shelf" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame b">
|
||||
<div class="b-top"><h3>Lucas's shelf</h3><p>Things that stayed with me. Books, games, shows and records on one shelf.</p></div>
|
||||
<div class="b-shelf">
|
||||
<div class="b-sp" style="--w:46px;width:46px;height:230px;background:#6b3fa0">From the Library of Jurgen Leitner <small>2025</small></div>
|
||||
<div class="b-sp" style="width:78px;height:250px;background:#2f6b58">The Magnus Archives <small>200 ep</small></div>
|
||||
<div class="b-sp" style="width:58px;height:205px;background:#c1652f">Voices of the Void <small>41 h</small></div>
|
||||
<div class="b-sp" style="width:64px;height:240px;background:#344b7a">The Twilight Zone <small>s1–s3</small></div>
|
||||
<div class="b-sp" style="width:40px;height:185px;background:#6b3fa0">Annihilation <small>2026</small></div>
|
||||
<div class="b-sp leaning" style="width:52px;height:215px;background:#c1652f">Outer Wilds <small>3×</small></div>
|
||||
<div class="b-sp" style="width:36px;height:200px;background:#a33b62">Lateralus <small>LP</small></div>
|
||||
<div class="b-sp" style="width:60px;height:225px;background:#344b7a">Severance <small>s2</small></div>
|
||||
<div class="b-box"><span>Box set</span><span style="font-size:1.1rem">Silent Hill, everything<br><small style="font-weight:500">12 entries, my order</small></span></div>
|
||||
</div>
|
||||
<div class="b-legend"><span style="--c:#6b3fa0">Books</span><span style="--c:#2f6b58">Audio</span><span style="--c:#c1652f">Games</span><span style="--c:#344b7a">Film & TV</span><span style="--c:#a33b62">Music</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- C -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>C. Scrapbook</h2><p>A personal page that feels made by hand: your screenshots, notes and tickets pinned up. Leans hard into "my page, my way."</p><button class="pick" data-choice="c-scrapbook" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame c">
|
||||
<h3>stuff that got under my skin</h3>
|
||||
<p class="sub">Lucas W. — updated last Tuesday</p>
|
||||
<div class="c-board">
|
||||
<div class="c-pol" style="left:0;top:10px;transform:rotate(-4deg)"><div class="c-tape"></div><div class="img" style="background:linear-gradient(160deg,#1b2a1f,#4f6b3a 60%,#c9d18a)"></div><div class="cap">Voices of the Void, night 12</div></div>
|
||||
<div class="c-pol" style="left:220px;top:40px;transform:rotate(3deg)"><div class="c-tape" style="left:40px;transform:rotate(6deg)"></div><div class="img" style="background:radial-gradient(circle at 40% 40%,#e0e0e0,#3a3a3a 70%)"></div><div class="cap">Twilight Zone, s1e8 ★★★★★</div></div>
|
||||
<div class="c-note" style="left:450px;top:0;transform:rotate(-2deg)">The Magnus Archives —<br>finished all 200 episodes. The series gets 5 stars even though season 2 dragged. Episodes rated on their own.</div>
|
||||
<div class="c-ticket" style="left:470px;top:170px;transform:rotate(2deg)"><b>From the Library of Jurgen Leitner</b><br>Bought: Waterstones, Leeds, £14<br>Read twice · ★★★★★</div>
|
||||
<div class="c-pol" style="left:760px;top:30px;transform:rotate(5deg);width:170px"><div class="c-tape" style="left:50px"></div><div class="img" style="height:130px;background:linear-gradient(200deg,#f3a35c,#5c2d6e)"></div><div class="cap">Outer Wilds, 3rd time</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- D -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>D. River</h2><p>Time is the organiser. Everything you consumed flows by month, and threads link related things across media.</p><button class="pick" data-choice="d-river" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame d">
|
||||
<h3>Lucas, the last three months</h3>
|
||||
<p class="sub">43 entries across 5 kinds of media</p>
|
||||
<div class="d-river">
|
||||
<div class="d-month">Oct</div>
|
||||
<div class="d-row">
|
||||
<div class="d-chip"><span class="d-dot" style="--c:#b9a6f2">BK</span><b>Annihilation</b><em>★ 4.5</em></div>
|
||||
<div class="d-chip"><span class="d-dot" style="--c:#f2a1a1">GM</span><b>Outer Wilds</b><span>3rd run</span><em>★ 5</em></div>
|
||||
<div class="d-chip"><span class="d-dot" style="--c:#9ad0c2">TV</span><b>Severance</b><span>s2 · 4 ep</span></div>
|
||||
</div>
|
||||
<div class="d-month">Sep</div>
|
||||
<div class="d-row">
|
||||
<div class="d-chip"><span class="d-dot" style="--c:#ffd27a">AU</span><b>The Magnus Archives</b><span>finished</span><em>★ 5</em></div>
|
||||
<div class="d-chip"><span class="d-dot" style="--c:#b9a6f2">BK</span><b>From the Library of Jurgen Leitner</b><em>★ 5</em></div>
|
||||
</div>
|
||||
<div class="d-month">Aug</div>
|
||||
<div class="d-row">
|
||||
<div class="d-chip"><span class="d-dot" style="--c:#f2a1a1">GM</span><b>Voices of the Void</b><span>41 h</span><em>★ 4</em></div>
|
||||
<div class="d-chip"><span class="d-dot" style="--c:#9ad0c2">TV</span><b>The Twilight Zone</b><span>s1–s3</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-thread"><p>Thread: <b style="color:#fff">"Cosmic dread"</b></p><div class="line"></div><p>Leitner → Magnus Archives → Voices of the Void → Twilight Zone</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- E -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>E. Hardcover-inspired</h2><p>Hardcover's dark, calm library app, stretched to every kind of media. Shelves by status, series rated as a whole.</p><button class="pick" data-choice="e-hardcover" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame e">
|
||||
<div class="e-side"><div class="logo">STTracker</div><div class="on">Library</div><div>Log</div><div>Lists</div><div>Owned</div><div>Sources</div><div>Changes</div></div>
|
||||
<div class="e-main">
|
||||
<h3>Library</h3><p class="sub">1,284 entries · 6 sources connected · last sync 4 min ago</p>
|
||||
<div class="e-tabs"><span class="on">All media</span><span>Books</span><span>Games</span><span>Film & TV</span><span>Audio</span><span>Music</span></div>
|
||||
<div class="e-series"><span><b>The Magnus Archives</b> — series · 200 of 200 episodes</span><span class="e-stars">★★★★★</span></div>
|
||||
<div class="e-row"><div class="e-cover" style="background:linear-gradient(#6b3fa0,#2a1747)"></div><div><h4>From the Library of Jurgen Leitner</h4><div class="meta">Book · Hardcover · read twice</div></div><div class="e-stars">★★★★★</div><div class="e-status">Read</div></div>
|
||||
<div class="e-row"><div class="e-cover" style="background:linear-gradient(#c1652f,#3d1d0c)"></div><div><h4>Voices of the Void</h4><div class="meta">Game · Steam · 41 h</div></div><div class="e-stars">★★★★☆</div><div class="e-status">Played</div></div>
|
||||
<div class="e-row"><div class="e-cover" style="background:linear-gradient(#344b7a,#111a2e)"></div><div><h4>The Twilight Zone</h4><div class="meta">Show · Trakt · season 3 of 5</div></div><div class="e-stars" style="opacity:.35">☆☆☆☆☆</div><div class="e-status" style="background:#6d5dfc">Watching</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- F -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>F. Letterboxd-inspired</h2><p>Poster-first profile with favourites up top and a diary underneath. Very shareable; covers carry the page.</p><button class="pick" data-choice="f-letterboxd" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame f">
|
||||
<div class="f-nav"><span class="logo"><i style="background:#ff8000"></i><i style="background:#00e054"></i><i style="background:#40bcf4"></i></span><span>Profile</span><span>Diary</span><span>Lists</span><span>Library</span></div>
|
||||
<div class="f-prof"><div class="f-av"></div><div><h3>Lucas W.</h3><span>Reads, plays, watches, listens.</span></div><div class="f-stats"><div><b>312</b><span>books</span></div><div><b>188</b><span>games</span></div><div><b>640</b><span>films & shows</span></div><div><b>14k</b><span>tracks</span></div></div></div>
|
||||
<div class="f-sec"><h4>Favourites</h4>
|
||||
<div class="f-posters">
|
||||
<div class="f-p" style="background:linear-gradient(#6b3fa0,#1d0f30)"><small>BOOK</small>From the Library of Jurgen Leitner</div>
|
||||
<div class="f-p" style="background:linear-gradient(#2f6b58,#0d211b)"><small>PODCAST</small>The Magnus Archives</div>
|
||||
<div class="f-p" style="background:linear-gradient(#c1652f,#2b1306)"><small>GAME</small>Voices of the Void</div>
|
||||
<div class="f-p" style="background:linear-gradient(#344b7a,#0c1324)"><small>SERIES</small>The Twilight Zone</div>
|
||||
</div>
|
||||
<h4>Diary</h4>
|
||||
<div class="f-diary"><span>OCT 28</span><b>Annihilation</b><span class="f-g">★★★★½</span><span>♥</span></div>
|
||||
<div class="f-diary"><span>OCT 26</span><b>Outer Wilds <span style="font-weight:400;color:#9ab">· replay</span></b><span class="f-g">★★★★★</span><span>♥</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- G -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>G. Trakt-inspired</h2><p>Stats-heavy dashboard with progress bars, totals and history. Great for "what am I in the middle of", less personal.</p><button class="pick" data-choice="g-trakt" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame g">
|
||||
<div class="g-hero"><div class="av"></div><div><h3>Lucas W.</h3><p>Joined 2014 · 6 sources connected</p></div></div>
|
||||
<div class="g-body">
|
||||
<div>
|
||||
<div class="g-card"><h4>This year</h4><div class="g-stat"><div><b>38</b><span>books</span></div><div><b>612 h</b><span>played</span></div><div><b>214</b><span>episodes</span></div><div><b>9.1k</b><span>scrobbles</span></div></div></div>
|
||||
<div class="g-card" style="margin-top:14px"><h4>In progress</h4>
|
||||
<div class="g-prog"><div class="t"><span>The Twilight Zone</span><span>58%</span></div><div class="g-bar"><i style="width:58%"></i></div>
|
||||
<div class="t"><span>Severance s2</span><span>4 / 10</span></div><div class="g-bar"><i style="width:40%"></i></div>
|
||||
<div class="t"><span>Children of Time</span><span>p. 212 / 600</span></div><div class="g-bar"><i style="width:35%"></i></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="g-card"><h4>History</h4>
|
||||
<div class="g-hist"><div class="th" style="background:#6b3fa0"></div><div>Annihilation<br><span>finished · book</span></div><span>2 d ago</span></div>
|
||||
<div class="g-hist"><div class="th" style="background:#c1652f"></div><div>Outer Wilds<br><span>played 3 h · game</span></div><span>4 d ago</span></div>
|
||||
<div class="g-hist"><div class="th" style="background:#344b7a"></div><div>Severance s2e04<br><span>watched · tv</span></div><span>5 d ago</span></div>
|
||||
<div class="g-hist"><div class="th" style="background:#a33b62"></div><div>Tool — Lateralus<br><span>full album · music</span></div><span>5 d ago</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>STTracker — style round 2</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Gelasio:ital,wght@0,400;0,600;1,400&family=Instrument+Sans:wght@400;600&family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #e7e9ec; font-family: system-ui, sans-serif; color: #222; padding: 32px 24px 80px; }
|
||||
.intro { max-width: 1160px; margin: 0 auto 28px; }
|
||||
.intro h1 { font-size: 1.5rem; margin-bottom: .4rem; }
|
||||
.intro p { color: #555; max-width: 75ch; line-height: 1.5; }
|
||||
.style { max-width: 1160px; margin: 0 auto 44px; }
|
||||
.style-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
|
||||
.style-head h2 { font-size: 1.1rem; white-space: nowrap; }
|
||||
.style-head p { color: #555; font-size: .9rem; flex: 1; }
|
||||
.pick { border: 1px solid #999; background: #fff; border-radius: 6px; padding: 6px 14px; cursor: pointer; font: inherit; font-size: .85rem; }
|
||||
.pick.selected { background: #2b5cd6; color: #fff; border-color: #2b5cd6; }
|
||||
.frame { border-radius: 10px; overflow: hidden; box-shadow: 0 1px 0 #0002, 0 8px 30px #0001; }
|
||||
|
||||
/* ================= 1. App (Hardcover-inspired) ================= */
|
||||
.app { background: #1e1b36; color: #ece9ff; font-family: "Inter", sans-serif; display: grid; grid-template-columns: 190px 360px 1fr; min-height: 640px; font-size: .9rem; }
|
||||
.app-side { background: #17152b; padding: 20px 14px; }
|
||||
.app-side .logo { font-weight: 700; font-size: 1.05rem; margin: 0 8px 20px; color: #fbbf24; }
|
||||
.app-side .nav { padding: 8px 10px; border-radius: 8px; color: #b8b3e0; display: flex; justify-content: space-between; }
|
||||
.app-side .nav.on { background: #2d2852; color: #fff; }
|
||||
.app-side .nav em { font-style: normal; background: #f97316; color: #fff; border-radius: 999px; font-size: .72rem; padding: 1px 7px; }
|
||||
.app-side .grp { color: #6f69a3; font-size: .78rem; margin: 18px 10px 6px; }
|
||||
.app-list { border-right: 1px solid #2d2852; padding: 18px 0; }
|
||||
.app-search { margin: 0 16px 12px; background: #2d2852; border-radius: 8px; padding: 9px 12px; color: #8d87c2; }
|
||||
.app-quick { margin: 0 16px 14px; border: 1px dashed #4b4485; border-radius: 8px; padding: 9px 12px; color: #c9c4f0; }
|
||||
.app-quick b { color: #fbbf24; font-weight: 600; }
|
||||
.app-item { display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center; padding: 9px 16px; }
|
||||
.app-item.on { background: #2d2852; }
|
||||
.app-item .cv { width: 40px; height: 56px; border-radius: 3px; }
|
||||
.app-item h4 { font-size: .88rem; font-weight: 600; }
|
||||
.app-item .m { color: #a9a3d6; font-size: .76rem; margin-top: 2px; }
|
||||
.app-item .flag { width: 8px; height: 8px; border-radius: 50%; background: #f97316; }
|
||||
.app-item .draft { font-size: .7rem; color: #fbbf24; border: 1px solid #fbbf2466; border-radius: 4px; padding: 1px 5px; }
|
||||
.app-detail { padding: 22px 26px; }
|
||||
.det-head { display: grid; grid-template-columns: 110px 1fr; gap: 20px; }
|
||||
.det-head .cv { width: 110px; height: 160px; border-radius: 5px; background: linear-gradient(160deg, #6b3fa0, #1d0f30); box-shadow: 0 6px 20px #0006; }
|
||||
.det-head h3 { font-size: 1.45rem; line-height: 1.2; }
|
||||
.det-head .m { color: #a9a3d6; margin: 4px 0 12px; }
|
||||
.stars { color: #fbbf24; letter-spacing: 2px; font-size: 1.05rem; }
|
||||
.pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.pills span { background: #2d2852; padding: 3px 10px; border-radius: 999px; font-size: .76rem; color: #c9c4f0; }
|
||||
.alert { margin: 18px 0 14px; background: #3a2a1a; border: 1px solid #f9731666; border-radius: 10px; padding: 11px 14px; display: flex; gap: 12px; align-items: center; }
|
||||
.alert p { flex: 1; color: #fde2c8; font-size: .85rem; line-height: 1.4; }
|
||||
.alert button { background: #f97316; border: 0; color: #fff; border-radius: 6px; padding: 6px 12px; font: inherit; font-size: .8rem; }
|
||||
.det-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.box { background: #262247; border-radius: 10px; padding: 12px 14px; }
|
||||
.box h5 { font-size: .78rem; color: #8d87c2; font-weight: 600; margin-bottom: 8px; }
|
||||
.box .r { display: flex; justify-content: space-between; padding: 4px 0; font-size: .84rem; }
|
||||
.box .r span:first-child { color: #a9a3d6; }
|
||||
.thumbs { display: flex; gap: 6px; }
|
||||
.thumbs div { width: 58px; height: 44px; border-radius: 4px; }
|
||||
.src { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: .84rem; }
|
||||
.src i { width: 8px; height: 8px; border-radius: 50%; background: #34d399; }
|
||||
.review { grid-column: 1 / -1; font-family: "Gelasio", serif; font-size: .95rem; line-height: 1.55; color: #e3e0fb; }
|
||||
|
||||
/* ================= 2. Scrapbook v2 ================= */
|
||||
.sb { background: #d8c3a0; color: #2a1f14; font-family: "Gelasio", serif; padding: 34px 40px 44px; }
|
||||
.sb-head { display: grid; grid-template-columns: 1fr auto; align-items: end; border-bottom: 1px solid #2a1f1433; padding-bottom: 14px; margin-bottom: 22px; }
|
||||
.sb-head h3 { font-family: "Caveat", cursive; font-size: 3.2rem; font-weight: 700; line-height: .9; }
|
||||
.sb-head p { font-style: italic; }
|
||||
.sb-head .links { text-align: right; font-size: .85rem; line-height: 1.6; }
|
||||
.sb-grid { display: grid; grid-template-columns: 1.15fr 1fr 1fr; gap: 26px; align-items: start; }
|
||||
.pol { background: #fffdf7; padding: 10px 10px 12px; box-shadow: 0 6px 14px #3b2a1533; position: relative; }
|
||||
.pol .img { aspect-ratio: 4/3; }
|
||||
.pol .cap { font-family: "Caveat", cursive; font-size: 1.35rem; line-height: 1.05; margin-top: 8px; }
|
||||
.pol .meta { font-size: .78rem; color: #6b5640; margin-top: 4px; }
|
||||
.tape { position: absolute; width: 74px; height: 22px; background: #f1ebc4c4; top: -11px; left: calc(50% - 37px); }
|
||||
.card-n { background: #fffbe6; padding: 16px 18px; box-shadow: 0 4px 10px #3b2a1526; }
|
||||
.card-n h4 { font-size: 1.05rem; margin-bottom: 6px; }
|
||||
.card-n p { font-size: .9rem; line-height: 1.55; }
|
||||
.card-n .sig { font-family: "Caveat"; font-size: 1.3rem; margin-top: 8px; color: #7a3b22; }
|
||||
.tix { background: #efe0bd; border: 1.5px dashed #8a6b3d; padding: 12px 14px; font-size: .84rem; line-height: 1.5; }
|
||||
.tix b { display: block; font-size: .95rem; }
|
||||
.now { margin-top: 26px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.now .lbl { font-family: "Caveat"; font-size: 1.6rem; margin-right: 6px; }
|
||||
.now span.it { background: #fffdf7; padding: 6px 12px; font-size: .85rem; box-shadow: 0 2px 5px #3b2a1522; }
|
||||
|
||||
/* ================= 3. River v2 ================= */
|
||||
.rv { background: #142033; color: #e9edf5; font-family: "Instrument Sans", sans-serif; padding: 30px 34px 26px; }
|
||||
.rv-head { display: flex; justify-content: space-between; align-items: end; margin-bottom: 14px; }
|
||||
.rv h3 { font-family: "Instrument Serif", serif; font-weight: 400; font-size: 2.6rem; line-height: 1; }
|
||||
.rv-head p { color: #9fb0cc; max-width: 46ch; text-align: right; font-size: .9rem; line-height: 1.45; }
|
||||
.rv-wrap { position: relative; }
|
||||
.rv svg { display: block; width: 100%; height: auto; }
|
||||
.rv-cov { position: absolute; width: 46px; height: 64px; border-radius: 4px; box-shadow: 0 0 0 2px #142033, 0 6px 14px #0008; display: flex; align-items: end; justify-content: center; font-size: .6rem; color: #fff; padding-bottom: 3px; }
|
||||
.rv-pop { position: absolute; background: #f3f5fa; color: #142033; border-radius: 10px; padding: 12px 14px; width: 250px; box-shadow: 0 12px 30px #0009; font-size: .84rem; line-height: 1.45; }
|
||||
.rv-pop b { font-size: .98rem; display: block; }
|
||||
.rv-pop .st { color: #b7791f; }
|
||||
.rv-pop .q { font-family: "Instrument Serif"; font-style: italic; font-size: 1.05rem; margin-top: 6px; }
|
||||
.rv-legend { display: flex; gap: 20px; font-size: .82rem; color: #9fb0cc; margin-top: 10px; }
|
||||
.rv-legend span::before { content: ""; display: inline-block; width: 18px; height: 6px; border-radius: 3px; margin-right: 7px; vertical-align: 2px; background: var(--c); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="intro">
|
||||
<h1>Style round 2: the app, and two public pages</h1>
|
||||
<p><b>1</b> is the everyday app, a fuller Hardcover-style take showing a real entry: your reads, where you bought it, screenshots, linked sources, and a warning that a platform changed something. <b>2</b> and <b>3</b> are the shareable public page: a tidier scrapbook, and a river that actually does something. In the river, the card for the purple book is shown open, as it would appear when you hover over or tap a cover.</p>
|
||||
</div>
|
||||
|
||||
<!-- 1 -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>1. The app</h2><p>Library on the left, entry on the right. Quick entries without a source show as drafts you can fix later.</p><button class="pick" data-choice="app-v2" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame app">
|
||||
<div class="app-side">
|
||||
<div class="logo">STTracker</div>
|
||||
<div class="nav on">Library</div><div class="nav">Log</div><div class="nav">Lists</div><div class="nav">Owned</div>
|
||||
<div class="nav">Changes <em>3</em></div>
|
||||
<div class="grp">Sources</div>
|
||||
<div class="nav">Hardcover</div><div class="nav">Trakt</div><div class="nav">MangaBaka</div><div class="nav">ListenBrainz</div><div class="nav">Steam (owned)</div>
|
||||
<div class="grp">Your page</div>
|
||||
<div class="nav">Edit public page</div>
|
||||
</div>
|
||||
<div class="app-list">
|
||||
<div class="app-search">Search 1,284 entries…</div>
|
||||
<div class="app-quick"><b>+</b> Quick add: type a title, sort it out later</div>
|
||||
<div class="app-item on"><div class="cv" style="background:linear-gradient(#6b3fa0,#1d0f30)"></div><div><h4>From the Library of Jurgen Leitner</h4><div class="m">Book · read twice</div></div><span class="flag" title="Changed on Hardcover"></span></div>
|
||||
<div class="app-item"><div class="cv" style="background:linear-gradient(#2f6b58,#0d211b)"></div><div><h4>The Magnus Archives</h4><div class="m">Podcast series · 200 ep</div></div><span class="stars" style="font-size:.75rem">★★★★★</span></div>
|
||||
<div class="app-item"><div class="cv" style="background:repeating-linear-gradient(45deg,#3a356b,#3a356b 6px,#2d2852 6px,#2d2852 12px)"></div><div><h4>that weird ARG youtube series</h4><div class="m">Video · added from phone</div></div><span class="draft">Draft</span></div>
|
||||
<div class="app-item"><div class="cv" style="background:linear-gradient(#c1652f,#2b1306)"></div><div><h4>Voices of the Void</h4><div class="m">Game · 41 h</div></div><span class="stars" style="font-size:.75rem">★★★★☆</span></div>
|
||||
<div class="app-item"><div class="cv" style="background:linear-gradient(#344b7a,#0c1324)"></div><div><h4>The Twilight Zone</h4><div class="m">Show · season 3 of 5</div></div><span class="flag" style="background:#6d5dfc"></span></div>
|
||||
<div class="app-item"><div class="cv" style="background:linear-gradient(#7a6b3f,#2a2412)"></div><div><h4>Annihilation</h4><div class="m">Book · Southern Reach #1</div></div><span class="stars" style="font-size:.75rem">★★★★½</span></div>
|
||||
</div>
|
||||
<div class="app-detail">
|
||||
<div class="det-head">
|
||||
<div class="cv"></div>
|
||||
<div>
|
||||
<h3>From the Library of Jurgen Leitner</h3>
|
||||
<div class="m">Book · Read twice · last finished 14 Sep 2026</div>
|
||||
<div class="stars">★★★★★</div>
|
||||
<div class="pills"><span>cosmic horror</span><span>re-read</span><span>Recommended by: Sam</span><span>Mood: 3am</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert"><p><b>Hardcover changed this book.</b> On 12 Oct it was merged into another edition. Your rating, reads and review here are unchanged.</p><button>See what changed</button></div>
|
||||
<div class="det-grid">
|
||||
<div class="box"><h5>Reads</h5><div class="r"><span>2nd read</span><span>2 Sep – 14 Sep 2026</span></div><div class="r"><span>1st read</span><span>Mar 2025 (no exact date)</span></div></div>
|
||||
<div class="box"><h5>Your copy</h5><div class="r"><span>Bought</span><span>Waterstones, Leeds</span></div><div class="r"><span>When / price</span><span>Feb 2025 · £14</span></div><div class="r"><span>Format</span><span>Hardback</span></div></div>
|
||||
<div class="box"><h5>Attachments</h5><div class="thumbs"><div style="background:linear-gradient(135deg,#5a4b3a,#a08a6a)"></div><div style="background:linear-gradient(135deg,#222,#666)"></div><div style="background:linear-gradient(135deg,#3b2f5c,#8a7bc0)"></div></div></div>
|
||||
<div class="box"><h5>Linked sources</h5><div class="src"><i></i>Hardcover · synced 4 min ago</div><div class="src"><i style="background:#6f69a3"></i>Obsidian note · Books/Leitner.md</div></div>
|
||||
<div class="box review"><h5 style="font-family:Inter">Your review</h5>Better the second time. Knowing what the books do makes every throwaway line land differently. Don't read it in the dark.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2 -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>2. Scrapbook, tidied</h2><p>Same hand-made feel, but on a grid, so it holds up with lots of entries and reads well on a phone.</p><button class="pick" data-choice="scrapbook-v2" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame sb">
|
||||
<div class="sb-head">
|
||||
<div><h3>stuff that got under my skin</h3><p>Lucas W. Books, games, shows, sounds.</p></div>
|
||||
<div class="links">Everything I've logged<br>Lists · Cosmic dread · Comfort games</div>
|
||||
</div>
|
||||
<div class="sb-grid">
|
||||
<div>
|
||||
<div class="pol" style="transform:rotate(-1.5deg)"><div class="tape"></div><div class="img" style="background:linear-gradient(160deg,#1b2a1f,#4f6b3a 60%,#c9d18a)"></div><div class="cap">Voices of the Void, night 12. Something knocked.</div><div class="meta">Game · 41 h · ★★★★☆ · my screenshot</div></div>
|
||||
<div class="tix" style="margin-top:22px;transform:rotate(1deg)"><b>From the Library of Jurgen Leitner</b>Bought at Waterstones, Leeds, Feb 2025<br>Read twice · ★★★★★</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card-n" style="transform:rotate(1deg)"><h4>The Magnus Archives</h4><p>All 200 episodes. The series gets five stars even though season 2 dragged; the ending earns it. Episodes are rated on their own if you want to argue.</p><div class="sig">★★★★★ the whole thing</div></div>
|
||||
<div class="pol" style="margin-top:24px;transform:rotate(-2deg)"><div class="tape" style="transform:rotate(4deg)"></div><div class="img" style="background:radial-gradient(circle at 40% 40%,#e0e0e0,#3a3a3a 70%)"></div><div class="cap">Twilight Zone, "Time Enough at Last"</div><div class="meta">Episode · ★★★★★</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="pol" style="transform:rotate(2deg)"><div class="tape"></div><div class="img" style="background:linear-gradient(200deg,#f3a35c,#5c2d6e)"></div><div class="cap">Outer Wilds, 3rd time and I still cried at the end</div><div class="meta">Game · ★★★★★</div></div>
|
||||
<div class="card-n" style="margin-top:22px;transform:rotate(-1deg)"><h4>If you liked these…</h4><p>Leitner → Magnus Archives → Voices of the Void → Twilight Zone. One long thread of cosmic dread across four media.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="now"><span class="lbl">right now:</span><span class="it">📖 Children of Time, p. 212</span><span class="it">📺 Severance s2e05</span><span class="it">🎧 Lateralus, on repeat</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3 -->
|
||||
<section class="style">
|
||||
<div class="style-head"><h2>3. River, take two</h2><p>Each kind of media is a stream; its width is how much time you spent on it. Covers sit where you finished things, and threads link related things across streams.</p><button class="pick" data-choice="river-v2" onclick="this.classList.toggle('selected')">I like this</button></div>
|
||||
<div class="frame rv">
|
||||
<div class="rv-head"><h3>Lucas, summer into autumn</h3><p>Summer was mostly games. Then September turned into one long, dark read-and-listen.</p></div>
|
||||
<div class="rv-wrap">
|
||||
<svg viewBox="0 0 1090 330" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- month grid -->
|
||||
<g stroke="#2a3a55" stroke-width="1">
|
||||
<line x1="20" y1="20" x2="20" y2="300"/><line x1="285" y1="20" x2="285" y2="300"/><line x1="550" y1="20" x2="550" y2="300"/><line x1="815" y1="20" x2="815" y2="300"/><line x1="1075" y1="20" x2="1075" y2="300"/>
|
||||
</g>
|
||||
<g fill="#6f84a8" font-size="13" font-family="Instrument Sans"><text x="26" y="318">July</text><text x="291" y="318">August</text><text x="556" y="318">September</text><text x="821" y="318">October</text></g>
|
||||
<!-- streams: thickness = time spent -->
|
||||
<path d="M20,60 C150,58 230,55 320,62 S520,40 600,48 S800,70 1075,62 L1075,82 C900,90 760,96 600,92 S420,74 320,70 S120,66 20,66 Z" fill="#b9a6f2" opacity=".85"/>
|
||||
<path d="M20,120 C150,118 300,122 420,124 S540,100 650,98 S760,116 1075,124 L1075,130 C900,134 760,150 650,160 S520,140 420,132 S150,128 20,128 Z" fill="#ffd27a" opacity=".85"/>
|
||||
<path d="M20,170 C120,150 200,140 300,145 S420,160 520,176 S800,186 1075,176 L1075,196 C800,212 640,200 520,192 S400,232 300,236 S120,220 20,210 Z" fill="#f2a1a1" opacity=".85"/>
|
||||
<path d="M20,252 C200,250 380,254 560,252 S800,236 1075,240 L1075,270 C820,276 640,268 560,266 S220,262 20,262 Z" fill="#9ad0c2" opacity=".85"/>
|
||||
<!-- thread -->
|
||||
<path d="M640,64 C660,90 700,100 720,124" stroke="#fff" stroke-width="2" stroke-dasharray="4 5" fill="none"/>
|
||||
<path d="M740,128 C700,170 380,140 360,186" stroke="#fff" stroke-width="2" stroke-dasharray="4 5" fill="none"/>
|
||||
<path d="M352,214 C420,250 860,220 900,248" stroke="#fff" stroke-width="2" stroke-dasharray="4 5" fill="none"/>
|
||||
<g font-family="Instrument Serif" font-style="italic" font-size="17" fill="#fff"><text x="470" y="150">cosmic dread</text></g>
|
||||
</svg>
|
||||
<div class="rv-cov" style="left:610px;top:22px;background:linear-gradient(#6b3fa0,#1d0f30)">Leitner</div>
|
||||
<div class="rv-cov" style="left:715px;top:92px;background:linear-gradient(#2f6b58,#0d211b)">TMA</div>
|
||||
<div class="rv-cov" style="left:330px;top:158px;background:linear-gradient(#c1652f,#2b1306)">VotV</div>
|
||||
<div class="rv-cov" style="left:885px;top:216px;background:linear-gradient(#344b7a,#0c1324)">TZ</div>
|
||||
<div class="rv-cov" style="left:960px;top:30px;background:linear-gradient(#7a6b3f,#2a2412)">Annih.</div>
|
||||
<div class="rv-cov" style="left:200px;top:140px;background:linear-gradient(200deg,#f3a35c,#5c2d6e)">OW</div>
|
||||
<div class="rv-pop" style="left:420px;top:4px"><b>From the Library of Jurgen Leitner</b><span class="st">★★★★★</span> · finished 14 Sep · 2nd read<div class="q">"Better the second time."</div></div>
|
||||
</div>
|
||||
<div class="rv-legend"><span style="--c:#b9a6f2">Books</span><span style="--c:#ffd27a">Podcasts & audio</span><span style="--c:#f2a1a1">Games</span><span style="--c:#9ad0c2">Film & TV</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
# Plans
|
||||
|
||||
Implementation plans from the superpowers writing-plans workflow. **Transient:** a plan lives only on
|
||||
its feature branch and is removed with `git rm` before the merge request is opened. This README stays.
|
||||
|
||||
- Naming: `NNN-short-slug.md`, matching its spec number.
|
||||
- Each plan links back to its spec and has bite-sized checkbox tasks with exact file paths, test commands, and
|
||||
commit checkpoints.
|
||||
- Write it for an engineer with zero context for this codebase. Spell out the obvious.
|
||||
- Tick checkboxes as you go so another agent can resume an interrupted session.
|
||||
- Front matter: `status: draft | approved | in-progress`. There is no `done`, because a finished plan is deleted.
|
||||
@@ -0,0 +1,368 @@
|
||||
---
|
||||
status: approved
|
||||
---
|
||||
|
||||
# STTracker: design
|
||||
|
||||
Date: 2026-10-30
|
||||
Status: approved 2026-10-30
|
||||
|
||||
## 1. Intent
|
||||
|
||||
People track what they read, watch, play and listen to across many platforms (Hardcover, Trakt, Backloggd, MangaBaka, Last.fm/ListenBrainz…), plus spreadsheets and notes apps. Each platform:
|
||||
|
||||
- can silently delete, merge or alter entries;
|
||||
- may have weak or missing export/backup;
|
||||
- has no bridge to the others, so moving between services is manual;
|
||||
- demands a "real" catalogue link (TMDB id, ISBN…) before you can log anything;
|
||||
- only covers one kind of media, with its own arbitrary limits.
|
||||
|
||||
STTracker is a self-hosted **meta-tracker** that sits above all of them. It is the user's own, permanent copy of everything they've consumed and what they thought of it. Platforms become *sources* it pulls from (and sometimes pushes to); none of them is trusted as the record.
|
||||
|
||||
**The one thing it must do well: put the user in control.** The user should never have to conform to the data. Anything a spreadsheet or an Obsidian note could hold, STTracker can hold, and no platform sync may overwrite or drop something the user wrote.
|
||||
|
||||
### Audience
|
||||
|
||||
1. The owner (single user, self-hosted). This is who v1 is for.
|
||||
2. A few friends on the same instance. Multi-user accounts exist from day one.
|
||||
3. Later, possibly a small public instance. Not designed for now, but nothing in v1 should block it.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- **One model for all media.** Books, comics/manga, games, films, shows (seasons, episodes), podcasts/audio dramas, music (artists, albums, tracks) and "other" share one entry model and one set of features: status, rating, review, dates, re-reads/re-plays, lists, tags, custom fields, attachments. A media type is a label, not a separate product.
|
||||
- **Hierarchy where every level is first-class.** A series contains books; a show has seasons and episodes; an album has tracks; a game has DLC. Every level can carry its own rating, review, tags and attachments. A parent's rating is the user's own, not an average (an optional computed average may be *shown* alongside).
|
||||
- **Quick logging without IDs.** A title alone is a valid entry ("draft"). It can be linked to catalogue IDs later, or never.
|
||||
- **Watchdog.** Each source is snapshotted on a schedule. Additions, edits, deletions and merges made *on the platform* are detected and shown in a **Changes** inbox with a clear before/after. Nothing destructive is applied to the user's data automatically.
|
||||
- **Backup and export.** Scheduled full backups. Exports in open formats (JSON, CSV, Markdown) that are easy for people and AI tools to work with.
|
||||
- **Portability.** Move data between services, via write-back where a platform allows it, or via generated import files.
|
||||
- **Private owned library.** What you *own or have access to* (Steam games, a Jellyfin server, physical books) is tracked separately from what you've *consumed*. It is private, never shown on profiles, and used to match plays/reads and to browse for something to start next. Each copy can record where/when it was acquired, price, format and notes.
|
||||
- **Sources as plugins.** Each integration is a self-contained plugin against a small, stable contract, so plugins can be fixed, added or updated without changing the core.
|
||||
- **Usable by other apps.** STTracker has its own API with per-user, per-device tokens / app passwords, so other software (Jellyfin, Navidrome, KOReader, scripts, AI assistants) can read from and report to it.
|
||||
- **A personal public page.** A shareable page of the things that matter to the user (see §7).
|
||||
|
||||
## 3. Non-goals (for now)
|
||||
|
||||
- Social features (following, feeds, comments). Nice-to-have, later.
|
||||
- A public catalogue that competes with TMDB/IGDB/OpenLibrary. STTracker caches metadata it needs; it is not a database for others.
|
||||
- A heavy recommendation engine. Cross-media suggestions start from the user's own links, tags and threads (and possibly AI help), later.
|
||||
- OAuth *provider* (third-party apps signing in with STTracker). App tokens cover v1; OAuth later with the public instance.
|
||||
- Scraping Backloggd. It has no API, is behind bot protection, and its ToS/robots discourage it. Backloggd support waits for its official CSV export.
|
||||
- Writing history *into* Last.fm (the API silently drops anything older than ~14 days).
|
||||
|
||||
## 4. Anti-goals (failure even if it "works")
|
||||
|
||||
- A platform sync silently overwrites or deletes something the user entered.
|
||||
- The user can't log something because there's no catalogue match.
|
||||
- The user's data is stuck: export is lossy, proprietary or incomplete.
|
||||
- Media types feel like separate apps with different features.
|
||||
- The public page looks like an admin stats dashboard instead of something about stories and things the user loves.
|
||||
- Adding or fixing one integration requires touching the core.
|
||||
|
||||
## 5. Constraints
|
||||
|
||||
- **Runtime:** Bun (1.3+). Follow Bun-native conventions: `Bun.serve()` with HTML imports for the frontend (no Vite/Express), `bun:sqlite` / `Bun.sql`, `bun test`, `Bun.password`, `Bun.file`, Bun's automatic `.env` loading.
|
||||
- **Databases:** SQLite (default, zero-config) and PostgreSQL (option). One code path for the domain logic; both dialects tested in CI.
|
||||
- **Frontend:** React.
|
||||
- **Deployment:** Docker image + compose file. All state under one data volume (`/data`: SQLite db if used, attachments, backups, raw snapshots, external plugins).
|
||||
- **Source terms that shape the design:**
|
||||
- Each self-hoster registers their own API credentials for Trakt, Last.fm, IGDB (Twitch), and optionally OAuth apps on Hardcover/MangaBaka. No shared keys are shipped.
|
||||
- Trakt requires the credit line "STTracker uses Trakt but is not endorsed or certified by Trakt." MangaBaka data is CC BY-NC-SA (attribution, non-commercial). IGDB/Last.fm require attribution, non-commercial. Show attributions in the app footer/about page.
|
||||
- Respect rate limits (Hardcover 60/min; MangaBaka 180/min; Trakt 500 GET/5 min, 1 write/s; IGDB 4/s; ListenBrainz ≈1/s with `X-RateLimit-*` headers; Last.fm ≈5/s).
|
||||
- Hardcover's API is beta and may break; its plugin must fail loudly and safely.
|
||||
- **Repo:** Gitea remote `https://gitea.lucaswinther.info/lucasw89/STTracker.git`.
|
||||
|
||||
## 6. Domain model
|
||||
|
||||
All IDs are ULIDs (sortable, generated in app code so both dialects behave the same). All user-facing rows have `created_at`, `updated_at`, and soft-delete `deleted_at`. Ratings are stored as integers 0–100. Display scale (5 stars with halves, 10-point, 100-point) is a user preference.
|
||||
|
||||
### 6.1 Catalogue layer: `works`
|
||||
|
||||
A **work** is a thing that can be consumed: a book, an edition-agnostic series, a game, a show, a season, an episode, an album, a track, a podcast, a manga series, etc.
|
||||
|
||||
- `media_type` comes from a built-in set (book, comic, manga, game, movie, show, season, episode, podcast, podcast_episode, audio_drama, artist, album, track, video, other). Users can add their own types. A user-defined type behaves like `other` but has its own name, icon and default custom fields.
|
||||
- `parent_id` → `works`, forming the hierarchy (series → book; show → season → episode; album → track; game → DLC). A work may also belong to user groupings via lists (§6.5), which are separate from this structural parent.
|
||||
- Display fields: title, original title, year/date, creators (JSON list of `{name, role}`), cover (attachment or remote URL cached locally), description, extra metadata (JSON).
|
||||
- `owner_user_id` is null for shared catalogue works (created from sources) and set for private works a user created by hand (quick add). Private works are visible only to their owner.
|
||||
- `external_ids` table: (`work_id`, `namespace`, `value`), unique on (`namespace`, `value`). Namespaces include `isbn13`, `isbn10`, `asin`, `hardcover_book`, `hardcover_edition`, `tmdb_movie`, `tmdb_show`, `tvdb`, `imdb`, `trakt`, `igdb`, `steam`, `mangabaka`, `anilist`, `mal`, `musicbrainz_recording/release/artist`, etc. Plugins may register new namespaces.
|
||||
- **Work merges** (user-initiated, or confirmed from a suggestion) keep a `merged_into_id` tombstone, so old references and external IDs still resolve.
|
||||
|
||||
#### Who controls the shared catalogue
|
||||
|
||||
Shared works are a **metadata cache**, not user data.
|
||||
- Source plugins may update a shared work's display fields directly. Every change is recorded in `work_history`, so it can be inspected and reverted by an admin.
|
||||
- What a user sees can always be pinned with their own **overrides** (§6.2). Overrides are per user and never touched by syncs.
|
||||
- A platform merge (e.g. Hardcover dedupes a book) never merges shared works on its own. It produces a change event in each affected user's inbox. "Follow the merge" moves *that user's* entry to the target work.
|
||||
- A user merging two works is also per user: their entry moves to the target, and an alias is kept so their old references still resolve. Only an admin can merge shared works globally, which leaves a `merged_into_id` tombstone.
|
||||
- A private draft work stays private. When the user links it to a shared work, their entry moves to the shared work, and the draft is tombstoned and points to it.
|
||||
- User-defined media types are a per-user label stored on the entry (`media_type_override`). Shared works keep a built-in type, defaulting to `other`.
|
||||
- Search shows shared works plus the user's own private works.
|
||||
|
||||
### 6.2 User layer: `entries`
|
||||
|
||||
An **entry** is one user's relationship with one work. It is unique per (user, work).
|
||||
|
||||
- `status`: a small fixed set mapped across all media: planned, in_progress, paused, completed, dropped, plus "none" for owned-but-untouched or rated-only. Each media type can show its own wording ("Want to read", "Watching", "Played"). Users can rename the labels.
|
||||
- `rating` (0–100, nullable), `review` (Markdown), `private_notes` (Markdown), `favorite` flag, `tags`.
|
||||
- `custom_fields` (JSON): values for user-defined fields (§6.6).
|
||||
- `visibility`: private | friends | public. The default is a user preference, initially private.
|
||||
- **Overrides:** a user can override display fields (title, cover, year…) for themselves without changing the shared work.
|
||||
- **Draft:** an entry whose work has no external IDs and no confirmed match is shown as a "Draft" so the user can fix it later. This is a derived state, not a separate table.
|
||||
|
||||
### 6.3 `engagements`: each read, watch or play
|
||||
|
||||
One row per time through: `entry_id`, `started_at`, `finished_at`, `date_precision` (day | month | year | unknown, so "I read this in 2019" is valid), `progress` (JSON: page/percent/chapter/episode/hours), `platform` (free text, e.g. "Switch", "Kindle"), `rating` and `notes` for that particular pass (optional), `source_ref` (which source reported it, if any).
|
||||
|
||||
#### How deep imports go
|
||||
|
||||
- Imports create shared works at every level the source reports, since works are cheap catalogue rows. For example, Trakt episode plays create show, season and episode works.
|
||||
- **Entries** are created only at the level where the source reports user data. A Trakt episode watch creates an episode entry plus an engagement on it. The show gets an entry too, holding the status and rating Trakt reports for the show.
|
||||
- The library list shows top-level entries by default, with children rolled up. For example: "season 3 of 5, 41 episodes watched".
|
||||
- Parent status is never derived automatically. The rollup is shown, and the user decides.
|
||||
- Tracks are the exception: plays stay in `listens` (§6.4), and track or album entries exist only when a source reports a rating/love or the user adds one.
|
||||
|
||||
### 6.4 `listens`
|
||||
|
||||
Music scrobbles are high-volume (hundreds of thousands) and get their own append-only table: `user_id`, `listened_at`, `artist`, `track`, `album`, `duration`, optional MBIDs, `work_id` (nullable, filled in by background matching), `source`, `source_key` (ListenBrainz `listened_at`+`recording_msid`, or Last.fm `ts`+artist+track). Unique on (user, source, source_key). Albums and tracks can still have normal entries for ratings and reviews.
|
||||
|
||||
**Matching listens** only links to existing works, or creates shared artist/album/track works when the listen carries MusicBrainz IDs. Listens with no IDs and no match stay unmatched (`work_id` null). Listens never create draft works. They remain fully visible in Log and in exports either way.
|
||||
|
||||
### 6.5 Lists
|
||||
|
||||
User-created ordered collections of works, mixed media allowed. Each item has an optional note. Fields: name, description, visibility, `kind`:
|
||||
- `list`: a plain list.
|
||||
- `grouping`: the user's own "box set" / reading order, e.g. "Silent Hill, everything" or "my Cosmere order". It can be rated and reviewed like an entry (it has its own entry-like fields).
|
||||
- `thread`: a cross-media chain of related things, e.g. "cosmic dread: Leitner → Magnus Archives → Voices of the Void → Twilight Zone". Threads feed the public page's river view and future recommendations.
|
||||
|
||||
### 6.6 Custom fields
|
||||
|
||||
`custom_field_definitions`: per user, `name`, `type` (text, long text, number, date, boolean, single select, multi select, url, rating), `applies_to` (all media types or a chosen subset), `level` (entry | engagement | copy). Values live in the JSON column of the matching row. Examples: "Recommended by", "Mood", "Translator".
|
||||
|
||||
### 6.7 Owned library: `copies`
|
||||
|
||||
Private to the user, never public. Fields: `user_id`, `work_id` (nullable until matched), `title_hint` (raw title from the source when unmatched), `format` (hardback, ebook, Steam, disc, file on Jellyfin…), `acquired_from`, `acquired_at` (+ precision), `price` + `currency`, `notes`, `custom_fields`, `source_ref`. Copies are linked to entries through the shared work. Library-only sources (Steam, Jellyfin, Kavita…) create copies, not entries.
|
||||
|
||||
### 6.8 Attachments
|
||||
|
||||
Content-addressed files (sha256) stored on disk under `/data/attachments`. The storage backend is behind an interface so S3-compatible storage can be added later. Attachments can be linked to an entry, engagement, copy, list or work (covers). Each has a caption, `taken_at`, and visibility (inherits from its parent by default). Thumbnails are generated for images. Attachments are included in backups.
|
||||
|
||||
### 6.9 History of the user's own data
|
||||
|
||||
Every change to a user-layer row (entries, engagements, copies, lists, custom field values) writes a row to `user_history`: entity, id, before/after JSON diff, actor (user | plugin:<id> | api token:<name>), timestamp. Users can view an entry's history and restore an earlier version. Deletes are soft deletes. This, together with snapshots, is what makes "nothing gets lost" true.
|
||||
|
||||
### 6.10 Users and auth
|
||||
|
||||
- `users`: username, display name, password hash (`Bun.password`, argon2id), role (admin | member), preferences JSON. The first registered user becomes admin. After that, registration is invite-only (admin creates invite links).
|
||||
- Sessions: secure HTTP-only cookie, server-side session table.
|
||||
- **App tokens** (also called "app passwords"): per user, named, scoped (read, write, ingest), revocable, last-used tracking, hashed at rest. Used by the API, plugins' inbound endpoints, scripts and AI tools.
|
||||
|
||||
## 7. Surfaces (UI)
|
||||
|
||||
The mockups from brainstorming (round 2) are the reference. They are in `docs/design/mockups/` (`round2-app-scrapbook-river.html` is the reference; `round1-seven-directions.html` holds the parked themes).
|
||||
|
||||
### 7.1 The app (everyday use), "Hardcover-style"
|
||||
|
||||
Dark, calm, dense. Indigo/near-violet surfaces, amber for ratings, orange reserved for change warnings. Three panes on desktop: navigation sidebar, library list, entry detail. Collapses to a stack on mobile.
|
||||
|
||||
- **Navigation:** Library, Log (chronological engagements and listens), Lists, Owned, Changes (with an unread count), Sources (one item per connection), Your page, Settings.
|
||||
- **Library list:** search, filters (media type, status, rating, tag, source, draft), sort. "Quick add" box at the top: type a title, optionally pick a media type, done. The entry is created as a draft.
|
||||
- **Entry detail:** cover, title, rating, status, tags, custom fields; a change warning when a source changed something; engagements (reads/plays) with dates and precision; your copy (owned library); attachments; linked sources with sync status; review; hierarchy (parent and children, each with its own rating); history.
|
||||
- **Changes inbox:** grouped by source run. Each change shows before/after and actions: accept (apply to my data), ignore, or "keep mine". Merge events show "X was merged into Y on <platform>" with an option to follow the merge.
|
||||
- **Matching:** for drafts and unmatched copies/listens, show candidate works (by external ID, then fuzzy title+year+creator) and let the user confirm, pick another, or keep as-is.
|
||||
- **Sources:** connect/disconnect, credentials, schedule, last run, run now, per-run log, capability toggles (import / watch / write-back).
|
||||
- **Export & backup:** download exports, configure scheduled backups, and restore.
|
||||
|
||||
### 7.2 The public page, "scrapbook + river"
|
||||
|
||||
A shareable URL per user (`/u/<username>`), showing only what the user marks public.
|
||||
|
||||
- **Scrapbook layout (default):** a grid-based collage of the user's chosen highlights. Polaroid-style cards for entries with screenshots/covers and handwritten-style captions, note cards for reviews, "ticket" cards for copies/provenance (only if the user explicitly chooses to show it), thread cards, and a "right now" strip of in-progress entries. Must read well on phones.
|
||||
- **River view:** a timeline where each media type is a stream whose width is time spent (from engagement dates/durations, listens and progress). Covers sit where things were finished, and threads draw links across streams. Tapping/hovering a cover shows its rating, date and a review excerpt.
|
||||
- **The user curates it:** pick highlights, order them, choose captions and which threads to show.
|
||||
- **Themes later:** the "shelf" and "card catalogue" directions from round 1 are parked as optional alternative themes.
|
||||
|
||||
## 8. Sources: the plugin system
|
||||
|
||||
### 8.1 Shape
|
||||
|
||||
A plugin is a folder with a `manifest` and a TypeScript entry module exporting an object that implements the plugin contract from the `@sttracker/plugin-sdk` package. Plugins are loaded at startup from:
|
||||
- `plugins/` in the repo (built-ins, shipped in the image), and
|
||||
- `/data/plugins/` (external plugins dropped in by the instance admin).
|
||||
|
||||
The admin can reload plugins from the UI without restarting the server, by re-importing with a cache-busting query. If hot reload proves unreliable in Bun, a restart is acceptable; it must not need a rebuild of the core.
|
||||
|
||||
Plugins are **trusted code** running in-process. Only the instance admin installs them. Sandboxing is out of scope.
|
||||
|
||||
### 8.2 Contract (capabilities)
|
||||
|
||||
The manifest declares `id`, `name`, `version`, `sdkVersion`, the media types covered, a config/credential schema (rendered as a form in Sources), and which capabilities it implements:
|
||||
|
||||
| Capability | What it does |
|
||||
|---|---|
|
||||
| `pull` | Full fetch of the user's data from the source (backfill and periodic full snapshot). |
|
||||
| `pullChanges` | Incremental fetch using a cursor or "last activity" signal, if the source supports it. |
|
||||
| `push` | Write changes back to the source (status, rating, engagement, list…). |
|
||||
| `ownedLibrary` | Fetch owned/available items, which become `copies`. |
|
||||
| `ingest` | Expose HTTP endpoints under `/ingest/<plugin-id>/…` for other apps to push events (webhooks, ListenBrainz-compatible API, kosync). Authenticated with app tokens. |
|
||||
| `lookup` | Search/resolve catalogue metadata (used by matching). |
|
||||
| `importFile` / `exportFile` | Parse an uploaded file, or generate a file in the source's import format. |
|
||||
|
||||
**Plugins never touch the database.** They receive a context (`http` client with rate limiting, retries and `Retry-After` handling; `log`; `credentials` get/refresh/save; `cursor` get/set; `now`) and return **normalised source records**:
|
||||
|
||||
```
|
||||
SourceRecord {
|
||||
key: string // stable id of this record on the source
|
||||
kind: "entry" | "engagement" | "list" | "listItem" | "copy" | "listen" | "work"
|
||||
raw: unknown // exact payload, stored in the snapshot
|
||||
normalized: { work hints (title, media type, year, creators, external ids, parent hint),
|
||||
entry/engagement/copy/listen fields mapped to the STTracker model }
|
||||
mergedInto?: string // the platform says this record was merged into another
|
||||
deleted?: boolean // the platform reports deletion (else inferred from absence in a full pull)
|
||||
}
|
||||
```
|
||||
|
||||
The core owns everything else: snapshot storage, diffing, change events, matching, applying changes to the user layer, history, scheduling and backups.
|
||||
|
||||
### 8.3 Snapshots and change detection
|
||||
|
||||
- `source_connections`: user, plugin id, config, encrypted credentials (AES-GCM with a key from the `STTRACKER_SECRET` env var), schedule, cursor, status.
|
||||
- `source_records`: (connection, key) → current hash, `first_seen`, `last_seen`, `deleted_at`, `merged_into`, and the link to the STTracker row it maps to.
|
||||
- `source_record_versions`: (record, hash, raw payload compressed, `seen_at`). A new version is stored only when the hash changes.
|
||||
- `source_runs`: one per pull, with counts and a log.
|
||||
- After each run the core diffs against the previous state and emits `change_events`: added, changed (field-level diff), removed (missing from a full pull, or reported deleted), merged.
|
||||
- **Applying changes:**
|
||||
- New records are applied automatically. They create entries, engagements, copies or listens, matched to works.
|
||||
- A change to a field the user has never edited in STTracker is applied automatically and logged.
|
||||
- A change to a field the user has edited, every removal and every merge goes to the Changes inbox as a pending decision. Nothing the user wrote is overwritten or deleted without their say.
|
||||
- Per-field provenance (`field_sources` JSON on user rows: field → "user" | "plugin:<id>@<time>") is what makes this decidable.
|
||||
- **Sources that disagree (owner's decision):** if a field was last set by source A and source B reports a different value, the conflict goes to the Changes inbox with both values. The user picks one, and nothing flips back and forth between runs. When the same engagement is reported by two sources (same work, finish dates within 24 h, or the same exact play timestamp), it is merged into one engagement that records both `source_ref`s, rather than being stored twice.
|
||||
|
||||
### 8.4 Matching
|
||||
|
||||
1. Exact external ID match (any namespace) → link.
|
||||
2. Parent hint + position (e.g. show + season + episode number).
|
||||
3. Fuzzy title/year/creator candidates → suggestion for the user. Never auto-linked.
|
||||
4. Nothing → a private draft work.
|
||||
|
||||
### 8.5 Write-back and migration (Phase 2)
|
||||
|
||||
**Owner's decision: Phase 1 is read-only.** Plugins pull, the app watches for changes, and data is backed up and exported. The `push` capability is defined in the SDK contract from the start so plugins can grow into it, but no plugin implements it and no push UI ships until Phase 2. In Phase 1, migrating to another service is done through exported files.
|
||||
|
||||
From Phase 2, `push` is **opt-in per user and per connection, off by default** (owner's decision), and always explicit:
|
||||
- the user enables write-back per connection, or
|
||||
- runs a "migrate/sync to <source>" action that shows a dry-run preview (what will be created, updated or skipped, and the free-tier limits it will hit) before executing.
|
||||
|
||||
Pushes are logged and idempotent where the target allows. Trakt does not dedupe plays, so the plugin checks first.
|
||||
|
||||
### 8.6 Planned plugins
|
||||
|
||||
**Phase 1:**
|
||||
|
||||
| Plugin | Capabilities | Notes |
|
||||
|---|---|---|
|
||||
| Hardcover | pull, pullChanges, lookup (push in phase 2) | GraphQL. Personal token for single user, OAuth app for multi-user. Use `merged_at`/`original_book_id` and book `canonical_id` for merge detection. Use the `imported: true` flag on bulk writes. |
|
||||
| Trakt | pull, pullChanges (`/sync/last_activities`), lookup (push in phase 2) | OAuth device flow, single-use rotating refresh tokens. Read limits from `/users/settings`. |
|
||||
| MangaBaka | pull, pullChanges (`/v2/my/library/changes`), lookup (push in phase 2) | PAT or OAuth. Key entries by `series_id`. Follow `merged_with`. |
|
||||
| ListenBrainz | pull (official export ZIP for backfill, `/listens` for recent); push (submit-listens, feedback) in phase 2 | Main music source; history migration in is possible. |
|
||||
| Last.fm | pull (read-only archive) | `user.getRecentTracks` paging, loved tracks, tags. No push. |
|
||||
| CSV / JSON | importFile, exportFile | Generic mapping UI for spreadsheets: column → field, saved per file shape. |
|
||||
|
||||
**Phase 2:** Markdown vault (Obsidian/Joplin) importFile/exportFile, with two-way sync as a later step; Libre.fm (best effort); Steam and Jellyfin owned-library; IGDB lookup (used by games and later Backloggd); TMDB lookup; OpenLibrary lookup.
|
||||
|
||||
**Phase 3:** Inbound integrations via `ingest`:
|
||||
- a webhook receiver for Jellyfin, Plex and Emby;
|
||||
- a ListenBrainz-compatible `/1/submit-listens` + `/1/validate-token` (for Navidrome, the Jellyfin ListenBrainz plugin, multi-scrobbler);
|
||||
- a kosync-compatible endpoint for KOReader.
|
||||
|
||||
Also: pull connectors for Kavita, Audiobookshelf, Komga and Calibre-Web(-Automated); Backloggd CSV import once Backloggd ships an export.
|
||||
|
||||
## 9. API
|
||||
|
||||
- REST + JSON under `/api/v1`, documented with an OpenAPI spec generated from route schemas. The same API the web app uses.
|
||||
- Auth: session cookie (web) or `Authorization: Bearer <app token>`.
|
||||
- Stable, documented export schema (`sttracker-export/v1`): a JSON Lines bundle (one file per entity type) + attachments, zipped. This is the backup format, the "give it to an AI" format, and the restore format.
|
||||
- Later: an MCP server exposing read/search/log tools for AI assistants (phase 3).
|
||||
|
||||
## 10. Backups and export
|
||||
|
||||
- **Scheduled backups** (default daily, keep 14) to `/data/backups/`: the export bundle above, plus a database-native dump when on SQLite (`VACUUM INTO`).
|
||||
- **On-demand exports:**
|
||||
- full bundle (JSON);
|
||||
- CSV per entity type;
|
||||
- Markdown (one file per entry with YAML frontmatter; phase 2);
|
||||
- per-target import files (e.g. Goodreads-style CSV for Hardcover/StoryGraph; phase 2).
|
||||
- **The export bundle is per user.** It contains the user's own rows plus every shared work they reference, with external IDs. It excludes credentials, sessions and tokens.
|
||||
- **The instance backup is separate:** a database dump plus attachments plus snapshots. It is only for admins and is restored by replacing the instance.
|
||||
- **Restore:** in Phase 1, load a user bundle into an empty account. Works are re-matched by external ID; anything unmatched becomes a private work. Merging a bundle into an account that already has data is Phase 2.
|
||||
- Raw source snapshots are kept independently of backups. Retention is configurable; by default every distinct version is kept.
|
||||
|
||||
## 11. Architecture
|
||||
|
||||
- One Bun process serves the API, the web app (HTML imports, React) and `/ingest`, and runs the job scheduler.
|
||||
- **Jobs:** a durable job table in the database (works on both dialects), a single in-process worker loop with per-plugin concurrency limits and rate limiting, and retries with backoff. No Redis.
|
||||
- **Database access:** Kysely as the single query layer across both dialects, with Kysely-schema-builder migrations written once (portable types only; JSON stored as text on SQLite and `jsonb` on Postgres behind a helper). Dialects:
|
||||
- SQLite through `bun:sqlite`;
|
||||
- Postgres through `Bun.sql`. Use an existing adapter if a maintained one exists; otherwise write a thin custom dialect (small, well-tested).
|
||||
- **Layout:** Bun workspaces:
|
||||
|
||||
```
|
||||
apps/server Bun.serve: API, ingest, jobs, static web
|
||||
apps/web React app (HTML imports, served by apps/server)
|
||||
packages/core domain model, services (entries, matching, changes, history, export)
|
||||
packages/db Kysely setup, dialects, migrations
|
||||
packages/plugin-sdk plugin contract, types, http/rate-limit helpers, test harness
|
||||
plugins/<id> one folder per built-in plugin
|
||||
docs/ specs, plans, design mockups, ADRs
|
||||
```
|
||||
|
||||
- **Config:** env vars (`DATABASE_URL` = `sqlite:///data/sttracker.db` by default or `postgres://…`; `STTRACKER_SECRET`; `PUBLIC_URL`; `DATA_DIR`).
|
||||
- **Testing:** `bun test`. Every core service is tested against both SQLite and Postgres (Postgres via a compose service in CI). Every plugin has a fixture-based test harness from the SDK (recorded API responses, no live calls in CI).
|
||||
|
||||
## 12. Phases
|
||||
|
||||
**Phase 1: own your data (v1).**
|
||||
- Core model (§6), multi-user auth with invites, and app tokens.
|
||||
- Plugin SDK, snapshots, change detection, the Changes inbox, matching and history.
|
||||
- Plugins: Hardcover, Trakt, MangaBaka, ListenBrainz, Last.fm, CSV/JSON.
|
||||
- App UI (§7.1): library, quick add/drafts, entry detail with hierarchy, engagements, copies (manual), attachments, custom fields, lists (plain + grouping), Changes, Sources, export/backup.
|
||||
- Docker, both databases.
|
||||
|
||||
**Phase 2: make it yours.**
|
||||
- Public page (scrapbook + river), threads, visibility controls.
|
||||
- Markdown vault, owned-library plugins (Steam, Jellyfin), metadata lookups (IGDB, TMDB, OpenLibrary), Libre.fm.
|
||||
- Write-back/migration UX with dry runs.
|
||||
|
||||
**Phase 3: plug everything in.**
|
||||
- Ingest endpoints (webhooks, ListenBrainz-compatible, kosync) and pull connectors (Kavita, Audiobookshelf, Komga, Calibre-Web).
|
||||
- Backloggd import, MCP server, cross-media suggestions, public-page themes (shelf, card catalogue).
|
||||
- OAuth provider and public-instance hardening.
|
||||
- Social features.
|
||||
|
||||
The implementation plan covers Phase 1 only. Phases 2 and 3 get their own plans.
|
||||
|
||||
## 13. Calls made by the designer (not discussed with the owner)
|
||||
|
||||
- Kysely over Drizzle/Prisma, so one query layer covers both dialects.
|
||||
- Ratings are stored as 0–100.
|
||||
- ULID primary keys.
|
||||
- In-process job queue in the database.
|
||||
- Plugins are trusted, in-process code. No sandbox.
|
||||
- Credentials are encrypted at rest with an env secret.
|
||||
- The status vocabulary is a fixed set with per-media-type labels.
|
||||
- Listens get their own table.
|
||||
- Phase boundaries as in §12.
|
||||
- Markdown vault and per-target import files are phase 2. CSV/JSON are phase 1.
|
||||
- Default visibility for everything is private.
|
||||
- The public page is phase 2 (the app and data safety come first).
|
||||
- Two-way Obsidian sync is later than one-way import/export.
|
||||
- Shared works are a metadata cache with per-user overrides. Merges are per user unless an admin does them (§6.1).
|
||||
- Imports create works at every level but entries only where the source reports user data. Tracks stay as listens (§6.3, "How deep imports go").
|
||||
- Listens never create draft works (§6.4).
|
||||
- Export bundles are per user. Instance backups are DB dumps. Merge-restore is Phase 2 (§10).
|
||||
|
||||
## 14. Left to the builder
|
||||
|
||||
- The exact React router, data-fetching and styling approach, as long as it is Bun-native (HTML imports, no Vite). The design tokens follow the mockups.
|
||||
- The fuzzy-matching algorithm and thresholds.
|
||||
- The exact snapshot payload compression and retention mechanics.
|
||||
- Internal module boundaries inside `packages/core`.
|
||||
- The exact OpenAPI tooling.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Specs
|
||||
|
||||
Approved specifications from the superpowers brainstorming workflow. Nothing non-trivial gets
|
||||
implemented without one.
|
||||
|
||||
- Naming: `NNN-short-slug.md`, zero-padded, sequential.
|
||||
- A spec states **what** and **why**, plus acceptance criteria. Not **how**.
|
||||
- Front matter: `status: draft | approved | implemented | superseded`. No link to a plan, because plans are
|
||||
removed before merge.
|
||||
- Never delete an implemented spec; it is the record of intent.
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "sttracker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["apps/*", "packages/*", "plugins/*"],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["apps", "packages", "plugins"],
|
||||
"exclude": ["**/node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user