+66
.claude/commands/digest.md
+66
.claude/commands/digest.md
···
1
+
---
2
+
description: Extract actionable insights from an external resource
3
+
argument-hint: [URL, AT-URI, or description of resource]
4
+
---
5
+
6
+
# digest
7
+
8
+
break down an external resource and identify what it means for us.
9
+
10
+
## process
11
+
12
+
1. **fetch the resource**: $ARGUMENTS
13
+
- use pdsx MCP for AT-URIs (bsky posts, leaflet docs, etc.)
14
+
- use WebFetch for URLs
15
+
- ask for clarification if the resource type is unclear
16
+
17
+
2. **extract concrete information**:
18
+
- what are they doing?
19
+
- what's their architecture/approach?
20
+
- what are their constraints and priorities?
21
+
- what's their roadmap?
22
+
23
+
3. **cross-reference with our state**:
24
+
- check open issues for overlap or gaps
25
+
- grep codebase for related implementations
26
+
- identify where we align or diverge
27
+
28
+
4. **identify actionable implications** - the core output:
29
+
- "given that X is true, we should consider Y"
30
+
- specific issues to open or update
31
+
- code changes to consider
32
+
- integration opportunities
33
+
- things we're missing or doing wrong
34
+
35
+
5. **present findings** - be direct:
36
+
- lead with the implications, not the summary
37
+
- include specific file:line or issue references
38
+
- propose concrete next steps
39
+
40
+
## anti-patterns
41
+
42
+
- philosophical musing without action items
43
+
- "we're complementary" without specifics
44
+
- agreeing that everything is fine
45
+
- backpedaling when challenged
46
+
47
+
## output format
48
+
49
+
```
50
+
## implications
51
+
52
+
1. **[actionable item]**: [reasoning]
53
+
- related: `file.py:123` or issue #456
54
+
- suggested: [specific change or issue to create]
55
+
56
+
2. **[actionable item]**: ...
57
+
58
+
## extracted facts
59
+
60
+
- [concrete thing from the resource]
61
+
- [another concrete thing]
62
+
63
+
## open questions
64
+
65
+
- [things to clarify or investigate further]
66
+
```
+47
-3
.github/workflows/check-rust.yml
+47
-3
.github/workflows/check-rust.yml
···
11
11
contents: read
12
12
13
13
jobs:
14
+
changes:
15
+
name: detect changes
16
+
runs-on: ubuntu-latest
17
+
outputs:
18
+
moderation: ${{ steps.filter.outputs.moderation }}
19
+
transcoder: ${{ steps.filter.outputs.transcoder }}
20
+
steps:
21
+
- uses: actions/checkout@v4
22
+
- uses: dorny/paths-filter@v3
23
+
id: filter
24
+
with:
25
+
filters: |
26
+
moderation:
27
+
- 'moderation/**'
28
+
- '.github/workflows/check-rust.yml'
29
+
transcoder:
30
+
- 'transcoder/**'
31
+
- '.github/workflows/check-rust.yml'
32
+
14
33
check:
15
34
name: cargo check
16
35
runs-on: ubuntu-latest
17
36
timeout-minutes: 15
37
+
needs: changes
18
38
19
39
strategy:
40
+
fail-fast: false
20
41
matrix:
21
-
service: [moderation, transcoder]
42
+
include:
43
+
- service: moderation
44
+
changed: ${{ needs.changes.outputs.moderation }}
45
+
- service: transcoder
46
+
changed: ${{ needs.changes.outputs.transcoder }}
22
47
23
48
steps:
24
49
- uses: actions/checkout@v4
50
+
if: matrix.changed == 'true'
25
51
26
52
- name: install rust toolchain
53
+
if: matrix.changed == 'true'
27
54
uses: dtolnay/rust-toolchain@stable
28
55
29
56
- name: cache cargo
57
+
if: matrix.changed == 'true'
30
58
uses: Swatinem/rust-cache@v2
31
59
with:
32
60
workspaces: ${{ matrix.service }}
33
61
34
62
- name: cargo check
63
+
if: matrix.changed == 'true'
35
64
working-directory: ${{ matrix.service }}
36
65
run: cargo check --release
37
66
67
+
- name: skip (no changes)
68
+
if: matrix.changed != 'true'
69
+
run: echo "skipping ${{ matrix.service }} - no changes"
70
+
38
71
docker-build:
39
72
name: docker build
40
73
runs-on: ubuntu-latest
41
74
timeout-minutes: 10
42
-
needs: check
75
+
needs: [changes, check]
43
76
44
77
strategy:
78
+
fail-fast: false
45
79
matrix:
46
-
service: [moderation, transcoder]
80
+
include:
81
+
- service: moderation
82
+
changed: ${{ needs.changes.outputs.moderation }}
83
+
- service: transcoder
84
+
changed: ${{ needs.changes.outputs.transcoder }}
47
85
48
86
steps:
49
87
- uses: actions/checkout@v4
88
+
if: matrix.changed == 'true'
50
89
51
90
- name: build docker image
91
+
if: matrix.changed == 'true'
52
92
working-directory: ${{ matrix.service }}
53
93
run: docker build -t ${{ matrix.service }}:ci-test .
94
+
95
+
- name: skip (no changes)
96
+
if: matrix.changed != 'true'
97
+
run: echo "skipping ${{ matrix.service }} - no changes"
+43
.github/workflows/deploy-redis.yml
+43
.github/workflows/deploy-redis.yml
···
1
+
name: deploy redis
2
+
3
+
on:
4
+
push:
5
+
branches:
6
+
- main
7
+
paths:
8
+
- "redis/fly.toml"
9
+
- "redis/fly.staging.toml"
10
+
- ".github/workflows/deploy-redis.yml"
11
+
workflow_dispatch:
12
+
13
+
jobs:
14
+
deploy-staging:
15
+
name: deploy redis staging
16
+
runs-on: ubuntu-latest
17
+
concurrency: deploy-redis-staging
18
+
steps:
19
+
- uses: actions/checkout@v4
20
+
21
+
- uses: superfly/flyctl-actions/setup-flyctl@master
22
+
23
+
- name: deploy to fly.io staging
24
+
run: flyctl deploy --config redis/fly.staging.toml --remote-only -a plyr-redis-stg
25
+
working-directory: redis
26
+
env:
27
+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_REDIS }}
28
+
29
+
deploy-prod:
30
+
name: deploy redis prod
31
+
runs-on: ubuntu-latest
32
+
needs: deploy-staging
33
+
concurrency: deploy-redis-prod
34
+
steps:
35
+
- uses: actions/checkout@v4
36
+
37
+
- uses: superfly/flyctl-actions/setup-flyctl@master
38
+
39
+
- name: deploy to fly.io prod
40
+
run: flyctl deploy --config redis/fly.toml --remote-only -a plyr-redis
41
+
working-directory: redis
42
+
env:
43
+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_REDIS }}
+64
.github/workflows/run-moderation-loop.yml
+64
.github/workflows/run-moderation-loop.yml
···
1
+
# run moderation loop via workflow dispatch
2
+
#
3
+
# analyzes pending copyright flags, auto-resolves false positives,
4
+
# creates review batches for human review, sends DM notification
5
+
#
6
+
# required secrets:
7
+
# MODERATION_SERVICE_URL - moderation service base URL
8
+
# MODERATION_AUTH_TOKEN - X-Moderation-Key header value
9
+
# ANTHROPIC_API_KEY - for flag analysis
10
+
# NOTIFY_BOT_HANDLE - bluesky bot handle for DMs
11
+
# NOTIFY_BOT_PASSWORD - bluesky bot app password
12
+
# NOTIFY_RECIPIENT_HANDLE - who receives DM notifications
13
+
14
+
name: run moderation loop
15
+
16
+
on:
17
+
workflow_dispatch:
18
+
inputs:
19
+
dry_run:
20
+
description: "dry run (analyze only, don't resolve or send DMs)"
21
+
type: boolean
22
+
default: true
23
+
limit:
24
+
description: "max flags to process (leave empty for all)"
25
+
type: string
26
+
default: ""
27
+
env:
28
+
description: "environment (for DM header)"
29
+
type: choice
30
+
options:
31
+
- prod
32
+
- staging
33
+
- dev
34
+
default: prod
35
+
36
+
jobs:
37
+
run:
38
+
runs-on: ubuntu-latest
39
+
40
+
steps:
41
+
- uses: actions/checkout@v4
42
+
43
+
- uses: astral-sh/setup-uv@v4
44
+
45
+
- name: Run moderation loop
46
+
run: |
47
+
ARGS=""
48
+
if [ "${{ inputs.dry_run }}" = "true" ]; then
49
+
ARGS="$ARGS --dry-run"
50
+
fi
51
+
if [ -n "${{ inputs.limit }}" ]; then
52
+
ARGS="$ARGS --limit ${{ inputs.limit }}"
53
+
fi
54
+
ARGS="$ARGS --env ${{ inputs.env }}"
55
+
56
+
echo "Running: uv run scripts/moderation_loop.py $ARGS"
57
+
uv run scripts/moderation_loop.py $ARGS
58
+
env:
59
+
MODERATION_SERVICE_URL: ${{ secrets.MODERATION_SERVICE_URL }}
60
+
MODERATION_AUTH_TOKEN: ${{ secrets.MODERATION_AUTH_TOKEN }}
61
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
62
+
NOTIFY_BOT_HANDLE: ${{ secrets.NOTIFY_BOT_HANDLE }}
63
+
NOTIFY_BOT_PASSWORD: ${{ secrets.NOTIFY_BOT_PASSWORD }}
64
+
NOTIFY_RECIPIENT_HANDLE: ${{ secrets.NOTIFY_RECIPIENT_HANDLE }}
+7
-5
README.md
+7
-5
README.md
···
119
119
│ └── src/routes/ # pages
120
120
├── moderation/ # Rust labeler service
121
121
├── transcoder/ # Rust audio service
122
+
├── redis/ # self-hosted Redis config
122
123
├── docs/ # documentation
123
124
└── justfile # task runner
124
125
```
···
128
129
<details>
129
130
<summary>costs</summary>
130
131
131
-
~$35-40/month:
132
-
- fly.io backend (prod + staging): ~$10/month
133
-
- fly.io transcoder: ~$0-5/month (auto-scales to zero)
132
+
~$20/month:
133
+
- fly.io (backend + redis + moderation): ~$14/month
134
134
- neon postgres: $5/month
135
-
- audd audio fingerprinting: ~$10/month
136
-
- cloudflare (pages + r2): ~$0.16/month
135
+
- cloudflare (pages + r2): ~$1/month
136
+
- audd audio fingerprinting: $5-10/month (usage-based)
137
+
138
+
live dashboard: https://plyr.fm/costs
137
139
138
140
</details>
139
141
+18
-5
STATUS.md
+18
-5
STATUS.md
···
47
47
48
48
### December 2025
49
49
50
+
#### self-hosted redis (PR #674-675, Dec 30)
51
+
52
+
**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month:
53
+
- Upstash pay-as-you-go was charging per command (37M commands = $75)
54
+
- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
55
+
- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
56
+
- added CI workflow for redis deployments on merge
57
+
58
+
**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
59
+
60
+
---
61
+
50
62
#### supporter-gated content (PR #637, Dec 22-23)
51
63
52
64
**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
···
265
277
266
278
## cost structure
267
279
268
-
current monthly costs: ~$18/month (plyr.fm specific)
280
+
current monthly costs: ~$20/month (plyr.fm specific)
269
281
270
282
see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
271
283
272
-
- fly.io (plyr apps only): ~$12/month
284
+
- fly.io (backend + redis + moderation): ~$14/month
273
285
- neon postgres: $5/month
274
-
- cloudflare (R2 + pages + domain): ~$1.16/month
275
-
- audd audio fingerprinting: $0-10/month (6000 free/month)
286
+
- cloudflare (R2 + pages + domain): ~$1/month
287
+
- audd audio fingerprinting: $5-10/month (usage-based)
276
288
- logfire: $0 (free tier)
277
289
278
290
## admin tooling
···
323
335
│ └── src/routes/ # pages
324
336
├── moderation/ # Rust moderation service (ATProto labeler)
325
337
├── transcoder/ # Rust audio transcoding service
338
+
├── redis/ # self-hosted Redis config
326
339
├── docs/ # documentation
327
340
└── justfile # task runner
328
341
```
···
338
351
339
352
---
340
353
341
-
this is a living document. last updated 2025-12-29.
354
+
this is a living document. last updated 2025-12-30.
+1
-1
backend/fly.staging.toml
+1
-1
backend/fly.staging.toml
···
44
44
# - AWS_ACCESS_KEY_ID (cloudflare R2)
45
45
# - AWS_SECRET_ACCESS_KEY (cloudflare R2)
46
46
# - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')
47
-
# - DOCKET_URL (upstash redis: rediss://default:xxx@xxx.upstash.io:6379)
47
+
# - DOCKET_URL (self-hosted redis: redis://plyr-redis-stg.internal:6379)
+1
-1
backend/fly.toml
+1
-1
backend/fly.toml
···
39
39
# - AWS_ACCESS_KEY_ID (cloudflare R2)
40
40
# - AWS_SECRET_ACCESS_KEY (cloudflare R2)
41
41
# - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')
42
-
# - DOCKET_URL (upstash redis: rediss://default:xxx@xxx.upstash.io:6379)
42
+
# - DOCKET_URL (self-hosted redis: redis://plyr-redis.internal:6379)
+2
-3
backend/src/backend/_internal/background.py
+2
-3
backend/src/backend/_internal/background.py
···
55
55
extra={"docket_name": settings.docket.name, "url": settings.docket.url},
56
56
)
57
57
58
+
# WARNING: do not modify Docket() or Worker() constructor args without
59
+
# reading docs/backend/background-tasks.md - see 2025-12-30 incident
58
60
async with Docket(
59
61
name=settings.docket.name,
60
62
url=settings.docket.url,
61
-
# default 2s is for systems needing fast worker failure detection
62
-
# with our 5-minute perpetual task, 30s is plenty responsive
63
-
heartbeat_interval=timedelta(seconds=30),
64
63
) as docket:
65
64
_docket = docket
66
65
+29
-17
docs/backend/background-tasks.md
+29
-17
docs/backend/background-tasks.md
···
39
39
DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit
40
40
```
41
41
42
+
### ⚠️ worker settings - do not modify
43
+
44
+
the worker is initialized in `backend/_internal/background.py` with pydocket's defaults. **do not change these settings without extensive testing:**
45
+
46
+
| setting | default | why it matters |
47
+
|---------|---------|----------------|
48
+
| `heartbeat_interval` | 2s | changing this broke all task execution (2025-12-30 incident) |
49
+
| `minimum_check_interval` | 1s | affects how quickly tasks are picked up |
50
+
| `scheduling_resolution` | 1s | affects scheduled task precision |
51
+
52
+
**2025-12-30 incident**: setting `heartbeat_interval=30s` caused all scheduled tasks (likes, comments, exports) to silently fail while perpetual tasks continued running. root cause unclear - correlation was definitive but mechanism wasn't found in pydocket source. reverted in PR #669.
53
+
54
+
if you need to tune worker settings:
55
+
1. test extensively in staging with real task volume
56
+
2. verify ALL task types execute (not just perpetual tasks)
57
+
3. check logfire for task execution spans
58
+
42
59
when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget).
43
60
44
61
### local development
···
54
71
55
72
### production/staging
56
73
57
-
Redis instances are provisioned via Upstash (managed Redis):
74
+
Redis instances are self-hosted on Fly.io (redis:7-alpine):
58
75
59
-
| environment | instance | region |
60
-
|-------------|----------|--------|
61
-
| production | `plyr-redis-prd` | us-east-1 (near fly.io) |
62
-
| staging | `plyr-redis-stg` | us-east-1 |
76
+
| environment | fly app | region |
77
+
|-------------|---------|--------|
78
+
| production | `plyr-redis` | iad |
79
+
| staging | `plyr-redis-stg` | iad |
63
80
64
81
set `DOCKET_URL` in fly.io secrets:
65
82
```bash
66
-
flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api
67
-
flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api-staging
83
+
flyctl secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api
84
+
flyctl secrets set DOCKET_URL=redis://plyr-redis-stg.internal:6379 -a relay-api-staging
68
85
```
69
86
70
-
note: use `rediss://` (with double 's') for TLS connections to Upstash.
87
+
note: uses Fly internal networking (`.internal` domain), no TLS needed within private network.
71
88
72
89
## usage
73
90
···
117
134
118
135
## costs
119
136
120
-
**Upstash pricing** (pay-per-request):
121
-
- free tier: 10k commands/day
122
-
- pro: $0.2 per 100k commands + $0.25/GB storage
137
+
**self-hosted Redis on Fly.io** (fixed monthly):
138
+
- ~$2/month per instance (256MB shared-cpu VM)
139
+
- ~$4/month total for prod + staging
123
140
124
-
for plyr.fm's volume (~100 uploads/day), this stays well within free tier or costs $0-5/mo.
125
-
126
-
**tips to avoid surprise bills**:
127
-
- use **regional** (not global) replication
128
-
- set **max data limit** (256MB is plenty for a task queue)
129
-
- monitor usage in Upstash dashboard
141
+
this replaced Upstash pay-per-command pricing which was costing ~$75/month at scale (37M commands/month).
130
142
131
143
## fallback behavior
132
144
+2
-2
docs/deployment/environments.md
+2
-2
docs/deployment/environments.md
···
7
7
| environment | trigger | backend URL | database | redis | frontend | storage |
8
8
|-------------|---------|-------------|----------|-------|----------|---------|
9
9
| **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) |
10
-
| **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (upstash) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) |
11
-
| **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis-prd (upstash) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) |
10
+
| **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (fly.io) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) |
11
+
| **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis (fly.io) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) |
12
12
13
13
## workflow
14
14
+117
docs/frontend/design-tokens.md
+117
docs/frontend/design-tokens.md
···
1
+
# design tokens
2
+
3
+
CSS custom properties defined in `frontend/src/routes/+layout.svelte`. Use these instead of hardcoding values.
4
+
5
+
## border radius
6
+
7
+
```css
8
+
--radius-sm: 4px; /* tight corners (inputs, small elements) */
9
+
--radius-base: 6px; /* default for most elements */
10
+
--radius-md: 8px; /* cards, modals */
11
+
--radius-lg: 12px; /* larger containers */
12
+
--radius-xl: 16px; /* prominent elements */
13
+
--radius-2xl: 24px; /* hero elements */
14
+
--radius-full: 9999px; /* pills, circles */
15
+
```
16
+
17
+
## typography
18
+
19
+
```css
20
+
/* scale */
21
+
--text-xs: 0.75rem; /* 12px - hints, captions */
22
+
--text-sm: 0.85rem; /* 13.6px - labels, secondary */
23
+
--text-base: 0.9rem; /* 14.4px - body default */
24
+
--text-lg: 1rem; /* 16px - body emphasized */
25
+
--text-xl: 1.1rem; /* 17.6px - subheadings */
26
+
--text-2xl: 1.25rem; /* 20px - section headings */
27
+
--text-3xl: 1.5rem; /* 24px - page headings */
28
+
29
+
/* semantic aliases */
30
+
--text-page-heading: var(--text-3xl);
31
+
--text-section-heading: 1.2rem;
32
+
--text-body: var(--text-lg);
33
+
--text-small: var(--text-base);
34
+
```
35
+
36
+
## colors
37
+
38
+
### accent
39
+
40
+
```css
41
+
--accent: #6a9fff; /* primary brand color (user-customizable) */
42
+
--accent-hover: #8ab3ff; /* hover state */
43
+
--accent-muted: #4a7ddd; /* subdued variant */
44
+
--accent-rgb: 106, 159, 255; /* for rgba() usage */
45
+
```
46
+
47
+
### backgrounds
48
+
49
+
```css
50
+
/* dark theme */
51
+
--bg-primary: #0a0a0a; /* main background */
52
+
--bg-secondary: #141414; /* elevated surfaces */
53
+
--bg-tertiary: #1a1a1a; /* cards, modals */
54
+
--bg-hover: #1f1f1f; /* hover states */
55
+
56
+
/* light theme overrides these automatically */
57
+
```
58
+
59
+
### borders
60
+
61
+
```css
62
+
--border-subtle: #282828; /* barely visible */
63
+
--border-default: #333333; /* standard borders */
64
+
--border-emphasis: #444444; /* highlighted borders */
65
+
```
66
+
67
+
### text
68
+
69
+
```css
70
+
--text-primary; /* high contrast */
71
+
--text-secondary; /* medium contrast */
72
+
--text-tertiary; /* low contrast */
73
+
--text-muted; /* very low contrast */
74
+
```
75
+
76
+
### semantic
77
+
78
+
```css
79
+
--success: #22c55e;
80
+
--warning: #f59e0b;
81
+
--error: #ef4444;
82
+
```
83
+
84
+
## usage
85
+
86
+
```svelte
87
+
<style>
88
+
.card {
89
+
border-radius: var(--radius-md);
90
+
background: var(--bg-tertiary);
91
+
border: 1px solid var(--border-default);
92
+
}
93
+
94
+
.label {
95
+
font-size: var(--text-sm);
96
+
color: var(--text-secondary);
97
+
}
98
+
99
+
input:focus {
100
+
border-color: var(--accent);
101
+
}
102
+
</style>
103
+
```
104
+
105
+
## anti-patterns
106
+
107
+
```css
108
+
/* bad - hardcoded values */
109
+
border-radius: 8px;
110
+
font-size: 14px;
111
+
background: #1a1a1a;
112
+
113
+
/* good - use tokens */
114
+
border-radius: var(--radius-md);
115
+
font-size: var(--text-base);
116
+
background: var(--bg-tertiary);
117
+
```
+1
frontend/CLAUDE.md
+1
frontend/CLAUDE.md
···
6
6
- **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache)
7
7
- **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc)
8
8
- **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading
9
+
- **design tokens**: use CSS variables from `+layout.svelte` - never hardcode colors, radii, or font sizes (see `docs/frontend/design-tokens.md`)
9
10
10
11
gotchas:
11
12
- **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
+8
-8
frontend/src/lib/components/AddToMenu.svelte
+8
-8
frontend/src/lib/components/AddToMenu.svelte
···
510
510
background: transparent;
511
511
border: none;
512
512
color: var(--text-primary);
513
-
font-size: 0.9rem;
513
+
font-size: var(--text-base);
514
514
font-family: inherit;
515
515
cursor: pointer;
516
516
transition: background 0.15s;
···
544
544
border: none;
545
545
border-bottom: 1px solid var(--border-subtle);
546
546
color: var(--text-secondary);
547
-
font-size: 0.85rem;
547
+
font-size: var(--text-sm);
548
548
font-family: inherit;
549
549
cursor: pointer;
550
550
transition: background 0.15s;
···
588
588
background: transparent;
589
589
border: none;
590
590
color: var(--text-primary);
591
-
font-size: 0.9rem;
591
+
font-size: var(--text-base);
592
592
font-family: inherit;
593
593
cursor: pointer;
594
594
transition: background 0.15s;
···
639
639
gap: 0.5rem;
640
640
padding: 1.5rem 1rem;
641
641
color: var(--text-tertiary);
642
-
font-size: 0.85rem;
642
+
font-size: var(--text-sm);
643
643
}
644
644
645
645
.create-playlist-btn {
···
652
652
border: none;
653
653
border-top: 1px solid var(--border-subtle);
654
654
color: var(--accent);
655
-
font-size: 0.9rem;
655
+
font-size: var(--text-base);
656
656
font-family: inherit;
657
657
cursor: pointer;
658
658
transition: background 0.15s;
···
678
678
border-radius: var(--radius-base);
679
679
color: var(--text-primary);
680
680
font-family: inherit;
681
-
font-size: 0.9rem;
681
+
font-size: var(--text-base);
682
682
}
683
683
684
684
.create-form input:focus {
···
701
701
border-radius: var(--radius-base);
702
702
color: white;
703
703
font-family: inherit;
704
-
font-size: 0.9rem;
704
+
font-size: var(--text-base);
705
705
font-weight: 500;
706
706
cursor: pointer;
707
707
transition: opacity 0.15s;
···
783
783
784
784
.menu-item {
785
785
padding: 1rem 1.25rem;
786
-
font-size: 1rem;
786
+
font-size: var(--text-lg);
787
787
}
788
788
789
789
.back-button {
+3
-3
frontend/src/lib/components/AlbumSelect.svelte
+3
-3
frontend/src/lib/components/AlbumSelect.svelte
···
104
104
border: 1px solid var(--border-default);
105
105
border-radius: var(--radius-sm);
106
106
color: var(--text-primary);
107
-
font-size: 1rem;
107
+
font-size: var(--text-lg);
108
108
font-family: inherit;
109
109
transition: all 0.2s;
110
110
}
···
203
203
}
204
204
205
205
.album-stats {
206
-
font-size: 0.85rem;
206
+
font-size: var(--text-sm);
207
207
color: var(--text-tertiary);
208
208
overflow: hidden;
209
209
text-overflow: ellipsis;
···
212
212
213
213
.similar-hint {
214
214
margin-top: 0.5rem;
215
-
font-size: 0.85rem;
215
+
font-size: var(--text-sm);
216
216
color: var(--warning);
217
217
font-style: italic;
218
218
margin-bottom: 0;
+9
-9
frontend/src/lib/components/BrokenTracks.svelte
+9
-9
frontend/src/lib/components/BrokenTracks.svelte
···
213
213
}
214
214
215
215
.section-header h2 {
216
-
font-size: 1.5rem;
216
+
font-size: var(--text-3xl);
217
217
margin: 0;
218
218
color: var(--warning);
219
219
}
···
225
225
border-radius: var(--radius-sm);
226
226
color: var(--warning);
227
227
font-family: inherit;
228
-
font-size: 0.9rem;
228
+
font-size: var(--text-base);
229
229
font-weight: 600;
230
230
cursor: pointer;
231
231
transition: all 0.2s;
···
249
249
color: var(--warning);
250
250
padding: 0.25rem 0.6rem;
251
251
border-radius: var(--radius-lg);
252
-
font-size: 0.85rem;
252
+
font-size: var(--text-sm);
253
253
font-weight: 600;
254
254
}
255
255
···
280
280
}
281
281
282
282
.warning-icon {
283
-
font-size: 1.25rem;
283
+
font-size: var(--text-2xl);
284
284
flex-shrink: 0;
285
285
}
286
286
···
291
291
292
292
.track-title {
293
293
font-weight: 600;
294
-
font-size: 1rem;
294
+
font-size: var(--text-lg);
295
295
margin-bottom: 0.25rem;
296
296
color: var(--text-primary);
297
297
}
298
298
299
299
.track-meta {
300
-
font-size: 0.9rem;
300
+
font-size: var(--text-base);
301
301
color: var(--text-secondary);
302
302
margin-bottom: 0.5rem;
303
303
}
304
304
305
305
.issue-description {
306
-
font-size: 0.85rem;
306
+
font-size: var(--text-sm);
307
307
color: var(--warning);
308
308
}
309
309
···
314
314
border-radius: var(--radius-sm);
315
315
color: var(--warning);
316
316
font-family: inherit;
317
-
font-size: 0.9rem;
317
+
font-size: var(--text-base);
318
318
font-weight: 500;
319
319
cursor: pointer;
320
320
transition: all 0.2s;
···
339
339
border: 1px solid var(--border-subtle);
340
340
border-radius: var(--radius-base);
341
341
padding: 1rem;
342
-
font-size: 0.9rem;
342
+
font-size: var(--text-base);
343
343
color: var(--text-secondary);
344
344
}
345
345
+4
-4
frontend/src/lib/components/ColorSettings.svelte
+4
-4
frontend/src/lib/components/ColorSettings.svelte
···
147
147
align-items: center;
148
148
margin-bottom: 1rem;
149
149
color: var(--text-primary);
150
-
font-size: 0.9rem;
150
+
font-size: var(--text-base);
151
151
}
152
152
153
153
.close-btn {
154
154
background: transparent;
155
155
border: none;
156
156
color: var(--text-secondary);
157
-
font-size: 1.5rem;
157
+
font-size: var(--text-3xl);
158
158
cursor: pointer;
159
159
padding: 0;
160
160
width: 24px;
···
198
198
199
199
.color-value {
200
200
font-family: monospace;
201
-
font-size: 0.85rem;
201
+
font-size: var(--text-sm);
202
202
color: var(--text-secondary);
203
203
}
204
204
···
209
209
}
210
210
211
211
.presets-label {
212
-
font-size: 0.85rem;
212
+
font-size: var(--text-sm);
213
213
color: var(--text-tertiary);
214
214
}
215
215
+3
-3
frontend/src/lib/components/HandleAutocomplete.svelte
+3
-3
frontend/src/lib/components/HandleAutocomplete.svelte
···
133
133
border: 1px solid var(--border-default);
134
134
border-radius: var(--radius-sm);
135
135
color: var(--text-primary);
136
-
font-size: 1rem;
136
+
font-size: var(--text-lg);
137
137
font-family: inherit;
138
138
transition: border-color 0.2s;
139
139
box-sizing: border-box;
···
159
159
top: 50%;
160
160
transform: translateY(-50%);
161
161
color: var(--text-muted);
162
-
font-size: 0.85rem;
162
+
font-size: var(--text-sm);
163
163
}
164
164
165
165
.results {
···
252
252
}
253
253
254
254
.handle {
255
-
font-size: 0.85rem;
255
+
font-size: var(--text-sm);
256
256
color: var(--text-tertiary);
257
257
overflow: hidden;
258
258
text-overflow: ellipsis;
+7
-7
frontend/src/lib/components/HandleSearch.svelte
+7
-7
frontend/src/lib/components/HandleSearch.svelte
···
181
181
border: 1px solid var(--border-default);
182
182
border-radius: var(--radius-sm);
183
183
color: var(--text-primary);
184
-
font-size: 1rem;
184
+
font-size: var(--text-lg);
185
185
font-family: inherit;
186
186
transition: all 0.2s;
187
187
}
···
201
201
right: 0.75rem;
202
202
top: 50%;
203
203
transform: translateY(-50%);
204
-
font-size: 0.85rem;
204
+
font-size: var(--text-sm);
205
205
color: var(--text-muted);
206
206
}
207
207
···
299
299
}
300
300
301
301
.result-handle {
302
-
font-size: 0.85rem;
302
+
font-size: var(--text-sm);
303
303
color: var(--text-tertiary);
304
304
overflow: hidden;
305
305
text-overflow: ellipsis;
···
322
322
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle));
323
323
border-radius: var(--radius-xl);
324
324
color: var(--text-primary);
325
-
font-size: 0.9rem;
325
+
font-size: var(--text-base);
326
326
}
327
327
328
328
.chip-avatar {
···
365
365
366
366
.max-features-message {
367
367
margin-top: 0.5rem;
368
-
font-size: 0.85rem;
368
+
font-size: var(--text-sm);
369
369
color: var(--warning);
370
370
}
371
371
···
376
376
border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle));
377
377
border-radius: var(--radius-sm);
378
378
color: var(--warning);
379
-
font-size: 0.9rem;
379
+
font-size: var(--text-base);
380
380
text-align: center;
381
381
}
382
382
···
401
401
402
402
.selected-artist-chip {
403
403
padding: 0.4rem 0.6rem;
404
-
font-size: 0.85rem;
404
+
font-size: var(--text-sm);
405
405
}
406
406
407
407
.chip-avatar {
+8
-8
frontend/src/lib/components/Header.svelte
+8
-8
frontend/src/lib/components/Header.svelte
···
276
276
color: var(--text-secondary);
277
277
padding: 0.5rem 1rem;
278
278
border-radius: var(--radius-base);
279
-
font-size: 0.9rem;
279
+
font-size: var(--text-base);
280
280
font-family: inherit;
281
281
cursor: pointer;
282
282
transition: all 0.2s;
···
320
320
}
321
321
322
322
h1 {
323
-
font-size: 1.5rem;
323
+
font-size: var(--text-3xl);
324
324
margin: 0;
325
325
color: var(--text-primary);
326
326
transition: color 0.2s;
···
353
353
.nav-link {
354
354
color: var(--text-secondary);
355
355
text-decoration: none;
356
-
font-size: 0.9rem;
356
+
font-size: var(--text-base);
357
357
transition: all 0.2s;
358
358
white-space: nowrap;
359
359
display: flex;
···
388
388
.user-handle {
389
389
color: var(--text-secondary);
390
390
text-decoration: none;
391
-
font-size: 0.9rem;
391
+
font-size: var(--text-base);
392
392
padding: 0.4rem 0.75rem;
393
393
background: var(--bg-tertiary);
394
394
border-radius: var(--radius-base);
···
409
409
color: var(--accent);
410
410
padding: 0.5rem 1rem;
411
411
border-radius: var(--radius-base);
412
-
font-size: 0.9rem;
412
+
font-size: var(--text-base);
413
413
text-decoration: none;
414
414
transition: all 0.2s;
415
415
cursor: pointer;
···
467
467
468
468
.nav-link {
469
469
padding: 0.3rem 0.5rem;
470
-
font-size: 0.8rem;
470
+
font-size: var(--text-sm);
471
471
}
472
472
473
473
.nav-link span {
···
475
475
}
476
476
477
477
.user-handle {
478
-
font-size: 0.8rem;
478
+
font-size: var(--text-sm);
479
479
padding: 0.3rem 0.5rem;
480
480
}
481
481
482
482
.btn-primary {
483
-
font-size: 0.8rem;
483
+
font-size: var(--text-sm);
484
484
padding: 0.3rem 0.65rem;
485
485
}
486
486
}
+7
-7
frontend/src/lib/components/HiddenTagsFilter.svelte
+7
-7
frontend/src/lib/components/HiddenTagsFilter.svelte
···
126
126
align-items: center;
127
127
gap: 0.5rem;
128
128
flex-wrap: wrap;
129
-
font-size: 0.8rem;
129
+
font-size: var(--text-sm);
130
130
}
131
131
132
132
.filter-toggle {
···
157
157
}
158
158
159
159
.filter-count {
160
-
font-size: 0.7rem;
160
+
font-size: var(--text-xs);
161
161
color: var(--text-tertiary);
162
162
}
163
163
164
164
.filter-label {
165
165
color: var(--text-tertiary);
166
166
white-space: nowrap;
167
-
font-size: 0.75rem;
167
+
font-size: var(--text-xs);
168
168
font-family: inherit;
169
169
}
170
170
···
184
184
border: 1px solid var(--border-default);
185
185
color: var(--text-secondary);
186
186
border-radius: var(--radius-sm);
187
-
font-size: 0.75rem;
187
+
font-size: var(--text-xs);
188
188
font-family: inherit;
189
189
cursor: pointer;
190
190
transition: all 0.15s;
···
201
201
}
202
202
203
203
.remove-icon {
204
-
font-size: 0.8rem;
204
+
font-size: var(--text-sm);
205
205
line-height: 1;
206
206
opacity: 0.5;
207
207
}
···
221
221
border: 1px dashed var(--border-default);
222
222
border-radius: var(--radius-sm);
223
223
color: var(--text-tertiary);
224
-
font-size: 0.8rem;
224
+
font-size: var(--text-sm);
225
225
cursor: pointer;
226
226
transition: all 0.15s;
227
227
}
···
236
236
background: transparent;
237
237
border: 1px solid var(--border-default);
238
238
color: var(--text-primary);
239
-
font-size: 0.75rem;
239
+
font-size: var(--text-xs);
240
240
font-family: inherit;
241
241
min-height: 24px;
242
242
width: 70px;
+5
-5
frontend/src/lib/components/LikersTooltip.svelte
+5
-5
frontend/src/lib/components/LikersTooltip.svelte
···
155
155
.error,
156
156
.empty {
157
157
color: var(--text-tertiary);
158
-
font-size: 0.85rem;
158
+
font-size: var(--text-sm);
159
159
text-align: center;
160
160
padding: 0.5rem;
161
161
}
···
206
206
justify-content: center;
207
207
color: var(--text-tertiary);
208
208
font-weight: 600;
209
-
font-size: 0.9rem;
209
+
font-size: var(--text-base);
210
210
}
211
211
212
212
.liker-info {
···
217
217
.display-name {
218
218
color: var(--text-primary);
219
219
font-weight: 500;
220
-
font-size: 0.9rem;
220
+
font-size: var(--text-base);
221
221
white-space: nowrap;
222
222
overflow: hidden;
223
223
text-overflow: ellipsis;
···
225
225
226
226
.handle {
227
227
color: var(--text-tertiary);
228
-
font-size: 0.8rem;
228
+
font-size: var(--text-sm);
229
229
white-space: nowrap;
230
230
overflow: hidden;
231
231
text-overflow: ellipsis;
···
233
233
234
234
.liked-time {
235
235
color: var(--text-muted);
236
-
font-size: 0.75rem;
236
+
font-size: var(--text-xs);
237
237
flex-shrink: 0;
238
238
}
239
239
+3
-3
frontend/src/lib/components/LinksMenu.svelte
+3
-3
frontend/src/lib/components/LinksMenu.svelte
···
186
186
}
187
187
188
188
.menu-header span {
189
-
font-size: 0.9rem;
189
+
font-size: var(--text-base);
190
190
font-weight: 600;
191
191
color: var(--text-primary);
192
192
text-transform: uppercase;
···
263
263
}
264
264
265
265
.link-title {
266
-
font-size: 0.95rem;
266
+
font-size: var(--text-base);
267
267
font-weight: 500;
268
268
color: var(--text-primary);
269
269
}
270
270
271
271
.link-subtitle {
272
-
font-size: 0.8rem;
272
+
font-size: var(--text-sm);
273
273
color: var(--text-tertiary);
274
274
}
275
275
+2
-2
frontend/src/lib/components/PlatformStats.svelte
+2
-2
frontend/src/lib/components/PlatformStats.svelte
···
182
182
gap: 0.5rem;
183
183
margin-bottom: 0.75rem;
184
184
color: var(--text-secondary);
185
-
font-size: 0.7rem;
185
+
font-size: var(--text-xs);
186
186
font-weight: 600;
187
187
text-transform: uppercase;
188
188
letter-spacing: 0.05em;
···
229
229
}
230
230
231
231
.stats-menu-value {
232
-
font-size: 0.95rem;
232
+
font-size: var(--text-base);
233
233
font-weight: 600;
234
234
color: var(--text-primary);
235
235
font-variant-numeric: tabular-nums;
+8
-8
frontend/src/lib/components/ProfileMenu.svelte
+8
-8
frontend/src/lib/components/ProfileMenu.svelte
···
326
326
}
327
327
328
328
.menu-header span {
329
-
font-size: 0.9rem;
329
+
font-size: var(--text-base);
330
330
font-weight: 600;
331
331
color: var(--text-primary);
332
332
text-transform: uppercase;
···
414
414
}
415
415
416
416
.item-title {
417
-
font-size: 0.95rem;
417
+
font-size: var(--text-base);
418
418
font-weight: 500;
419
419
color: var(--text-primary);
420
420
}
421
421
422
422
.item-subtitle {
423
-
font-size: 0.8rem;
423
+
font-size: var(--text-sm);
424
424
color: var(--text-tertiary);
425
425
overflow: hidden;
426
426
text-overflow: ellipsis;
···
443
443
border-radius: var(--radius-base);
444
444
color: var(--text-secondary);
445
445
font-family: inherit;
446
-
font-size: 0.85rem;
446
+
font-size: var(--text-sm);
447
447
cursor: pointer;
448
448
transition: all 0.15s;
449
449
-webkit-tap-highlight-color: transparent;
···
469
469
470
470
.settings-section h3 {
471
471
margin: 0;
472
-
font-size: 0.75rem;
472
+
font-size: var(--text-xs);
473
473
text-transform: uppercase;
474
474
letter-spacing: 0.08em;
475
475
color: var(--text-tertiary);
···
518
518
}
519
519
520
520
.theme-btn span {
521
-
font-size: 0.7rem;
521
+
font-size: var(--text-xs);
522
522
text-transform: uppercase;
523
523
letter-spacing: 0.05em;
524
524
}
···
583
583
align-items: center;
584
584
gap: 0.75rem;
585
585
color: var(--text-primary);
586
-
font-size: 0.9rem;
586
+
font-size: var(--text-base);
587
587
cursor: pointer;
588
588
padding: 0.5rem 0;
589
589
}
···
635
635
border-top: 1px solid var(--border-subtle);
636
636
color: var(--text-secondary);
637
637
text-decoration: none;
638
-
font-size: 0.9rem;
638
+
font-size: var(--text-base);
639
639
transition: color 0.15s;
640
640
}
641
641
+9
-9
frontend/src/lib/components/Queue.svelte
+9
-9
frontend/src/lib/components/Queue.svelte
···
249
249
250
250
.queue-header h2 {
251
251
margin: 0;
252
-
font-size: 1rem;
252
+
font-size: var(--text-lg);
253
253
text-transform: uppercase;
254
254
letter-spacing: 0.12em;
255
255
color: var(--text-tertiary);
···
263
263
264
264
.clear-btn {
265
265
padding: 0.25rem 0.75rem;
266
-
font-size: 0.75rem;
266
+
font-size: var(--text-xs);
267
267
font-family: inherit;
268
268
text-transform: uppercase;
269
269
letter-spacing: 0.08em;
···
290
290
}
291
291
292
292
.section-label {
293
-
font-size: 0.75rem;
293
+
font-size: var(--text-xs);
294
294
text-transform: uppercase;
295
295
letter-spacing: 0.08em;
296
296
color: var(--text-tertiary);
···
316
316
}
317
317
318
318
.now-playing-card .track-artist {
319
-
font-size: 0.9rem;
319
+
font-size: var(--text-base);
320
320
color: var(--text-secondary);
321
321
}
322
322
···
344
344
justify-content: space-between;
345
345
align-items: center;
346
346
color: var(--text-tertiary);
347
-
font-size: 0.85rem;
347
+
font-size: var(--text-sm);
348
348
text-transform: uppercase;
349
349
letter-spacing: 0.08em;
350
350
}
351
351
352
352
.section-header h3 {
353
353
margin: 0;
354
-
font-size: 0.85rem;
354
+
font-size: var(--text-sm);
355
355
font-weight: 600;
356
356
color: var(--text-secondary);
357
357
text-transform: uppercase;
···
449
449
}
450
450
451
451
.track-artist {
452
-
font-size: 0.85rem;
452
+
font-size: var(--text-sm);
453
453
color: var(--text-tertiary);
454
454
white-space: nowrap;
455
455
overflow: hidden;
···
524
524
525
525
.empty-state p {
526
526
margin: 0.5rem 0 0.25rem;
527
-
font-size: 1.1rem;
527
+
font-size: var(--text-xl);
528
528
color: var(--text-secondary);
529
529
}
530
530
531
531
.empty-state span {
532
-
font-size: 0.9rem;
532
+
font-size: var(--text-base);
533
533
}
534
534
535
535
.queue-tracks::-webkit-scrollbar {
+10
-10
frontend/src/lib/components/SearchModal.svelte
+10
-10
frontend/src/lib/components/SearchModal.svelte
···
303
303
background: transparent;
304
304
border: none;
305
305
outline: none;
306
-
font-size: 1rem;
306
+
font-size: var(--text-lg);
307
307
font-family: inherit;
308
308
color: var(--text-primary);
309
309
}
···
313
313
}
314
314
315
315
.search-shortcut {
316
-
font-size: 0.7rem;
316
+
font-size: var(--text-xs);
317
317
padding: 0.25rem 0.5rem;
318
318
background: var(--bg-tertiary);
319
319
border: 1px solid var(--border-default);
···
397
397
justify-content: center;
398
398
background: var(--bg-tertiary);
399
399
border-radius: var(--radius-md);
400
-
font-size: 0.9rem;
400
+
font-size: var(--text-base);
401
401
flex-shrink: 0;
402
402
position: relative;
403
403
overflow: hidden;
···
441
441
}
442
442
443
443
.result-title {
444
-
font-size: 0.9rem;
444
+
font-size: var(--text-base);
445
445
font-weight: 500;
446
446
white-space: nowrap;
447
447
overflow: hidden;
···
449
449
}
450
450
451
451
.result-subtitle {
452
-
font-size: 0.75rem;
452
+
font-size: var(--text-xs);
453
453
color: var(--text-secondary);
454
454
white-space: nowrap;
455
455
overflow: hidden;
···
471
471
padding: 2rem;
472
472
text-align: center;
473
473
color: var(--text-secondary);
474
-
font-size: 0.9rem;
474
+
font-size: var(--text-base);
475
475
}
476
476
477
477
.search-hints {
···
482
482
.search-hints p {
483
483
margin: 0 0 1rem 0;
484
484
color: var(--text-secondary);
485
-
font-size: 0.85rem;
485
+
font-size: var(--text-sm);
486
486
}
487
487
488
488
.hint-shortcuts {
···
490
490
justify-content: center;
491
491
gap: 1.5rem;
492
492
color: var(--text-muted);
493
-
font-size: 0.75rem;
493
+
font-size: var(--text-xs);
494
494
}
495
495
496
496
.hint-shortcuts span {
···
512
512
padding: 1rem;
513
513
text-align: center;
514
514
color: var(--error);
515
-
font-size: 0.85rem;
515
+
font-size: var(--text-sm);
516
516
}
517
517
518
518
/* mobile optimizations */
···
535
535
}
536
536
537
537
.search-input::placeholder {
538
-
font-size: 0.85rem;
538
+
font-size: var(--text-sm);
539
539
}
540
540
541
541
.search-results {
+2
-2
frontend/src/lib/components/SensitiveImage.svelte
+2
-2
frontend/src/lib/components/SensitiveImage.svelte
···
72
72
border: 1px solid var(--border-default);
73
73
border-radius: var(--radius-sm);
74
74
padding: 0.25rem 0.5rem;
75
-
font-size: 0.7rem;
75
+
font-size: var(--text-xs);
76
76
color: var(--text-tertiary);
77
77
white-space: nowrap;
78
78
opacity: 0;
···
89
89
transform: translate(-50%, -50%);
90
90
margin-bottom: 0;
91
91
padding: 0.5rem 0.75rem;
92
-
font-size: 0.8rem;
92
+
font-size: var(--text-sm);
93
93
}
94
94
95
95
.sensitive-wrapper.blur:hover .sensitive-tooltip {
+7
-7
frontend/src/lib/components/SettingsMenu.svelte
+7
-7
frontend/src/lib/components/SettingsMenu.svelte
···
216
216
align-items: center;
217
217
color: var(--text-primary);
218
218
font-weight: 600;
219
-
font-size: 0.95rem;
219
+
font-size: var(--text-base);
220
220
}
221
221
222
222
.close-btn {
···
245
245
246
246
.settings-section h3 {
247
247
margin: 0;
248
-
font-size: 0.85rem;
248
+
font-size: var(--text-sm);
249
249
text-transform: uppercase;
250
250
letter-spacing: 0.08em;
251
251
color: var(--text-tertiary);
···
288
288
}
289
289
290
290
.theme-btn span {
291
-
font-size: 0.7rem;
291
+
font-size: var(--text-xs);
292
292
text-transform: uppercase;
293
293
letter-spacing: 0.05em;
294
294
}
···
319
319
320
320
.color-value {
321
321
font-family: monospace;
322
-
font-size: 0.85rem;
322
+
font-size: var(--text-sm);
323
323
color: var(--text-secondary);
324
324
}
325
325
···
354
354
align-items: center;
355
355
gap: 0.65rem;
356
356
color: var(--text-primary);
357
-
font-size: 0.9rem;
357
+
font-size: var(--text-base);
358
358
}
359
359
360
360
.toggle input {
···
403
403
.toggle-hint {
404
404
margin: 0;
405
405
color: var(--text-tertiary);
406
-
font-size: 0.8rem;
406
+
font-size: var(--text-sm);
407
407
line-height: 1.3;
408
408
}
409
409
···
415
415
border-top: 1px solid var(--border-subtle);
416
416
color: var(--text-secondary);
417
417
text-decoration: none;
418
-
font-size: 0.85rem;
418
+
font-size: var(--text-sm);
419
419
transition: color 0.15s;
420
420
}
421
421
+1
-1
frontend/src/lib/components/SupporterBadge.svelte
+1
-1
frontend/src/lib/components/SupporterBadge.svelte
+5
-5
frontend/src/lib/components/TagInput.svelte
+5
-5
frontend/src/lib/components/TagInput.svelte
···
196
196
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
197
197
color: var(--accent-hover);
198
198
border-radius: var(--radius-xl);
199
-
font-size: 0.9rem;
199
+
font-size: var(--text-base);
200
200
font-weight: 500;
201
201
}
202
202
···
233
233
background: transparent;
234
234
border: none;
235
235
color: var(--text-primary);
236
-
font-size: 1rem;
236
+
font-size: var(--text-lg);
237
237
font-family: inherit;
238
238
outline: none;
239
239
}
···
249
249
250
250
.spinner {
251
251
color: var(--text-muted);
252
-
font-size: 0.85rem;
252
+
font-size: var(--text-sm);
253
253
margin-left: auto;
254
254
}
255
255
···
317
317
}
318
318
319
319
.tag-count {
320
-
font-size: 0.85rem;
320
+
font-size: var(--text-sm);
321
321
color: var(--text-tertiary);
322
322
}
323
323
···
332
332
333
333
.tag-chip {
334
334
padding: 0.3rem 0.5rem;
335
-
font-size: 0.85rem;
335
+
font-size: var(--text-sm);
336
336
}
337
337
}
338
338
</style>
+4
-4
frontend/src/lib/components/Toast.svelte
+4
-4
frontend/src/lib/components/Toast.svelte
···
63
63
border: 1px solid rgba(255, 255, 255, 0.06);
64
64
border-radius: var(--radius-md);
65
65
pointer-events: none;
66
-
font-size: 0.85rem;
66
+
font-size: var(--text-sm);
67
67
max-width: 450px;
68
68
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
69
69
}
70
70
71
71
.toast-icon {
72
-
font-size: 0.8rem;
72
+
font-size: var(--text-sm);
73
73
flex-shrink: 0;
74
74
opacity: 0.7;
75
75
margin-top: 0.1rem;
···
135
135
136
136
.toast {
137
137
padding: 0.35rem 0.7rem;
138
-
font-size: 0.8rem;
138
+
font-size: var(--text-sm);
139
139
max-width: 90vw;
140
140
}
141
141
142
142
.toast-icon {
143
-
font-size: 0.75rem;
143
+
font-size: var(--text-xs);
144
144
}
145
145
146
146
.toast-message {
+9
-9
frontend/src/lib/components/TrackActionsMenu.svelte
+9
-9
frontend/src/lib/components/TrackActionsMenu.svelte
···
474
474
}
475
475
476
476
.menu-item span {
477
-
font-size: 1rem;
477
+
font-size: var(--text-lg);
478
478
font-weight: 400;
479
479
flex: 1;
480
480
}
···
508
508
border: none;
509
509
border-bottom: 1px solid var(--border-default);
510
510
color: var(--text-secondary);
511
-
font-size: 0.9rem;
511
+
font-size: var(--text-base);
512
512
font-family: inherit;
513
513
cursor: pointer;
514
514
transition: background 0.15s;
···
534
534
border: none;
535
535
border-bottom: 1px solid var(--border-subtle);
536
536
color: var(--text-primary);
537
-
font-size: 1rem;
537
+
font-size: var(--text-lg);
538
538
font-family: inherit;
539
539
cursor: pointer;
540
540
transition: background 0.15s;
···
590
590
gap: 0.5rem;
591
591
padding: 2rem 1rem;
592
592
color: var(--text-tertiary);
593
-
font-size: 0.9rem;
593
+
font-size: var(--text-base);
594
594
}
595
595
596
596
.create-playlist-btn {
···
603
603
border: none;
604
604
border-top: 1px solid var(--border-subtle);
605
605
color: var(--accent);
606
-
font-size: 1rem;
606
+
font-size: var(--text-lg);
607
607
font-family: inherit;
608
608
cursor: pointer;
609
609
transition: background 0.15s;
···
630
630
border-radius: var(--radius-md);
631
631
color: var(--text-primary);
632
632
font-family: inherit;
633
-
font-size: 1rem;
633
+
font-size: var(--text-lg);
634
634
}
635
635
636
636
.create-form input:focus {
···
653
653
border-radius: var(--radius-md);
654
654
color: white;
655
655
font-family: inherit;
656
-
font-size: 1rem;
656
+
font-size: var(--text-lg);
657
657
font-weight: 500;
658
658
cursor: pointer;
659
659
transition: opacity 0.15s;
···
723
723
}
724
724
725
725
.menu-item span {
726
-
font-size: 0.9rem;
726
+
font-size: var(--text-base);
727
727
}
728
728
729
729
.menu-item svg {
···
737
737
738
738
.playlist-item {
739
739
padding: 0.625rem 1rem;
740
-
font-size: 0.9rem;
740
+
font-size: var(--text-base);
741
741
}
742
742
743
743
.playlist-thumb,
+12
-12
frontend/src/lib/components/TrackItem.svelte
+12
-12
frontend/src/lib/components/TrackItem.svelte
···
370
370
371
371
.track-index {
372
372
width: 24px;
373
-
font-size: 0.85rem;
373
+
font-size: var(--text-sm);
374
374
color: var(--text-muted);
375
375
text-align: center;
376
376
flex-shrink: 0;
···
540
540
align-items: flex-start;
541
541
gap: 0.15rem;
542
542
color: var(--text-secondary);
543
-
font-size: 0.9rem;
543
+
font-size: var(--text-base);
544
544
font-family: inherit;
545
545
min-width: 0;
546
546
width: 100%;
···
560
560
561
561
.metadata-separator {
562
562
display: none;
563
-
font-size: 0.7rem;
563
+
font-size: var(--text-xs);
564
564
}
565
565
566
566
.artist-link {
···
660
660
background: color-mix(in srgb, var(--accent) 15%, transparent);
661
661
color: var(--accent-hover);
662
662
border-radius: var(--radius-sm);
663
-
font-size: 0.75rem;
663
+
font-size: var(--text-xs);
664
664
font-weight: 500;
665
665
text-decoration: none;
666
666
transition: all 0.15s;
···
680
680
color: var(--text-muted);
681
681
border: none;
682
682
border-radius: var(--radius-sm);
683
-
font-size: 0.75rem;
683
+
font-size: var(--text-xs);
684
684
font-weight: 500;
685
685
font-family: inherit;
686
686
cursor: pointer;
···
695
695
}
696
696
697
697
.track-meta {
698
-
font-size: 0.8rem;
698
+
font-size: var(--text-sm);
699
699
color: var(--text-tertiary);
700
700
display: flex;
701
701
align-items: center;
···
709
709
710
710
.meta-separator {
711
711
color: var(--text-muted);
712
-
font-size: 0.7rem;
712
+
font-size: var(--text-xs);
713
713
}
714
714
715
715
.likes {
···
822
822
}
823
823
824
824
.track-title {
825
-
font-size: 0.9rem;
825
+
font-size: var(--text-base);
826
826
}
827
827
828
828
.track-metadata {
829
-
font-size: 0.8rem;
829
+
font-size: var(--text-sm);
830
830
gap: 0.35rem;
831
831
}
832
832
833
833
.track-meta {
834
-
font-size: 0.7rem;
834
+
font-size: var(--text-xs);
835
835
}
836
836
837
837
.track-actions {
···
875
875
}
876
876
877
877
.track-title {
878
-
font-size: 0.85rem;
878
+
font-size: var(--text-sm);
879
879
}
880
880
881
881
.track-metadata {
882
-
font-size: 0.75rem;
882
+
font-size: var(--text-xs);
883
883
}
884
884
885
885
.metadata-separator {
+1
-1
frontend/src/lib/components/WaveLoading.svelte
+1
-1
frontend/src/lib/components/WaveLoading.svelte
+2
-2
frontend/src/lib/components/player/PlaybackControls.svelte
+2
-2
frontend/src/lib/components/player/PlaybackControls.svelte
···
310
310
}
311
311
312
312
.time {
313
-
font-size: 0.85rem;
313
+
font-size: var(--text-sm);
314
314
color: var(--text-tertiary);
315
315
min-width: 45px;
316
316
font-variant-numeric: tabular-nums;
···
493
493
}
494
494
495
495
.time {
496
-
font-size: 0.75rem;
496
+
font-size: var(--text-xs);
497
497
min-width: 38px;
498
498
}
499
499
+3
-3
frontend/src/lib/components/player/TrackInfo.svelte
+3
-3
frontend/src/lib/components/player/TrackInfo.svelte
···
197
197
198
198
.player-title,
199
199
.player-title-link {
200
-
font-size: 0.95rem;
200
+
font-size: var(--text-base);
201
201
font-weight: 600;
202
202
color: var(--text-primary);
203
203
margin-bottom: 0;
···
384
384
385
385
.player-title,
386
386
.player-title-link {
387
-
font-size: 0.9rem;
387
+
font-size: var(--text-base);
388
388
margin-bottom: 0;
389
389
}
390
390
391
391
.player-metadata {
392
-
font-size: 0.8rem;
392
+
font-size: var(--text-sm);
393
393
}
394
394
395
395
.player-title.scrolling,
+4
-4
frontend/src/routes/+error.svelte
+4
-4
frontend/src/routes/+error.svelte
···
62
62
}
63
63
64
64
.error-message {
65
-
font-size: 1.25rem;
65
+
font-size: var(--text-2xl);
66
66
color: var(--text-secondary);
67
67
margin: 0 0 0.5rem 0;
68
68
}
69
69
70
70
.error-detail {
71
-
font-size: 1rem;
71
+
font-size: var(--text-lg);
72
72
color: var(--text-tertiary);
73
73
margin: 0 0 2rem 0;
74
74
}
···
76
76
.home-link {
77
77
color: var(--accent);
78
78
text-decoration: none;
79
-
font-size: 1.1rem;
79
+
font-size: var(--text-xl);
80
80
padding: 0.75rem 1.5rem;
81
81
border: 1px solid var(--accent);
82
82
border-radius: var(--radius-base);
···
98
98
}
99
99
100
100
.error-message {
101
-
font-size: 1.1rem;
101
+
font-size: var(--text-xl);
102
102
}
103
103
}
104
104
</style>
+22
-3
frontend/src/routes/+layout.svelte
+22
-3
frontend/src/routes/+layout.svelte
···
450
450
--text-muted: #666666;
451
451
452
452
/* typography scale */
453
-
--text-page-heading: 1.5rem;
453
+
--text-xs: 0.75rem;
454
+
--text-sm: 0.85rem;
455
+
--text-base: 0.9rem;
456
+
--text-lg: 1rem;
457
+
--text-xl: 1.1rem;
458
+
--text-2xl: 1.25rem;
459
+
--text-3xl: 1.5rem;
460
+
461
+
/* semantic typography (aliases) */
462
+
--text-page-heading: var(--text-3xl);
454
463
--text-section-heading: 1.2rem;
455
-
--text-body: 1rem;
456
-
--text-small: 0.9rem;
464
+
--text-body: var(--text-lg);
465
+
--text-small: var(--text-base);
457
466
458
467
/* border radius scale */
459
468
--radius-sm: 4px;
···
523
532
:global(:root.theme-light) :global(.tag-badge) {
524
533
background: color-mix(in srgb, var(--accent) 12%, white);
525
534
color: var(--accent-muted);
535
+
}
536
+
537
+
/* shared animation for active play buttons */
538
+
@keyframes -global-ethereal-glow {
539
+
0%, 100% {
540
+
box-shadow: 0 0 8px 1px color-mix(in srgb, var(--accent) 25%, transparent);
541
+
}
542
+
50% {
543
+
box-shadow: 0 0 14px 3px color-mix(in srgb, var(--accent) 45%, transparent);
544
+
}
526
545
}
527
546
528
547
:global(body) {
+1
-1
frontend/src/routes/+page.svelte
+1
-1
frontend/src/routes/+page.svelte
+16
-37
frontend/src/routes/costs/+page.svelte
+16
-37
frontend/src/routes/costs/+page.svelte
···
35
35
breakdown: CostBreakdown;
36
36
note: string;
37
37
};
38
-
upstash?: {
39
-
amount: number;
40
-
note: string;
41
-
};
42
38
audd: {
43
39
amount: number;
44
40
base_cost: number;
···
97
93
data.costs.fly_io.amount,
98
94
data.costs.neon.amount,
99
95
data.costs.cloudflare.amount,
100
-
data.costs.upstash?.amount ?? 0,
101
96
data.costs.audd.amount
102
97
)
103
98
: 1
···
227
222
<span class="cost-note">{data.costs.cloudflare.note}</span>
228
223
</div>
229
224
230
-
{#if data.costs.upstash}
231
-
<div class="cost-item">
232
-
<div class="cost-header">
233
-
<span class="cost-name">upstash</span>
234
-
<span class="cost-amount">{formatCurrency(data.costs.upstash.amount)}</span>
235
-
</div>
236
-
<div class="cost-bar-bg">
237
-
<div
238
-
class="cost-bar"
239
-
style="width: {barWidth(data.costs.upstash.amount, maxCost)}%"
240
-
></div>
241
-
</div>
242
-
<span class="cost-note">{data.costs.upstash.note}</span>
243
-
</div>
244
-
{/if}
245
-
246
225
<div class="cost-item">
247
226
<div class="cost-header">
248
227
<span class="cost-name">audd</span>
···
373
352
374
353
.subtitle {
375
354
color: var(--text-tertiary);
376
-
font-size: 0.9rem;
355
+
font-size: var(--text-base);
377
356
margin: 0;
378
357
}
379
358
···
391
370
392
371
.error-state .hint {
393
372
color: var(--text-tertiary);
394
-
font-size: 0.85rem;
373
+
font-size: var(--text-sm);
395
374
margin-top: 0.5rem;
396
375
}
397
376
···
411
390
}
412
391
413
392
.total-label {
414
-
font-size: 0.8rem;
393
+
font-size: var(--text-sm);
415
394
text-transform: uppercase;
416
395
letter-spacing: 0.08em;
417
396
color: var(--text-tertiary);
···
426
405
427
406
.updated {
428
407
text-align: center;
429
-
font-size: 0.75rem;
408
+
font-size: var(--text-xs);
430
409
color: var(--text-tertiary);
431
410
margin-top: 0.75rem;
432
411
}
···
438
417
439
418
.breakdown-section h2,
440
419
.audd-section h2 {
441
-
font-size: 0.8rem;
420
+
font-size: var(--text-sm);
442
421
text-transform: uppercase;
443
422
letter-spacing: 0.08em;
444
423
color: var(--text-tertiary);
···
496
475
}
497
476
498
477
.cost-note {
499
-
font-size: 0.75rem;
478
+
font-size: var(--text-xs);
500
479
color: var(--text-tertiary);
501
480
}
502
481
···
529
508
.time-range-toggle button {
530
509
padding: 0.35rem 0.75rem;
531
510
font-family: inherit;
532
-
font-size: 0.75rem;
511
+
font-size: var(--text-xs);
533
512
font-weight: 500;
534
513
background: transparent;
535
514
border: none;
···
551
530
.no-data {
552
531
text-align: center;
553
532
color: var(--text-tertiary);
554
-
font-size: 0.85rem;
533
+
font-size: var(--text-sm);
555
534
padding: 2rem;
556
535
background: var(--bg-tertiary);
557
536
border: 1px solid var(--border-subtle);
···
566
545
}
567
546
568
547
.audd-explainer {
569
-
font-size: 0.8rem;
548
+
font-size: var(--text-sm);
570
549
color: var(--text-secondary);
571
550
margin-bottom: 1.5rem;
572
551
line-height: 1.5;
···
587
566
}
588
567
589
568
.stat-value {
590
-
font-size: 1.25rem;
569
+
font-size: var(--text-2xl);
591
570
font-weight: 700;
592
571
color: var(--text-primary);
593
572
font-variant-numeric: tabular-nums;
594
573
}
595
574
596
575
.stat-label {
597
-
font-size: 0.7rem;
576
+
font-size: var(--text-xs);
598
577
color: var(--text-tertiary);
599
578
text-align: center;
600
579
margin-top: 0.25rem;
···
610
589
}
611
590
612
591
.daily-chart h3 {
613
-
font-size: 0.75rem;
592
+
font-size: var(--text-xs);
614
593
text-transform: uppercase;
615
594
letter-spacing: 0.05em;
616
595
color: var(--text-tertiary);
···
683
662
684
663
.support-text h3 {
685
664
margin: 0 0 0.5rem;
686
-
font-size: 1.1rem;
665
+
font-size: var(--text-xl);
687
666
color: var(--text-primary);
688
667
}
689
668
690
669
.support-text p {
691
670
margin: 0 0 1.5rem;
692
671
color: var(--text-secondary);
693
-
font-size: 0.9rem;
672
+
font-size: var(--text-base);
694
673
}
695
674
696
675
.support-button {
···
703
682
border-radius: var(--radius-md);
704
683
text-decoration: none;
705
684
font-weight: 600;
706
-
font-size: 0.9rem;
685
+
font-size: var(--text-base);
707
686
transition: transform 0.15s, box-shadow 0.15s;
708
687
}
709
688
···
715
694
/* footer */
716
695
.footer-note {
717
696
text-align: center;
718
-
font-size: 0.8rem;
697
+
font-size: var(--text-sm);
719
698
color: var(--text-tertiary);
720
699
padding-bottom: 1rem;
721
700
}
+15
-15
frontend/src/routes/library/+page.svelte
+15
-15
frontend/src/routes/library/+page.svelte
···
264
264
}
265
265
266
266
.page-header p {
267
-
font-size: 0.9rem;
267
+
font-size: var(--text-base);
268
268
color: var(--text-tertiary);
269
269
margin: 0;
270
270
}
···
331
331
}
332
332
333
333
.collection-info h3 {
334
-
font-size: 1rem;
334
+
font-size: var(--text-lg);
335
335
font-weight: 600;
336
336
color: var(--text-primary);
337
337
margin: 0 0 0.15rem 0;
···
341
341
}
342
342
343
343
.collection-info p {
344
-
font-size: 0.85rem;
344
+
font-size: var(--text-sm);
345
345
color: var(--text-tertiary);
346
346
margin: 0;
347
347
}
···
370
370
}
371
371
372
372
.section-header h2 {
373
-
font-size: 1.1rem;
373
+
font-size: var(--text-xl);
374
374
font-weight: 600;
375
375
color: var(--text-primary);
376
376
margin: 0;
···
432
432
}
433
433
434
434
.empty-state p {
435
-
font-size: 1rem;
435
+
font-size: var(--text-lg);
436
436
font-weight: 500;
437
437
color: var(--text-secondary);
438
438
margin: 0 0 0.25rem 0;
439
439
}
440
440
441
441
.empty-state span {
442
-
font-size: 0.85rem;
442
+
font-size: var(--text-sm);
443
443
color: var(--text-muted);
444
444
}
445
445
···
476
476
}
477
477
478
478
.modal-header h3 {
479
-
font-size: 1.1rem;
479
+
font-size: var(--text-xl);
480
480
font-weight: 600;
481
481
color: var(--text-primary);
482
482
margin: 0;
···
507
507
508
508
.modal-body label {
509
509
display: block;
510
-
font-size: 0.85rem;
510
+
font-size: var(--text-sm);
511
511
font-weight: 500;
512
512
color: var(--text-secondary);
513
513
margin-bottom: 0.5rem;
···
520
520
border: 1px solid var(--border-default);
521
521
border-radius: var(--radius-md);
522
522
font-family: inherit;
523
-
font-size: 1rem;
523
+
font-size: var(--text-lg);
524
524
color: var(--text-primary);
525
525
transition: border-color 0.15s;
526
526
}
···
536
536
537
537
.modal-body .error {
538
538
margin: 0.5rem 0 0 0;
539
-
font-size: 0.85rem;
539
+
font-size: var(--text-sm);
540
540
color: #ef4444;
541
541
}
542
542
···
552
552
padding: 0.625rem 1.25rem;
553
553
border-radius: var(--radius-md);
554
554
font-family: inherit;
555
-
font-size: 0.9rem;
555
+
font-size: var(--text-base);
556
556
font-weight: 500;
557
557
cursor: pointer;
558
558
transition: all 0.15s;
···
596
596
}
597
597
598
598
.page-header h1 {
599
-
font-size: 1.5rem;
599
+
font-size: var(--text-3xl);
600
600
}
601
601
602
602
.collection-card {
···
609
609
}
610
610
611
611
.collection-info h3 {
612
-
font-size: 0.95rem;
612
+
font-size: var(--text-base);
613
613
}
614
614
615
615
.section-header h2 {
616
-
font-size: 1rem;
616
+
font-size: var(--text-lg);
617
617
}
618
618
619
619
.create-btn {
620
620
padding: 0.5rem 0.875rem;
621
-
font-size: 0.85rem;
621
+
font-size: var(--text-sm);
622
622
}
623
623
624
624
.empty-state {
+8
-8
frontend/src/routes/liked/+page.svelte
+8
-8
frontend/src/routes/liked/+page.svelte
···
345
345
}
346
346
347
347
.count {
348
-
font-size: 0.85rem;
348
+
font-size: var(--text-sm);
349
349
font-weight: 500;
350
350
color: var(--text-tertiary);
351
351
background: var(--bg-tertiary);
···
364
364
padding: 0.75rem 1.5rem;
365
365
border-radius: var(--radius-2xl);
366
366
font-weight: 600;
367
-
font-size: 0.95rem;
367
+
font-size: var(--text-base);
368
368
font-family: inherit;
369
369
cursor: pointer;
370
370
transition: all 0.2s;
···
419
419
}
420
420
421
421
.empty-state h2 {
422
-
font-size: 1.5rem;
422
+
font-size: var(--text-3xl);
423
423
font-weight: 600;
424
424
color: var(--text-secondary);
425
425
margin: 0 0 0.5rem 0;
426
426
}
427
427
428
428
.empty-state p {
429
-
font-size: 0.95rem;
429
+
font-size: var(--text-base);
430
430
margin: 0;
431
431
}
432
432
···
505
505
}
506
506
507
507
.section-header h2 {
508
-
font-size: 1.25rem;
508
+
font-size: var(--text-2xl);
509
509
}
510
510
511
511
.count {
512
-
font-size: 0.8rem;
512
+
font-size: var(--text-sm);
513
513
padding: 0.15rem 0.45rem;
514
514
}
515
515
···
518
518
}
519
519
520
520
.empty-state h2 {
521
-
font-size: 1.25rem;
521
+
font-size: var(--text-2xl);
522
522
}
523
523
524
524
.header-actions {
···
528
528
.queue-button,
529
529
.reorder-button {
530
530
padding: 0.6rem 1rem;
531
-
font-size: 0.85rem;
531
+
font-size: var(--text-sm);
532
532
}
533
533
534
534
.queue-button svg,
+14
-14
frontend/src/routes/liked/[handle]/+page.svelte
+14
-14
frontend/src/routes/liked/[handle]/+page.svelte
···
137
137
justify-content: center;
138
138
background: var(--bg-tertiary);
139
139
color: var(--text-secondary);
140
-
font-size: 1.5rem;
140
+
font-size: var(--text-3xl);
141
141
font-weight: 600;
142
142
}
143
143
···
149
149
}
150
150
151
151
.user-info h1 {
152
-
font-size: 1.5rem;
152
+
font-size: var(--text-3xl);
153
153
font-weight: 700;
154
154
color: var(--text-primary);
155
155
margin: 0;
···
159
159
}
160
160
161
161
.handle {
162
-
font-size: 0.9rem;
162
+
font-size: var(--text-base);
163
163
color: var(--text-tertiary);
164
164
text-decoration: none;
165
165
transition: color 0.15s;
···
189
189
}
190
190
191
191
.count {
192
-
font-size: 0.95rem;
192
+
font-size: var(--text-base);
193
193
font-weight: 500;
194
194
color: var(--text-secondary);
195
195
}
···
209
209
border: 1px solid var(--border-default);
210
210
color: var(--text-secondary);
211
211
border-radius: var(--radius-base);
212
-
font-size: 0.85rem;
212
+
font-size: var(--text-sm);
213
213
font-family: inherit;
214
214
cursor: pointer;
215
215
transition: all 0.15s;
···
241
241
}
242
242
243
243
.empty-state h2 {
244
-
font-size: 1.5rem;
244
+
font-size: var(--text-3xl);
245
245
font-weight: 600;
246
246
color: var(--text-secondary);
247
247
margin: 0 0 0.5rem 0;
248
248
}
249
249
250
250
.empty-state p {
251
-
font-size: 0.95rem;
251
+
font-size: var(--text-base);
252
252
margin: 0;
253
253
}
254
254
···
275
275
}
276
276
277
277
.avatar-placeholder {
278
-
font-size: 1.25rem;
278
+
font-size: var(--text-2xl);
279
279
}
280
280
281
281
.user-info h1 {
282
-
font-size: 1.25rem;
282
+
font-size: var(--text-2xl);
283
283
}
284
284
285
285
.handle {
286
-
font-size: 0.85rem;
286
+
font-size: var(--text-sm);
287
287
}
288
288
289
289
.section-header h2 {
290
-
font-size: 1.25rem;
290
+
font-size: var(--text-2xl);
291
291
}
292
292
293
293
.count {
294
-
font-size: 0.85rem;
294
+
font-size: var(--text-sm);
295
295
}
296
296
297
297
.empty-state {
···
299
299
}
300
300
301
301
.empty-state h2 {
302
-
font-size: 1.25rem;
302
+
font-size: var(--text-2xl);
303
303
}
304
304
305
305
.btn-action {
306
306
padding: 0.45rem 0.7rem;
307
-
font-size: 0.8rem;
307
+
font-size: var(--text-sm);
308
308
}
309
309
310
310
.btn-action svg {
+5
-5
frontend/src/routes/login/+page.svelte
+5
-5
frontend/src/routes/login/+page.svelte
···
171
171
172
172
label {
173
173
color: var(--text-secondary);
174
-
font-size: 0.9rem;
174
+
font-size: var(--text-base);
175
175
}
176
176
177
177
button.primary {
···
181
181
color: white;
182
182
border: none;
183
183
border-radius: var(--radius-md);
184
-
font-size: 0.95rem;
184
+
font-size: var(--text-base);
185
185
font-weight: 500;
186
186
font-family: inherit;
187
187
cursor: pointer;
···
213
213
border: none;
214
214
color: var(--text-secondary);
215
215
font-family: inherit;
216
-
font-size: 0.9rem;
216
+
font-size: var(--text-base);
217
217
cursor: pointer;
218
218
text-align: left;
219
219
}
···
234
234
.faq-content {
235
235
padding: 0 0 1rem 0;
236
236
color: var(--text-tertiary);
237
-
font-size: 0.85rem;
237
+
font-size: var(--text-sm);
238
238
line-height: 1.6;
239
239
}
240
240
···
269
269
}
270
270
271
271
h1 {
272
-
font-size: 1.5rem;
272
+
font-size: var(--text-3xl);
273
273
}
274
274
}
275
275
</style>
+49
-30
frontend/src/routes/playlist/[id]/+page.svelte
+49
-30
frontend/src/routes/playlist/[id]/+page.svelte
···
607
607
608
608
// check if user owns this playlist
609
609
const isOwner = $derived(auth.user?.did === playlist.owner_did);
610
+
611
+
// check if current track is from this playlist (active, regardless of paused state)
612
+
const isPlaylistActive = $derived(
613
+
player.currentTrack !== null &&
614
+
tracks.some(t => t.id === player.currentTrack?.id)
615
+
);
616
+
617
+
// check if actively playing (not paused)
618
+
const isPlaylistPlaying = $derived(isPlaylistActive && !player.paused);
610
619
</script>
611
620
612
621
<svelte:window on:keydown={handleKeydown} />
···
865
874
</div>
866
875
867
876
<div class="playlist-actions">
868
-
<button class="play-button" onclick={playNow}>
869
-
<svg
870
-
width="20"
871
-
height="20"
872
-
viewBox="0 0 24 24"
873
-
fill="currentColor"
874
-
>
875
-
<path d="M8 5v14l11-7z" />
876
-
</svg>
877
-
play now
877
+
<button
878
+
class="play-button"
879
+
class:is-playing={isPlaylistPlaying}
880
+
onclick={() => isPlaylistActive ? player.togglePlayPause() : playNow()}
881
+
>
882
+
{#if isPlaylistPlaying}
883
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
884
+
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
885
+
</svg>
886
+
pause
887
+
{:else}
888
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
889
+
<path d="M8 5v14l11-7z" />
890
+
</svg>
891
+
play
892
+
{/if}
878
893
</button>
879
894
<button class="queue-button" onclick={addToQueue}>
880
895
<svg
···
1400
1415
1401
1416
.art-edit-overlay span {
1402
1417
font-family: inherit;
1403
-
font-size: 0.85rem;
1418
+
font-size: var(--text-sm);
1404
1419
font-weight: 500;
1405
1420
}
1406
1421
···
1432
1447
1433
1448
.playlist-type {
1434
1449
text-transform: uppercase;
1435
-
font-size: 0.75rem;
1450
+
font-size: var(--text-xs);
1436
1451
font-weight: 600;
1437
1452
letter-spacing: 0.1em;
1438
1453
color: var(--text-tertiary);
···
1473
1488
display: flex;
1474
1489
align-items: center;
1475
1490
gap: 0.75rem;
1476
-
font-size: 0.95rem;
1491
+
font-size: var(--text-base);
1477
1492
color: var(--text-secondary);
1478
1493
}
1479
1494
···
1490
1505
1491
1506
.meta-separator {
1492
1507
color: var(--text-muted);
1493
-
font-size: 0.7rem;
1508
+
font-size: var(--text-xs);
1494
1509
}
1495
1510
1496
1511
.show-on-profile-toggle {
···
1499
1514
gap: 0.5rem;
1500
1515
margin-top: 0.75rem;
1501
1516
cursor: pointer;
1502
-
font-size: 0.85rem;
1517
+
font-size: var(--text-sm);
1503
1518
color: var(--text-secondary);
1504
1519
}
1505
1520
···
1562
1577
padding: 0.75rem 1.5rem;
1563
1578
border-radius: var(--radius-2xl);
1564
1579
font-weight: 600;
1565
-
font-size: 0.95rem;
1580
+
font-size: var(--text-base);
1566
1581
font-family: inherit;
1567
1582
cursor: pointer;
1568
1583
transition: all 0.2s;
···
1581
1596
transform: scale(1.05);
1582
1597
}
1583
1598
1599
+
.play-button.is-playing {
1600
+
animation: ethereal-glow 3s ease-in-out infinite;
1601
+
}
1602
+
1584
1603
.queue-button {
1585
1604
background: var(--glass-btn-bg, transparent);
1586
1605
color: var(--text-primary);
···
1615
1634
}
1616
1635
1617
1636
.section-heading {
1618
-
font-size: 1.25rem;
1637
+
font-size: var(--text-2xl);
1619
1638
font-weight: 600;
1620
1639
color: var(--text-primary);
1621
1640
margin-bottom: 1rem;
···
1735
1754
border-radius: var(--radius-md);
1736
1755
color: var(--text-tertiary);
1737
1756
font-family: inherit;
1738
-
font-size: 0.9rem;
1757
+
font-size: var(--text-base);
1739
1758
cursor: pointer;
1740
1759
transition: all 0.2s;
1741
1760
}
···
1769
1788
}
1770
1789
1771
1790
.empty-state p {
1772
-
font-size: 1rem;
1791
+
font-size: var(--text-lg);
1773
1792
font-weight: 500;
1774
1793
color: var(--text-secondary);
1775
1794
margin: 0 0 0.25rem 0;
1776
1795
}
1777
1796
1778
1797
.empty-state span {
1779
-
font-size: 0.85rem;
1798
+
font-size: var(--text-sm);
1780
1799
color: var(--text-muted);
1781
1800
margin-bottom: 1.5rem;
1782
1801
}
···
1788
1807
border: none;
1789
1808
border-radius: var(--radius-md);
1790
1809
font-family: inherit;
1791
-
font-size: 0.9rem;
1810
+
font-size: var(--text-base);
1792
1811
font-weight: 500;
1793
1812
cursor: pointer;
1794
1813
transition: all 0.15s;
···
1838
1857
}
1839
1858
1840
1859
.modal-header h3 {
1841
-
font-size: 1.1rem;
1860
+
font-size: var(--text-xl);
1842
1861
font-weight: 600;
1843
1862
color: var(--text-primary);
1844
1863
margin: 0;
···
1877
1896
background: transparent;
1878
1897
border: none;
1879
1898
font-family: inherit;
1880
-
font-size: 1rem;
1899
+
font-size: var(--text-lg);
1881
1900
color: var(--text-primary);
1882
1901
outline: none;
1883
1902
}
···
1898
1917
padding: 2rem 1.5rem;
1899
1918
text-align: center;
1900
1919
color: var(--text-muted);
1901
-
font-size: 0.9rem;
1920
+
font-size: var(--text-base);
1902
1921
margin: 0;
1903
1922
}
1904
1923
···
1947
1966
}
1948
1967
1949
1968
.result-title {
1950
-
font-size: 0.9rem;
1969
+
font-size: var(--text-base);
1951
1970
font-weight: 500;
1952
1971
color: var(--text-primary);
1953
1972
white-space: nowrap;
···
1956
1975
}
1957
1976
1958
1977
.result-artist {
1959
-
font-size: 0.8rem;
1978
+
font-size: var(--text-sm);
1960
1979
color: var(--text-tertiary);
1961
1980
white-space: nowrap;
1962
1981
overflow: hidden;
···
1994
2013
.modal-body p {
1995
2014
margin: 0;
1996
2015
color: var(--text-secondary);
1997
-
font-size: 0.95rem;
2016
+
font-size: var(--text-base);
1998
2017
line-height: 1.5;
1999
2018
}
2000
2019
···
2010
2029
padding: 0.625rem 1.25rem;
2011
2030
border-radius: var(--radius-md);
2012
2031
font-family: inherit;
2013
-
font-size: 0.9rem;
2032
+
font-size: var(--text-base);
2014
2033
font-weight: 500;
2015
2034
cursor: pointer;
2016
2035
transition: all 0.15s;
···
2099
2118
}
2100
2119
2101
2120
.playlist-meta {
2102
-
font-size: 0.85rem;
2121
+
font-size: var(--text-sm);
2103
2122
}
2104
2123
2105
2124
.playlist-actions {
···
2142
2161
}
2143
2162
2144
2163
.playlist-meta {
2145
-
font-size: 0.8rem;
2164
+
font-size: var(--text-sm);
2146
2165
flex-wrap: wrap;
2147
2166
}
2148
2167
}
+78
-89
frontend/src/routes/portal/+page.svelte
+78
-89
frontend/src/routes/portal/+page.svelte
···
106
106
}
107
107
108
108
try {
109
-
await loadMyTracks();
110
-
await loadArtistProfile();
111
-
await loadMyAlbums();
112
-
await loadMyPlaylists();
109
+
await Promise.all([
110
+
loadMyTracks(),
111
+
loadArtistProfile(),
112
+
loadMyAlbums(),
113
+
loadMyPlaylists()
114
+
]);
113
115
} catch (_e) {
114
116
console.error('error loading portal data:', _e);
115
117
error = 'failed to load portal data';
···
1142
1144
.view-profile-link {
1143
1145
color: var(--text-secondary);
1144
1146
text-decoration: none;
1145
-
font-size: 0.8rem;
1147
+
font-size: var(--text-sm);
1146
1148
padding: 0.35rem 0.6rem;
1147
1149
background: var(--bg-tertiary);
1148
1150
border-radius: var(--radius-sm);
···
1160
1162
.settings-link {
1161
1163
color: var(--text-secondary);
1162
1164
text-decoration: none;
1163
-
font-size: 0.8rem;
1165
+
font-size: var(--text-sm);
1164
1166
padding: 0.35rem 0.6rem;
1165
1167
background: var(--bg-tertiary);
1166
1168
border-radius: var(--radius-sm);
···
1219
1221
.upload-card-title {
1220
1222
display: block;
1221
1223
font-weight: 600;
1222
-
font-size: 0.95rem;
1224
+
font-size: var(--text-base);
1223
1225
color: var(--text-primary);
1224
1226
}
1225
1227
1226
1228
.upload-card-subtitle {
1227
1229
display: block;
1228
-
font-size: 0.8rem;
1230
+
font-size: var(--text-sm);
1229
1231
color: var(--text-tertiary);
1230
1232
}
1231
1233
···
1259
1261
display: block;
1260
1262
color: var(--text-secondary);
1261
1263
margin-bottom: 0.4rem;
1262
-
font-size: 0.85rem;
1264
+
font-size: var(--text-sm);
1263
1265
}
1264
1266
1265
-
input[type='text'] {
1267
+
input[type='text'],
1268
+
input[type='url'],
1269
+
textarea {
1266
1270
width: 100%;
1267
1271
padding: 0.6rem 0.75rem;
1268
1272
background: var(--bg-primary);
1269
1273
border: 1px solid var(--border-default);
1270
1274
border-radius: var(--radius-sm);
1271
1275
color: var(--text-primary);
1272
-
font-size: 0.95rem;
1276
+
font-size: var(--text-base);
1273
1277
font-family: inherit;
1274
1278
transition: all 0.15s;
1275
1279
}
1276
1280
1277
-
input[type='text']:focus {
1281
+
input[type='text']:focus,
1282
+
input[type='url']:focus,
1283
+
textarea:focus {
1278
1284
outline: none;
1279
1285
border-color: var(--accent);
1280
1286
}
1281
1287
1282
-
input[type='text']:disabled {
1288
+
input[type='text']:disabled,
1289
+
input[type='url']:disabled,
1290
+
textarea:disabled {
1283
1291
opacity: 0.5;
1284
1292
cursor: not-allowed;
1285
1293
}
1286
1294
1287
1295
textarea {
1288
-
width: 100%;
1289
-
padding: 0.6rem 0.75rem;
1290
-
background: var(--bg-primary);
1291
-
border: 1px solid var(--border-default);
1292
-
border-radius: var(--radius-sm);
1293
-
color: var(--text-primary);
1294
-
font-size: 0.95rem;
1295
-
font-family: inherit;
1296
-
transition: all 0.15s;
1297
1296
resize: vertical;
1298
1297
min-height: 80px;
1299
1298
}
1300
1299
1301
-
textarea:focus {
1302
-
outline: none;
1303
-
border-color: var(--accent);
1304
-
}
1305
-
1306
-
textarea:disabled {
1307
-
opacity: 0.5;
1308
-
cursor: not-allowed;
1309
-
}
1310
-
1311
1300
.hint {
1312
1301
margin-top: 0.35rem;
1313
-
font-size: 0.75rem;
1302
+
font-size: var(--text-xs);
1314
1303
color: var(--text-muted);
1315
1304
}
1316
1305
···
1328
1317
display: block;
1329
1318
color: var(--text-secondary);
1330
1319
margin-bottom: 0.6rem;
1331
-
font-size: 0.85rem;
1320
+
font-size: var(--text-sm);
1332
1321
}
1333
1322
1334
1323
.support-options {
···
1368
1357
}
1369
1358
1370
1359
.support-option span {
1371
-
font-size: 0.9rem;
1360
+
font-size: var(--text-base);
1372
1361
color: var(--text-primary);
1373
1362
}
1374
1363
1375
1364
.support-status {
1376
1365
margin-left: auto;
1377
-
font-size: 0.75rem;
1366
+
font-size: var(--text-xs);
1378
1367
color: var(--text-tertiary);
1379
1368
}
1380
1369
1381
1370
.support-setup-link,
1382
1371
.support-status-link {
1383
1372
margin-left: auto;
1384
-
font-size: 0.75rem;
1373
+
font-size: var(--text-xs);
1385
1374
text-decoration: none;
1386
1375
}
1387
1376
···
1414
1403
border: 1px solid var(--border-default);
1415
1404
border-radius: var(--radius-sm);
1416
1405
color: var(--text-primary);
1417
-
font-size: 0.95rem;
1406
+
font-size: var(--text-base);
1418
1407
font-family: inherit;
1419
1408
transition: all 0.15s;
1420
1409
margin-bottom: 0.5rem;
···
1452
1441
border: 1px solid var(--border-default);
1453
1442
border-radius: var(--radius-sm);
1454
1443
color: var(--text-primary);
1455
-
font-size: 0.9rem;
1444
+
font-size: var(--text-base);
1456
1445
font-family: inherit;
1457
1446
cursor: pointer;
1458
1447
}
···
1464
1453
1465
1454
.file-info {
1466
1455
margin-top: 0.5rem;
1467
-
font-size: 0.85rem;
1456
+
font-size: var(--text-sm);
1468
1457
color: var(--text-muted);
1469
1458
}
1470
1459
···
1475
1464
color: var(--text-primary);
1476
1465
border: none;
1477
1466
border-radius: var(--radius-sm);
1478
-
font-size: 1rem;
1467
+
font-size: var(--text-lg);
1479
1468
font-weight: 600;
1480
1469
font-family: inherit;
1481
1470
cursor: pointer;
···
1586
1575
}
1587
1576
1588
1577
.track-view-link {
1589
-
font-size: 0.7rem;
1578
+
font-size: var(--text-xs);
1590
1579
color: var(--text-muted);
1591
1580
text-decoration: none;
1592
1581
transition: color 0.15s;
···
1633
1622
}
1634
1623
1635
1624
.edit-label {
1636
-
font-size: 0.85rem;
1625
+
font-size: var(--text-sm);
1637
1626
color: var(--text-secondary);
1638
1627
}
1639
1628
···
1642
1631
align-items: center;
1643
1632
gap: 0.5rem;
1644
1633
cursor: pointer;
1645
-
font-size: 0.9rem;
1634
+
font-size: var(--text-base);
1646
1635
color: var(--text-primary);
1647
1636
}
1648
1637
···
1653
1642
}
1654
1643
1655
1644
.field-hint {
1656
-
font-size: 0.8rem;
1645
+
font-size: var(--text-sm);
1657
1646
color: var(--text-tertiary);
1658
1647
margin-top: 0.25rem;
1659
1648
}
···
1669
1658
1670
1659
.track-title {
1671
1660
font-weight: 600;
1672
-
font-size: 1rem;
1661
+
font-size: var(--text-lg);
1673
1662
margin-bottom: 0.25rem;
1674
1663
color: var(--text-primary);
1675
1664
display: flex;
···
1705
1694
}
1706
1695
1707
1696
.track-meta {
1708
-
font-size: 0.9rem;
1697
+
font-size: var(--text-base);
1709
1698
color: var(--text-secondary);
1710
1699
margin-bottom: 0.25rem;
1711
1700
display: flex;
···
1774
1763
background: color-mix(in srgb, var(--accent) 15%, transparent);
1775
1764
color: var(--accent-hover);
1776
1765
border-radius: var(--radius-sm);
1777
-
font-size: 0.8rem;
1766
+
font-size: var(--text-sm);
1778
1767
font-weight: 500;
1779
1768
text-decoration: none;
1780
1769
transition: all 0.15s;
···
1786
1775
}
1787
1776
1788
1777
.track-date {
1789
-
font-size: 0.85rem;
1778
+
font-size: var(--text-sm);
1790
1779
color: var(--text-muted);
1791
1780
}
1792
1781
···
1854
1843
border: 1px solid var(--border-default);
1855
1844
border-radius: var(--radius-sm);
1856
1845
color: var(--text-primary);
1857
-
font-size: 0.9rem;
1846
+
font-size: var(--text-base);
1858
1847
font-family: inherit;
1859
1848
}
1860
1849
···
1878
1867
1879
1868
.current-image-label {
1880
1869
color: var(--text-tertiary);
1881
-
font-size: 0.85rem;
1870
+
font-size: var(--text-sm);
1882
1871
}
1883
1872
1884
1873
.edit-input:focus {
···
1949
1938
}
1950
1939
1951
1940
.album-title {
1952
-
font-size: 1rem;
1941
+
font-size: var(--text-lg);
1953
1942
font-weight: 600;
1954
1943
color: var(--text-primary);
1955
1944
margin: 0 0 0.25rem 0;
···
1959
1948
}
1960
1949
1961
1950
.album-stats {
1962
-
font-size: 0.85rem;
1951
+
font-size: var(--text-sm);
1963
1952
color: var(--text-tertiary);
1964
1953
margin: 0;
1965
1954
}
···
1977
1966
.view-playlists-link {
1978
1967
color: var(--text-secondary);
1979
1968
text-decoration: none;
1980
-
font-size: 0.8rem;
1969
+
font-size: var(--text-sm);
1981
1970
padding: 0.35rem 0.6rem;
1982
1971
background: var(--bg-tertiary);
1983
1972
border-radius: var(--radius-sm);
···
2040
2029
}
2041
2030
2042
2031
.playlist-title {
2043
-
font-size: 1rem;
2032
+
font-size: var(--text-lg);
2044
2033
font-weight: 600;
2045
2034
color: var(--text-primary);
2046
2035
margin: 0 0 0.25rem 0;
···
2050
2039
}
2051
2040
2052
2041
.playlist-stats {
2053
-
font-size: 0.85rem;
2042
+
font-size: var(--text-sm);
2054
2043
color: var(--text-tertiary);
2055
2044
margin: 0;
2056
2045
}
···
2087
2076
}
2088
2077
2089
2078
.control-info h3 {
2090
-
font-size: 0.9rem;
2079
+
font-size: var(--text-base);
2091
2080
font-weight: 600;
2092
2081
margin: 0 0 0.15rem 0;
2093
2082
color: var(--text-primary);
2094
2083
}
2095
2084
2096
2085
.control-description {
2097
-
font-size: 0.75rem;
2086
+
font-size: var(--text-xs);
2098
2087
color: var(--text-tertiary);
2099
2088
margin: 0;
2100
2089
line-height: 1.4;
···
2106
2095
color: var(--text-primary);
2107
2096
border: none;
2108
2097
border-radius: var(--radius-base);
2109
-
font-size: 0.9rem;
2098
+
font-size: var(--text-base);
2110
2099
font-weight: 600;
2111
2100
cursor: pointer;
2112
2101
transition: all 0.2s;
···
2152
2141
border: 1px solid var(--error);
2153
2142
border-radius: var(--radius-base);
2154
2143
font-family: inherit;
2155
-
font-size: 0.9rem;
2144
+
font-size: var(--text-base);
2156
2145
font-weight: 600;
2157
2146
cursor: pointer;
2158
2147
transition: all 0.2s;
···
2174
2163
.delete-warning {
2175
2164
margin: 0 0 1rem;
2176
2165
color: var(--error);
2177
-
font-size: 0.9rem;
2166
+
font-size: var(--text-base);
2178
2167
line-height: 1.5;
2179
2168
}
2180
2169
···
2189
2178
display: flex;
2190
2179
align-items: center;
2191
2180
gap: 0.5rem;
2192
-
font-size: 0.9rem;
2181
+
font-size: var(--text-base);
2193
2182
color: var(--text-primary);
2194
2183
cursor: pointer;
2195
2184
}
···
2202
2191
2203
2192
.atproto-note {
2204
2193
margin: 0.5rem 0 0;
2205
-
font-size: 0.8rem;
2194
+
font-size: var(--text-sm);
2206
2195
color: var(--text-tertiary);
2207
2196
}
2208
2197
···
2220
2209
padding: 0.5rem;
2221
2210
background: color-mix(in srgb, var(--warning) 10%, transparent);
2222
2211
border-radius: var(--radius-sm);
2223
-
font-size: 0.8rem;
2212
+
font-size: var(--text-sm);
2224
2213
color: var(--warning);
2225
2214
}
2226
2215
2227
2216
.confirm-prompt {
2228
2217
margin: 0 0 0.5rem;
2229
-
font-size: 0.9rem;
2218
+
font-size: var(--text-base);
2230
2219
color: var(--text-secondary);
2231
2220
}
2232
2221
···
2237
2226
border: 1px solid var(--border-default);
2238
2227
border-radius: var(--radius-base);
2239
2228
color: var(--text-primary);
2240
-
font-size: 0.9rem;
2229
+
font-size: var(--text-base);
2241
2230
font-family: inherit;
2242
2231
margin-bottom: 1rem;
2243
2232
}
···
2260
2249
border-radius: var(--radius-base);
2261
2250
color: var(--text-secondary);
2262
2251
font-family: inherit;
2263
-
font-size: 0.9rem;
2252
+
font-size: var(--text-base);
2264
2253
cursor: pointer;
2265
2254
transition: all 0.15s;
2266
2255
}
···
2282
2271
border-radius: var(--radius-base);
2283
2272
color: white;
2284
2273
font-family: inherit;
2285
-
font-size: 0.9rem;
2274
+
font-size: var(--text-base);
2286
2275
font-weight: 600;
2287
2276
cursor: pointer;
2288
2277
transition: all 0.15s;
···
2308
2297
}
2309
2298
2310
2299
.portal-header h2 {
2311
-
font-size: 1.25rem;
2300
+
font-size: var(--text-2xl);
2312
2301
}
2313
2302
2314
2303
.profile-section h2,
···
2316
2305
.albums-section h2,
2317
2306
.playlists-section h2,
2318
2307
.data-section h2 {
2319
-
font-size: 1.1rem;
2308
+
font-size: var(--text-xl);
2320
2309
}
2321
2310
2322
2311
.section-header {
···
2324
2313
}
2325
2314
2326
2315
.view-profile-link {
2327
-
font-size: 0.75rem;
2316
+
font-size: var(--text-xs);
2328
2317
padding: 0.3rem 0.5rem;
2329
2318
}
2330
2319
···
2337
2326
}
2338
2327
2339
2328
label {
2340
-
font-size: 0.8rem;
2329
+
font-size: var(--text-sm);
2341
2330
margin-bottom: 0.3rem;
2342
2331
}
2343
2332
···
2345
2334
input[type='url'],
2346
2335
textarea {
2347
2336
padding: 0.5rem 0.6rem;
2348
-
font-size: 0.9rem;
2337
+
font-size: var(--text-base);
2349
2338
}
2350
2339
2351
2340
textarea {
···
2353
2342
}
2354
2343
2355
2344
.hint {
2356
-
font-size: 0.7rem;
2345
+
font-size: var(--text-xs);
2357
2346
}
2358
2347
2359
2348
.avatar-preview img {
···
2363
2352
2364
2353
button[type="submit"] {
2365
2354
padding: 0.6rem;
2366
-
font-size: 0.9rem;
2355
+
font-size: var(--text-base);
2367
2356
}
2368
2357
2369
2358
/* upload card mobile */
···
2383
2372
}
2384
2373
2385
2374
.upload-card-title {
2386
-
font-size: 0.9rem;
2375
+
font-size: var(--text-base);
2387
2376
}
2388
2377
2389
2378
.upload-card-subtitle {
2390
-
font-size: 0.75rem;
2379
+
font-size: var(--text-xs);
2391
2380
}
2392
2381
2393
2382
/* tracks mobile */
···
2421
2410
}
2422
2411
2423
2412
.track-title {
2424
-
font-size: 0.9rem;
2413
+
font-size: var(--text-base);
2425
2414
}
2426
2415
2427
2416
.track-meta {
2428
-
font-size: 0.8rem;
2417
+
font-size: var(--text-sm);
2429
2418
}
2430
2419
2431
2420
.track-date {
2432
-
font-size: 0.75rem;
2421
+
font-size: var(--text-xs);
2433
2422
}
2434
2423
2435
2424
.track-actions {
···
2457
2446
}
2458
2447
2459
2448
.edit-label {
2460
-
font-size: 0.8rem;
2449
+
font-size: var(--text-sm);
2461
2450
}
2462
2451
2463
2452
.edit-input {
2464
2453
padding: 0.45rem 0.5rem;
2465
-
font-size: 0.85rem;
2454
+
font-size: var(--text-sm);
2466
2455
}
2467
2456
2468
2457
.edit-actions {
···
2476
2465
}
2477
2466
2478
2467
.control-info h3 {
2479
-
font-size: 0.85rem;
2468
+
font-size: var(--text-sm);
2480
2469
}
2481
2470
2482
2471
.control-description {
2483
-
font-size: 0.7rem;
2472
+
font-size: var(--text-xs);
2484
2473
}
2485
2474
2486
2475
.export-btn {
2487
2476
padding: 0.5rem 0.85rem;
2488
-
font-size: 0.8rem;
2477
+
font-size: var(--text-sm);
2489
2478
}
2490
2479
2491
2480
/* albums mobile */
···
2500
2489
}
2501
2490
2502
2491
.album-title {
2503
-
font-size: 0.85rem;
2492
+
font-size: var(--text-sm);
2504
2493
}
2505
2494
2506
2495
/* playlists mobile */
···
2515
2504
}
2516
2505
2517
2506
.playlist-title {
2518
-
font-size: 0.85rem;
2507
+
font-size: var(--text-sm);
2519
2508
}
2520
2509
2521
2510
.playlist-stats {
2522
-
font-size: 0.75rem;
2511
+
font-size: var(--text-xs);
2523
2512
}
2524
2513
2525
2514
.view-playlists-link {
2526
-
font-size: 0.75rem;
2515
+
font-size: var(--text-xs);
2527
2516
padding: 0.3rem 0.5rem;
2528
2517
}
2529
2518
}
+4
-4
frontend/src/routes/profile/setup/+page.svelte
+4
-4
frontend/src/routes/profile/setup/+page.svelte
···
248
248
display: block;
249
249
color: var(--text-secondary);
250
250
margin-bottom: 0.5rem;
251
-
font-size: 0.9rem;
251
+
font-size: var(--text-base);
252
252
font-weight: 500;
253
253
}
254
254
···
261
261
border: 1px solid var(--border-default);
262
262
border-radius: var(--radius-sm);
263
263
color: var(--text-primary);
264
-
font-size: 1rem;
264
+
font-size: var(--text-lg);
265
265
font-family: inherit;
266
266
transition: all 0.2s;
267
267
}
···
287
287
288
288
.hint {
289
289
margin-top: 0.5rem;
290
-
font-size: 0.85rem;
290
+
font-size: var(--text-sm);
291
291
color: var(--text-muted);
292
292
}
293
293
···
298
298
color: white;
299
299
border: none;
300
300
border-radius: var(--radius-sm);
301
-
font-size: 1rem;
301
+
font-size: var(--text-lg);
302
302
font-weight: 600;
303
303
cursor: pointer;
304
304
transition: all 0.2s;
+25
-25
frontend/src/routes/settings/+page.svelte
+25
-25
frontend/src/routes/settings/+page.svelte
···
788
788
789
789
.token-overlay-content h2 {
790
790
margin: 0 0 0.75rem;
791
-
font-size: 1.5rem;
791
+
font-size: var(--text-3xl);
792
792
color: var(--text-primary);
793
793
}
794
794
795
795
.token-overlay-warning {
796
796
color: var(--warning);
797
-
font-size: 0.9rem;
797
+
font-size: var(--text-base);
798
798
margin: 0 0 1.5rem;
799
799
line-height: 1.5;
800
800
}
···
811
811
812
812
.token-overlay-display code {
813
813
flex: 1;
814
-
font-size: 0.85rem;
814
+
font-size: var(--text-sm);
815
815
word-break: break-all;
816
816
color: var(--accent);
817
817
text-align: left;
···
825
825
border-radius: var(--radius-base);
826
826
color: var(--text-primary);
827
827
font-family: inherit;
828
-
font-size: 0.85rem;
828
+
font-size: var(--text-sm);
829
829
font-weight: 600;
830
830
cursor: pointer;
831
831
white-space: nowrap;
···
837
837
}
838
838
839
839
.token-overlay-hint {
840
-
font-size: 0.8rem;
840
+
font-size: var(--text-sm);
841
841
color: var(--text-tertiary);
842
842
margin: 0 0 1.5rem;
843
843
}
···
858
858
border-radius: var(--radius-md);
859
859
color: var(--text-secondary);
860
860
font-family: inherit;
861
-
font-size: 0.9rem;
861
+
font-size: var(--text-base);
862
862
cursor: pointer;
863
863
transition: all 0.15s;
864
864
}
···
901
901
.portal-link {
902
902
color: var(--text-secondary);
903
903
text-decoration: none;
904
-
font-size: 0.85rem;
904
+
font-size: var(--text-sm);
905
905
padding: 0.4rem 0.75rem;
906
906
background: var(--bg-tertiary);
907
907
border-radius: var(--radius-base);
···
919
919
}
920
920
921
921
.settings-section h2 {
922
-
font-size: 0.8rem;
922
+
font-size: var(--text-sm);
923
923
text-transform: uppercase;
924
924
letter-spacing: 0.08em;
925
925
color: var(--text-tertiary);
···
957
957
958
958
.setting-info h3 {
959
959
margin: 0 0 0.25rem;
960
-
font-size: 0.95rem;
960
+
font-size: var(--text-base);
961
961
font-weight: 600;
962
962
color: var(--text-primary);
963
963
}
964
964
965
965
.setting-info p {
966
966
margin: 0;
967
-
font-size: 0.8rem;
967
+
font-size: var(--text-sm);
968
968
color: var(--text-tertiary);
969
969
line-height: 1.4;
970
970
}
···
1087
1087
border: 1px solid var(--border-default);
1088
1088
border-radius: var(--radius-base);
1089
1089
color: var(--text-primary);
1090
-
font-size: 0.85rem;
1090
+
font-size: var(--text-sm);
1091
1091
font-family: inherit;
1092
1092
}
1093
1093
···
1104
1104
display: flex;
1105
1105
align-items: center;
1106
1106
gap: 0.4rem;
1107
-
font-size: 0.8rem;
1107
+
font-size: var(--text-sm);
1108
1108
color: var(--text-secondary);
1109
1109
cursor: pointer;
1110
1110
}
···
1169
1169
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
1170
1170
border-radius: var(--radius-base);
1171
1171
margin-top: 0.75rem;
1172
-
font-size: 0.8rem;
1172
+
font-size: var(--text-sm);
1173
1173
color: var(--warning);
1174
1174
}
1175
1175
···
1191
1191
/* developer tokens */
1192
1192
.loading-tokens {
1193
1193
color: var(--text-tertiary);
1194
-
font-size: 0.85rem;
1194
+
font-size: var(--text-sm);
1195
1195
}
1196
1196
1197
1197
.existing-tokens {
···
1199
1199
}
1200
1200
1201
1201
.tokens-header {
1202
-
font-size: 0.75rem;
1202
+
font-size: var(--text-xs);
1203
1203
text-transform: uppercase;
1204
1204
letter-spacing: 0.05em;
1205
1205
color: var(--text-tertiary);
···
1233
1233
.token-name {
1234
1234
font-weight: 500;
1235
1235
color: var(--text-primary);
1236
-
font-size: 0.9rem;
1236
+
font-size: var(--text-base);
1237
1237
}
1238
1238
1239
1239
.token-meta {
1240
-
font-size: 0.75rem;
1240
+
font-size: var(--text-xs);
1241
1241
color: var(--text-tertiary);
1242
1242
}
1243
1243
···
1248
1248
border-radius: var(--radius-sm);
1249
1249
color: var(--text-secondary);
1250
1250
font-family: inherit;
1251
-
font-size: 0.8rem;
1251
+
font-size: var(--text-sm);
1252
1252
cursor: pointer;
1253
1253
transition: all 0.15s;
1254
1254
white-space: nowrap;
···
1277
1277
1278
1278
.token-value {
1279
1279
flex: 1;
1280
-
font-size: 0.8rem;
1280
+
font-size: var(--text-sm);
1281
1281
word-break: break-all;
1282
1282
color: var(--accent);
1283
1283
}
···
1290
1290
border-radius: var(--radius-sm);
1291
1291
color: var(--text-secondary);
1292
1292
font-family: inherit;
1293
-
font-size: 0.8rem;
1293
+
font-size: var(--text-sm);
1294
1294
cursor: pointer;
1295
1295
transition: all 0.15s;
1296
1296
}
···
1303
1303
1304
1304
.token-warning {
1305
1305
margin-top: 0.5rem;
1306
-
font-size: 0.8rem;
1306
+
font-size: var(--text-sm);
1307
1307
color: var(--warning);
1308
1308
}
1309
1309
···
1322
1322
border: 1px solid var(--border-default);
1323
1323
border-radius: var(--radius-base);
1324
1324
color: var(--text-primary);
1325
-
font-size: 0.9rem;
1325
+
font-size: var(--text-base);
1326
1326
font-family: inherit;
1327
1327
}
1328
1328
···
1335
1335
display: flex;
1336
1336
align-items: center;
1337
1337
gap: 0.5rem;
1338
-
font-size: 0.85rem;
1338
+
font-size: var(--text-sm);
1339
1339
color: var(--text-secondary);
1340
1340
}
1341
1341
···
1345
1345
border: 1px solid var(--border-default);
1346
1346
border-radius: var(--radius-base);
1347
1347
color: var(--text-primary);
1348
-
font-size: 0.85rem;
1348
+
font-size: var(--text-sm);
1349
1349
font-family: inherit;
1350
1350
cursor: pointer;
1351
1351
}
···
1362
1362
border-radius: var(--radius-base);
1363
1363
color: var(--text-primary);
1364
1364
font-family: inherit;
1365
-
font-size: 0.9rem;
1365
+
font-size: var(--text-base);
1366
1366
font-weight: 600;
1367
1367
cursor: pointer;
1368
1368
transition: all 0.15s;
+9
-9
frontend/src/routes/tag/[name]/+page.svelte
+9
-9
frontend/src/routes/tag/[name]/+page.svelte
···
197
197
}
198
198
199
199
.subtitle {
200
-
font-size: 0.95rem;
200
+
font-size: var(--text-base);
201
201
color: var(--text-tertiary);
202
202
margin: 0;
203
203
text-shadow: var(--text-shadow, none);
···
212
212
border: 1px solid var(--glass-btn-border, var(--accent));
213
213
color: var(--accent);
214
214
border-radius: var(--radius-base);
215
-
font-size: 0.9rem;
215
+
font-size: var(--text-base);
216
216
font-family: inherit;
217
217
cursor: pointer;
218
218
transition: all 0.2s;
···
240
240
}
241
241
242
242
.empty-state h2 {
243
-
font-size: 1.5rem;
243
+
font-size: var(--text-3xl);
244
244
font-weight: 600;
245
245
color: var(--text-secondary);
246
246
margin: 0 0 0.5rem 0;
247
247
}
248
248
249
249
.empty-state p {
250
-
font-size: 0.95rem;
250
+
font-size: var(--text-base);
251
251
margin: 0;
252
252
}
253
253
···
264
264
text-align: center;
265
265
padding: 4rem 1rem;
266
266
color: var(--text-tertiary);
267
-
font-size: 0.95rem;
267
+
font-size: var(--text-base);
268
268
}
269
269
270
270
.tracks-list {
···
292
292
}
293
293
294
294
.empty-state h2 {
295
-
font-size: 1.25rem;
295
+
font-size: var(--text-2xl);
296
296
}
297
297
298
298
.btn-queue-all {
299
299
padding: 0.5rem 0.75rem;
300
-
font-size: 0.85rem;
300
+
font-size: var(--text-sm);
301
301
}
302
302
303
303
.btn-queue-all svg {
···
330
330
}
331
331
332
332
.subtitle {
333
-
font-size: 0.85rem;
333
+
font-size: var(--text-sm);
334
334
}
335
335
336
336
.btn-queue-all {
337
337
padding: 0.45rem 0.65rem;
338
-
font-size: 0.8rem;
338
+
font-size: var(--text-sm);
339
339
}
340
340
341
341
.btn-queue-all svg {
+26
-26
frontend/src/routes/track/[id]/+page.svelte
+26
-26
frontend/src/routes/track/[id]/+page.svelte
···
769
769
gap: 0.75rem;
770
770
flex-wrap: wrap;
771
771
color: var(--text-secondary);
772
-
font-size: 1.1rem;
772
+
font-size: var(--text-xl);
773
773
}
774
774
775
775
.separator {
776
776
color: var(--text-muted);
777
-
font-size: 0.8rem;
777
+
font-size: var(--text-sm);
778
778
}
779
779
780
780
.artist-link {
···
855
855
856
856
.track-stats {
857
857
color: var(--text-tertiary);
858
-
font-size: 0.95rem;
858
+
font-size: var(--text-base);
859
859
display: flex;
860
860
align-items: center;
861
861
gap: 0.5rem;
···
863
863
}
864
864
865
865
.track-stats .separator {
866
-
font-size: 0.7rem;
866
+
font-size: var(--text-xs);
867
867
}
868
868
869
869
.track-tags {
···
879
879
background: color-mix(in srgb, var(--accent) 15%, transparent);
880
880
color: var(--accent-hover);
881
881
border-radius: var(--radius-sm);
882
-
font-size: 0.85rem;
882
+
font-size: var(--text-sm);
883
883
font-weight: 500;
884
884
text-decoration: none;
885
885
transition: all 0.15s;
···
915
915
color: var(--bg-primary);
916
916
border: none;
917
917
border-radius: var(--radius-2xl);
918
-
font-size: 0.95rem;
918
+
font-size: var(--text-base);
919
919
font-weight: 600;
920
920
font-family: inherit;
921
921
cursor: pointer;
···
928
928
}
929
929
930
930
.btn-play.playing {
931
-
opacity: 0.8;
931
+
animation: ethereal-glow 3s ease-in-out infinite;
932
932
}
933
933
934
934
.btn-queue {
···
940
940
color: var(--text-primary);
941
941
border: 1px solid var(--border-emphasis);
942
942
border-radius: var(--radius-2xl);
943
-
font-size: 0.95rem;
943
+
font-size: var(--text-base);
944
944
font-weight: 500;
945
945
font-family: inherit;
946
946
cursor: pointer;
···
984
984
}
985
985
986
986
.track-title {
987
-
font-size: 1.5rem;
987
+
font-size: var(--text-3xl);
988
988
}
989
989
990
990
.track-metadata {
991
-
font-size: 0.9rem;
991
+
font-size: var(--text-base);
992
992
gap: 0.5rem;
993
993
}
994
994
995
995
.track-stats {
996
-
font-size: 0.85rem;
996
+
font-size: var(--text-sm);
997
997
}
998
998
999
999
.track-actions {
···
1010
1010
min-width: calc(50% - 0.25rem);
1011
1011
justify-content: center;
1012
1012
padding: 0.6rem 1rem;
1013
-
font-size: 0.9rem;
1013
+
font-size: var(--text-base);
1014
1014
}
1015
1015
1016
1016
.btn-play svg {
···
1023
1023
min-width: calc(50% - 0.25rem);
1024
1024
justify-content: center;
1025
1025
padding: 0.6rem 1rem;
1026
-
font-size: 0.9rem;
1026
+
font-size: var(--text-base);
1027
1027
}
1028
1028
1029
1029
.btn-queue svg {
···
1042
1042
}
1043
1043
1044
1044
.comments-title {
1045
-
font-size: 1rem;
1045
+
font-size: var(--text-lg);
1046
1046
font-weight: 600;
1047
1047
color: var(--text-primary);
1048
1048
margin: 0 0 0.75rem 0;
···
1069
1069
border: 1px solid var(--border-default);
1070
1070
border-radius: var(--radius-base);
1071
1071
color: var(--text-primary);
1072
-
font-size: 0.9rem;
1072
+
font-size: var(--text-base);
1073
1073
font-family: inherit;
1074
1074
}
1075
1075
···
1088
1088
color: var(--bg-primary);
1089
1089
border: none;
1090
1090
border-radius: var(--radius-base);
1091
-
font-size: 0.9rem;
1091
+
font-size: var(--text-base);
1092
1092
font-weight: 600;
1093
1093
font-family: inherit;
1094
1094
cursor: pointer;
···
1106
1106
1107
1107
.login-prompt {
1108
1108
color: var(--text-tertiary);
1109
-
font-size: 0.9rem;
1109
+
font-size: var(--text-base);
1110
1110
margin-bottom: 1rem;
1111
1111
}
1112
1112
···
1121
1121
1122
1122
.no-comments {
1123
1123
color: var(--text-muted);
1124
-
font-size: 0.9rem;
1124
+
font-size: var(--text-base);
1125
1125
text-align: center;
1126
1126
padding: 1rem;
1127
1127
}
···
1169
1169
}
1170
1170
1171
1171
.comment-timestamp {
1172
-
font-size: 0.8rem;
1172
+
font-size: var(--text-sm);
1173
1173
font-weight: 600;
1174
1174
color: var(--accent);
1175
1175
background: color-mix(in srgb, var(--accent) 10%, transparent);
···
1207
1207
}
1208
1208
1209
1209
.comment-time {
1210
-
font-size: 0.75rem;
1210
+
font-size: var(--text-xs);
1211
1211
color: var(--text-muted);
1212
1212
}
1213
1213
···
1226
1226
}
1227
1227
1228
1228
.comment-author {
1229
-
font-size: 0.85rem;
1229
+
font-size: var(--text-sm);
1230
1230
font-weight: 500;
1231
1231
color: var(--text-secondary);
1232
1232
text-decoration: none;
···
1237
1237
}
1238
1238
1239
1239
.comment-text {
1240
-
font-size: 0.9rem;
1240
+
font-size: var(--text-base);
1241
1241
color: var(--text-primary);
1242
1242
margin: 0;
1243
1243
line-height: 1.4;
···
1277
1277
border: none;
1278
1278
padding: 0;
1279
1279
color: var(--text-muted);
1280
-
font-size: 0.8rem;
1280
+
font-size: var(--text-sm);
1281
1281
cursor: pointer;
1282
1282
transition: color 0.15s;
1283
1283
font-family: inherit;
···
1312
1312
border: 1px solid var(--border-default);
1313
1313
border-radius: var(--radius-sm);
1314
1314
color: var(--text-primary);
1315
-
font-size: 0.9rem;
1315
+
font-size: var(--text-base);
1316
1316
font-family: inherit;
1317
1317
}
1318
1318
···
1329
1329
1330
1330
.edit-form-btn {
1331
1331
padding: 0.25rem 0.6rem;
1332
-
font-size: 0.8rem;
1332
+
font-size: var(--text-sm);
1333
1333
font-family: inherit;
1334
1334
border-radius: var(--radius-sm);
1335
1335
cursor: pointer;
···
1443
1443
}
1444
1444
1445
1445
.comment-timestamp {
1446
-
font-size: 0.75rem;
1446
+
font-size: var(--text-xs);
1447
1447
padding: 0.15rem 0.4rem;
1448
1448
}
1449
1449
}
+4
-4
frontend/src/routes/u/[handle]/+error.svelte
+4
-4
frontend/src/routes/u/[handle]/+error.svelte
···
96
96
}
97
97
98
98
.error-message {
99
-
font-size: 1.25rem;
99
+
font-size: var(--text-2xl);
100
100
color: var(--text-secondary);
101
101
margin: 0 0 0.5rem 0;
102
102
}
103
103
104
104
.error-detail {
105
-
font-size: 1rem;
105
+
font-size: var(--text-lg);
106
106
color: var(--text-tertiary);
107
107
margin: 0 0 2rem 0;
108
108
}
···
118
118
.bsky-link {
119
119
color: var(--accent);
120
120
text-decoration: none;
121
-
font-size: 1.1rem;
121
+
font-size: var(--text-xl);
122
122
padding: 0.75rem 1.5rem;
123
123
border: 1px solid var(--accent);
124
124
border-radius: var(--radius-base);
···
151
151
}
152
152
153
153
.error-message {
154
-
font-size: 1.1rem;
154
+
font-size: var(--text-xl);
155
155
}
156
156
157
157
.actions {
+16
-16
frontend/src/routes/u/[handle]/+page.svelte
+16
-16
frontend/src/routes/u/[handle]/+page.svelte
···
653
653
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
654
654
border-radius: var(--radius-sm);
655
655
color: var(--accent);
656
-
font-size: 0.85rem;
656
+
font-size: var(--text-sm);
657
657
text-decoration: none;
658
658
transition: all 0.2s ease;
659
659
}
···
695
695
696
696
.handle {
697
697
color: var(--text-tertiary);
698
-
font-size: 1.1rem;
698
+
font-size: var(--text-xl);
699
699
text-decoration: none;
700
700
transition: color 0.2s;
701
701
}
···
738
738
739
739
.section-header span {
740
740
color: var(--text-tertiary);
741
-
font-size: 0.9rem;
741
+
font-size: var(--text-base);
742
742
text-transform: uppercase;
743
743
letter-spacing: 0.1em;
744
744
}
···
820
820
.album-card-meta p {
821
821
margin: 0;
822
822
color: var(--text-tertiary);
823
-
font-size: 0.9rem;
823
+
font-size: var(--text-base);
824
824
display: flex;
825
825
align-items: center;
826
826
gap: 0.4rem;
···
853
853
854
854
.stat-label {
855
855
color: var(--text-tertiary);
856
-
font-size: 0.9rem;
856
+
font-size: var(--text-base);
857
857
text-transform: lowercase;
858
858
line-height: 1;
859
859
}
860
860
861
861
.stat-duration {
862
862
margin-top: 0.5rem;
863
-
font-size: 0.85rem;
863
+
font-size: var(--text-sm);
864
864
color: var(--text-secondary);
865
865
font-variant-numeric: tabular-nums;
866
866
}
···
888
888
889
889
.top-item-plays {
890
890
color: var(--accent);
891
-
font-size: 1rem;
891
+
font-size: var(--text-lg);
892
892
line-height: 1;
893
893
}
894
894
···
963
963
border-radius: var(--radius-md);
964
964
color: var(--text-secondary);
965
965
font-family: inherit;
966
-
font-size: 0.95rem;
966
+
font-size: var(--text-base);
967
967
cursor: pointer;
968
968
transition: all 0.2s ease;
969
969
}
···
981
981
982
982
.tracks-loading {
983
983
margin-left: 0.75rem;
984
-
font-size: 0.95rem;
984
+
font-size: var(--text-base);
985
985
color: var(--text-secondary);
986
986
font-weight: 400;
987
987
text-transform: lowercase;
···
1003
1003
1004
1004
.empty-message {
1005
1005
color: var(--text-secondary);
1006
-
font-size: 1.25rem;
1006
+
font-size: var(--text-2xl);
1007
1007
margin: 0 0 0.5rem 0;
1008
1008
}
1009
1009
···
1015
1015
.bsky-link {
1016
1016
color: var(--accent);
1017
1017
text-decoration: none;
1018
-
font-size: 1rem;
1018
+
font-size: var(--text-lg);
1019
1019
padding: 0.75rem 1.5rem;
1020
1020
border: 1px solid var(--accent);
1021
1021
border-radius: var(--radius-base);
···
1072
1072
1073
1073
.support-btn {
1074
1074
height: 28px;
1075
-
font-size: 0.8rem;
1075
+
font-size: var(--text-sm);
1076
1076
padding: 0 0.6rem;
1077
1077
}
1078
1078
···
1116
1116
}
1117
1117
1118
1118
.album-card-meta h3 {
1119
-
font-size: 0.95rem;
1119
+
font-size: var(--text-base);
1120
1120
margin-bottom: 0.25rem;
1121
1121
}
1122
1122
1123
1123
.album-card-meta p {
1124
-
font-size: 0.8rem;
1124
+
font-size: var(--text-sm);
1125
1125
}
1126
1126
}
1127
1127
···
1193
1193
1194
1194
.collection-info h3 {
1195
1195
margin: 0 0 0.25rem 0;
1196
-
font-size: 1.1rem;
1196
+
font-size: var(--text-xl);
1197
1197
color: var(--text-primary);
1198
1198
}
1199
1199
1200
1200
.collection-info p {
1201
1201
margin: 0;
1202
-
font-size: 0.9rem;
1202
+
font-size: var(--text-base);
1203
1203
color: var(--text-tertiary);
1204
1204
}
1205
1205
+41
-17
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
+41
-17
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
···
27
27
tracks = [...data.album.tracks];
28
28
});
29
29
30
+
// local mutable copy of tracks for reordering
31
+
let tracks = $state<Track[]>([...data.album.tracks]);
32
+
30
33
// check if current user owns this album
31
34
const isOwner = $derived(auth.user?.did === albumMetadata.artist_did);
32
35
// can only reorder if owner and album has an ATProto list
33
36
const canReorder = $derived(isOwner && !!albumMetadata.list_uri);
34
37
35
-
// local mutable copy of tracks for reordering
36
-
let tracks = $state<Track[]>([...data.album.tracks]);
38
+
// check if current track is from this album (active, regardless of paused state)
39
+
const isAlbumActive = $derived(
40
+
player.currentTrack !== null &&
41
+
tracks.some(t => t.id === player.currentTrack?.id)
42
+
);
43
+
44
+
// check if actively playing (not paused)
45
+
const isAlbumPlaying = $derived(isAlbumActive && !player.paused);
37
46
38
47
// edit mode state
39
48
let isEditMode = $state(false);
···
545
554
</div>
546
555
547
556
<div class="album-actions">
548
-
<button class="play-button" onclick={playNow}>
549
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
550
-
<path d="M8 5v14l11-7z"/>
551
-
</svg>
552
-
play now
557
+
<button
558
+
class="play-button"
559
+
class:is-playing={isAlbumPlaying}
560
+
onclick={() => isAlbumActive ? player.togglePlayPause() : playNow()}
561
+
>
562
+
{#if isAlbumPlaying}
563
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
564
+
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
565
+
</svg>
566
+
pause
567
+
{:else}
568
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
569
+
<path d="M8 5v14l11-7z"/>
570
+
</svg>
571
+
play
572
+
{/if}
553
573
</button>
554
574
<button class="queue-button" onclick={addToQueue}>
555
575
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
···
865
885
justify-content: center;
866
886
gap: 0.5rem;
867
887
color: white;
868
-
font-size: 0.85rem;
888
+
font-size: var(--text-sm);
869
889
opacity: 0;
870
890
transition: opacity 0.2s;
871
891
}
···
893
913
894
914
.album-type {
895
915
text-transform: uppercase;
896
-
font-size: 0.75rem;
916
+
font-size: var(--text-xs);
897
917
font-weight: 600;
898
918
letter-spacing: 0.1em;
899
919
color: var(--text-tertiary);
···
917
937
display: flex;
918
938
align-items: center;
919
939
gap: 0.75rem;
920
-
font-size: 0.95rem;
940
+
font-size: var(--text-base);
921
941
color: var(--text-secondary);
922
942
text-shadow: var(--text-shadow, none);
923
943
}
···
936
956
937
957
.meta-separator {
938
958
color: var(--text-muted);
939
-
font-size: 0.7rem;
959
+
font-size: var(--text-xs);
940
960
}
941
961
942
962
.album-actions {
···
950
970
padding: 0.75rem 1.5rem;
951
971
border-radius: var(--radius-2xl);
952
972
font-weight: 600;
953
-
font-size: 0.95rem;
973
+
font-size: var(--text-base);
954
974
font-family: inherit;
955
975
cursor: pointer;
956
976
transition: all 0.2s;
···
969
989
transform: scale(1.05);
970
990
}
971
991
992
+
.play-button.is-playing {
993
+
animation: ethereal-glow 3s ease-in-out infinite;
994
+
}
995
+
972
996
.queue-button {
973
997
background: var(--glass-btn-bg, transparent);
974
998
color: var(--text-primary);
···
1000
1024
}
1001
1025
1002
1026
.section-heading {
1003
-
font-size: 1.25rem;
1027
+
font-size: var(--text-2xl);
1004
1028
font-weight: 600;
1005
1029
color: var(--text-primary);
1006
1030
margin-bottom: 1rem;
···
1111
1135
}
1112
1136
1113
1137
.album-meta {
1114
-
font-size: 0.85rem;
1138
+
font-size: var(--text-sm);
1115
1139
}
1116
1140
1117
1141
.album-actions {
···
1143
1167
}
1144
1168
1145
1169
.album-meta {
1146
-
font-size: 0.8rem;
1170
+
font-size: var(--text-sm);
1147
1171
flex-wrap: wrap;
1148
1172
}
1149
1173
}
···
1202
1226
1203
1227
.modal-header h3 {
1204
1228
margin: 0;
1205
-
font-size: 1.25rem;
1229
+
font-size: var(--text-2xl);
1206
1230
font-weight: 600;
1207
1231
color: var(--text-primary);
1208
1232
}
···
1228
1252
padding: 0.625rem 1.25rem;
1229
1253
border-radius: var(--radius-md);
1230
1254
font-weight: 500;
1231
-
font-size: 0.9rem;
1255
+
font-size: var(--text-base);
1232
1256
font-family: inherit;
1233
1257
cursor: pointer;
1234
1258
transition: all 0.2s;
+11
-11
frontend/src/routes/upload/+page.svelte
+11
-11
frontend/src/routes/upload/+page.svelte
···
435
435
display: block;
436
436
color: var(--text-secondary);
437
437
margin-bottom: 0.5rem;
438
-
font-size: 0.9rem;
438
+
font-size: var(--text-base);
439
439
}
440
440
441
441
input[type="text"] {
···
445
445
border: 1px solid var(--border-default);
446
446
border-radius: var(--radius-sm);
447
447
color: var(--text-primary);
448
-
font-size: 1rem;
448
+
font-size: var(--text-lg);
449
449
font-family: inherit;
450
450
transition: all 0.2s;
451
451
}
···
462
462
border: 1px solid var(--border-default);
463
463
border-radius: var(--radius-sm);
464
464
color: var(--text-primary);
465
-
font-size: 0.9rem;
465
+
font-size: var(--text-base);
466
466
font-family: inherit;
467
467
cursor: pointer;
468
468
}
469
469
470
470
.format-hint {
471
471
margin-top: 0.25rem;
472
-
font-size: 0.8rem;
472
+
font-size: var(--text-sm);
473
473
color: var(--text-tertiary);
474
474
}
475
475
476
476
.file-info {
477
477
margin-top: 0.5rem;
478
-
font-size: 0.85rem;
478
+
font-size: var(--text-sm);
479
479
color: var(--text-muted);
480
480
}
481
481
···
486
486
color: var(--text-primary);
487
487
border: none;
488
488
border-radius: var(--radius-sm);
489
-
font-size: 1rem;
489
+
font-size: var(--text-lg);
490
490
font-weight: 600;
491
491
font-family: inherit;
492
492
cursor: pointer;
···
542
542
}
543
543
544
544
.checkbox-text {
545
-
font-size: 0.95rem;
545
+
font-size: var(--text-base);
546
546
color: var(--text-primary);
547
547
line-height: 1.4;
548
548
}
···
550
550
.attestation-note {
551
551
margin-top: 0.75rem;
552
552
margin-left: 2rem;
553
-
font-size: 0.8rem;
553
+
font-size: var(--text-sm);
554
554
color: var(--text-tertiary);
555
555
line-height: 1.4;
556
556
}
···
584
584
.gating-note {
585
585
margin-top: 0.5rem;
586
586
margin-left: 2rem;
587
-
font-size: 0.8rem;
587
+
font-size: var(--text-sm);
588
588
color: var(--text-tertiary);
589
589
line-height: 1.4;
590
590
}
···
611
611
}
612
612
613
613
.gating-disabled-text {
614
-
font-size: 0.85rem;
614
+
font-size: var(--text-sm);
615
615
line-height: 1.4;
616
616
}
617
617
···
638
638
}
639
639
640
640
.section-header h2 {
641
-
font-size: 1.25rem;
641
+
font-size: var(--text-2xl);
642
642
}
643
643
}
644
644
</style>
+1
-1
moderation/Cargo.toml
+1
-1
moderation/Cargo.toml
···
6
6
[dependencies]
7
7
anyhow = "1.0"
8
8
axum = { version = "0.7", features = ["macros", "json", "ws"] }
9
+
rand = "0.8"
9
10
bytes = "1.0"
10
11
chrono = { version = "0.4", features = ["serde"] }
11
12
futures = "0.3"
···
25
26
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
26
27
27
28
[dev-dependencies]
28
-
rand = "0.8"
+69
moderation/src/admin.rs
+69
moderation/src/admin.rs
···
137
137
pub message: String,
138
138
}
139
139
140
+
/// Request to create a review batch.
141
+
#[derive(Debug, Deserialize)]
142
+
pub struct CreateBatchRequest {
143
+
/// URIs to include. If empty, uses all pending flags.
144
+
#[serde(default)]
145
+
pub uris: Vec<String>,
146
+
/// Who created this batch.
147
+
pub created_by: Option<String>,
148
+
}
149
+
150
+
/// Response after creating a review batch.
151
+
#[derive(Debug, Serialize)]
152
+
pub struct CreateBatchResponse {
153
+
pub id: String,
154
+
pub url: String,
155
+
pub flag_count: usize,
156
+
}
157
+
140
158
/// List all flagged tracks - returns JSON for API, HTML for htmx.
141
159
pub async fn list_flagged(
142
160
State(state): State<AppState>,
···
325
343
Ok(Json(StoreContextResponse {
326
344
message: format!("context stored for {}", request.uri),
327
345
}))
346
+
}
347
+
348
+
/// Create a review batch from pending flags.
349
+
pub async fn create_batch(
350
+
State(state): State<AppState>,
351
+
Json(request): Json<CreateBatchRequest>,
352
+
) -> Result<Json<CreateBatchResponse>, AppError> {
353
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
354
+
355
+
// Get URIs to include
356
+
let uris = if request.uris.is_empty() {
357
+
let pending = db.get_pending_flags().await?;
358
+
pending
359
+
.into_iter()
360
+
.filter(|t| !t.resolved)
361
+
.map(|t| t.uri)
362
+
.collect()
363
+
} else {
364
+
request.uris
365
+
};
366
+
367
+
if uris.is_empty() {
368
+
return Err(AppError::BadRequest("no flags to review".to_string()));
369
+
}
370
+
371
+
let id = generate_batch_id();
372
+
let flag_count = uris.len();
373
+
374
+
tracing::info!(
375
+
batch_id = %id,
376
+
flag_count = flag_count,
377
+
"creating review batch"
378
+
);
379
+
380
+
db.create_batch(&id, &uris, request.created_by.as_deref())
381
+
.await?;
382
+
383
+
let url = format!("/admin/review/{}", id);
384
+
385
+
Ok(Json(CreateBatchResponse { id, url, flag_count }))
386
+
}
387
+
388
+
/// Generate a short, URL-safe batch ID.
389
+
fn generate_batch_id() -> String {
390
+
use std::time::{SystemTime, UNIX_EPOCH};
391
+
let now = SystemTime::now()
392
+
.duration_since(UNIX_EPOCH)
393
+
.unwrap()
394
+
.as_millis();
395
+
let rand_part: u32 = rand::random();
396
+
format!("{:x}{:x}", (now as u64) & 0xFFFFFFFF, rand_part & 0xFFFF)
328
397
}
329
398
330
399
/// Add a sensitive image entry.
+5
-1
moderation/src/auth.rs
+5
-1
moderation/src/auth.rs
···
12
12
let path = req.uri().path();
13
13
14
14
// Public endpoints - no auth required
15
-
// Note: /admin serves HTML, auth is handled client-side for API calls
15
+
// Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls
16
16
// Static files must be public for admin UI CSS/JS to load
17
+
let is_review_page = path.starts_with("/admin/review/")
18
+
&& !path.ends_with("/data")
19
+
&& !path.ends_with("/submit");
17
20
if path == "/"
18
21
|| path == "/health"
19
22
|| path == "/sensitive-images"
20
23
|| path == "/admin"
24
+
|| is_review_page
21
25
|| path.starts_with("/static/")
22
26
|| path.starts_with("/xrpc/com.atproto.label.")
23
27
{
+251
moderation/src/db.rs
+251
moderation/src/db.rs
···
23
23
pub flagged_by: Option<String>,
24
24
}
25
25
26
+
/// Review batch for mobile-friendly flag review.
27
+
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
28
+
pub struct ReviewBatch {
29
+
pub id: String,
30
+
pub created_at: DateTime<Utc>,
31
+
pub expires_at: Option<DateTime<Utc>>,
32
+
/// Status: pending, completed.
33
+
pub status: String,
34
+
/// Who created this batch.
35
+
pub created_by: Option<String>,
36
+
}
37
+
38
+
/// A flag within a review batch.
39
+
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
40
+
pub struct BatchFlag {
41
+
pub id: i64,
42
+
pub batch_id: String,
43
+
pub uri: String,
44
+
pub reviewed: bool,
45
+
pub reviewed_at: Option<DateTime<Utc>>,
46
+
/// Decision: approved, rejected, or null.
47
+
pub decision: Option<String>,
48
+
}
49
+
26
50
/// Type alias for context row from database query.
27
51
type ContextRow = (
28
52
Option<i64>, // track_id
···
71
95
FingerprintNoise,
72
96
/// Legal cover version or remix
73
97
CoverVersion,
98
+
/// Content was deleted from plyr.fm
99
+
ContentDeleted,
74
100
/// Other reason (see resolution_notes)
75
101
Other,
76
102
}
···
83
109
Self::Licensed => "licensed",
84
110
Self::FingerprintNoise => "fingerprint noise",
85
111
Self::CoverVersion => "cover/remix",
112
+
Self::ContentDeleted => "content deleted",
86
113
Self::Other => "other",
87
114
}
88
115
}
···
94
121
"licensed" => Some(Self::Licensed),
95
122
"fingerprint_noise" => Some(Self::FingerprintNoise),
96
123
"cover_version" => Some(Self::CoverVersion),
124
+
"content_deleted" => Some(Self::ContentDeleted),
97
125
"other" => Some(Self::Other),
98
126
_ => None,
99
127
}
···
232
260
.execute(&self.pool)
233
261
.await?;
234
262
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sensitive_images_url ON sensitive_images(url)")
263
+
.execute(&self.pool)
264
+
.await?;
265
+
266
+
// Review batches for mobile-friendly flag review
267
+
sqlx::query(
268
+
r#"
269
+
CREATE TABLE IF NOT EXISTS review_batches (
270
+
id TEXT PRIMARY KEY,
271
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
272
+
expires_at TIMESTAMPTZ,
273
+
status TEXT NOT NULL DEFAULT 'pending',
274
+
created_by TEXT
275
+
)
276
+
"#,
277
+
)
278
+
.execute(&self.pool)
279
+
.await?;
280
+
281
+
// Flags within review batches
282
+
sqlx::query(
283
+
r#"
284
+
CREATE TABLE IF NOT EXISTS batch_flags (
285
+
id BIGSERIAL PRIMARY KEY,
286
+
batch_id TEXT NOT NULL REFERENCES review_batches(id) ON DELETE CASCADE,
287
+
uri TEXT NOT NULL,
288
+
reviewed BOOLEAN NOT NULL DEFAULT FALSE,
289
+
reviewed_at TIMESTAMPTZ,
290
+
decision TEXT,
291
+
UNIQUE(batch_id, uri)
292
+
)
293
+
"#,
294
+
)
295
+
.execute(&self.pool)
296
+
.await?;
297
+
298
+
sqlx::query("CREATE INDEX IF NOT EXISTS idx_batch_flags_batch_id ON batch_flags(batch_id)")
235
299
.execute(&self.pool)
236
300
.await?;
237
301
···
631
695
.collect();
632
696
633
697
Ok(tracks)
698
+
}
699
+
700
+
// -------------------------------------------------------------------------
701
+
// Review batches
702
+
// -------------------------------------------------------------------------
703
+
704
+
/// Create a review batch with the given flags.
705
+
pub async fn create_batch(
706
+
&self,
707
+
id: &str,
708
+
uris: &[String],
709
+
created_by: Option<&str>,
710
+
) -> Result<ReviewBatch, sqlx::Error> {
711
+
let batch = sqlx::query_as::<_, ReviewBatch>(
712
+
r#"
713
+
INSERT INTO review_batches (id, created_by)
714
+
VALUES ($1, $2)
715
+
RETURNING id, created_at, expires_at, status, created_by
716
+
"#,
717
+
)
718
+
.bind(id)
719
+
.bind(created_by)
720
+
.fetch_one(&self.pool)
721
+
.await?;
722
+
723
+
for uri in uris {
724
+
sqlx::query(
725
+
r#"
726
+
INSERT INTO batch_flags (batch_id, uri)
727
+
VALUES ($1, $2)
728
+
ON CONFLICT (batch_id, uri) DO NOTHING
729
+
"#,
730
+
)
731
+
.bind(id)
732
+
.bind(uri)
733
+
.execute(&self.pool)
734
+
.await?;
735
+
}
736
+
737
+
Ok(batch)
738
+
}
739
+
740
+
/// Get a batch by ID.
741
+
pub async fn get_batch(&self, id: &str) -> Result<Option<ReviewBatch>, sqlx::Error> {
742
+
sqlx::query_as::<_, ReviewBatch>(
743
+
r#"
744
+
SELECT id, created_at, expires_at, status, created_by
745
+
FROM review_batches
746
+
WHERE id = $1
747
+
"#,
748
+
)
749
+
.bind(id)
750
+
.fetch_optional(&self.pool)
751
+
.await
752
+
}
753
+
754
+
/// Get all flags in a batch with their context.
755
+
pub async fn get_batch_flags(&self, batch_id: &str) -> Result<Vec<FlaggedTrack>, sqlx::Error> {
756
+
let rows: Vec<FlaggedRow> = sqlx::query_as(
757
+
r#"
758
+
SELECT l.seq, l.uri, l.val, l.cts,
759
+
c.track_id, c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches,
760
+
c.resolution_reason, c.resolution_notes
761
+
FROM batch_flags bf
762
+
JOIN labels l ON l.uri = bf.uri AND l.val = 'copyright-violation' AND l.neg = false
763
+
LEFT JOIN label_context c ON l.uri = c.uri
764
+
WHERE bf.batch_id = $1
765
+
ORDER BY l.seq DESC
766
+
"#,
767
+
)
768
+
.bind(batch_id)
769
+
.fetch_all(&self.pool)
770
+
.await?;
771
+
772
+
let batch_uris: Vec<String> = rows.iter().map(|r| r.1.clone()).collect();
773
+
let negated_uris: std::collections::HashSet<String> = if !batch_uris.is_empty() {
774
+
sqlx::query_scalar::<_, String>(
775
+
r#"
776
+
SELECT DISTINCT uri
777
+
FROM labels
778
+
WHERE val = 'copyright-violation' AND neg = true AND uri = ANY($1)
779
+
"#,
780
+
)
781
+
.bind(&batch_uris)
782
+
.fetch_all(&self.pool)
783
+
.await?
784
+
.into_iter()
785
+
.collect()
786
+
} else {
787
+
std::collections::HashSet::new()
788
+
};
789
+
790
+
let tracks = rows
791
+
.into_iter()
792
+
.map(
793
+
|(
794
+
seq,
795
+
uri,
796
+
val,
797
+
cts,
798
+
track_id,
799
+
track_title,
800
+
artist_handle,
801
+
artist_did,
802
+
highest_score,
803
+
matches,
804
+
resolution_reason,
805
+
resolution_notes,
806
+
)| {
807
+
let context = if track_id.is_some()
808
+
|| track_title.is_some()
809
+
|| artist_handle.is_some()
810
+
|| resolution_reason.is_some()
811
+
{
812
+
Some(LabelContext {
813
+
track_id,
814
+
track_title,
815
+
artist_handle,
816
+
artist_did,
817
+
highest_score,
818
+
matches: matches.and_then(|v| serde_json::from_value(v).ok()),
819
+
resolution_reason: resolution_reason
820
+
.and_then(|s| ResolutionReason::from_str(&s)),
821
+
resolution_notes,
822
+
})
823
+
} else {
824
+
None
825
+
};
826
+
827
+
FlaggedTrack {
828
+
seq,
829
+
uri: uri.clone(),
830
+
val,
831
+
created_at: cts.format("%Y-%m-%d %H:%M:%S").to_string(),
832
+
resolved: negated_uris.contains(&uri),
833
+
context,
834
+
}
835
+
},
836
+
)
837
+
.collect();
838
+
839
+
Ok(tracks)
840
+
}
841
+
842
+
/// Update batch status.
843
+
pub async fn update_batch_status(&self, id: &str, status: &str) -> Result<bool, sqlx::Error> {
844
+
let result = sqlx::query("UPDATE review_batches SET status = $1 WHERE id = $2")
845
+
.bind(status)
846
+
.bind(id)
847
+
.execute(&self.pool)
848
+
.await?;
849
+
Ok(result.rows_affected() > 0)
850
+
}
851
+
852
+
/// Mark a flag in a batch as reviewed.
853
+
pub async fn mark_flag_reviewed(
854
+
&self,
855
+
batch_id: &str,
856
+
uri: &str,
857
+
decision: &str,
858
+
) -> Result<bool, sqlx::Error> {
859
+
let result = sqlx::query(
860
+
r#"
861
+
UPDATE batch_flags
862
+
SET reviewed = true, reviewed_at = NOW(), decision = $1
863
+
WHERE batch_id = $2 AND uri = $3
864
+
"#,
865
+
)
866
+
.bind(decision)
867
+
.bind(batch_id)
868
+
.bind(uri)
869
+
.execute(&self.pool)
870
+
.await?;
871
+
Ok(result.rows_affected() > 0)
872
+
}
873
+
874
+
/// Get pending (non-reviewed) flags from a batch.
875
+
pub async fn get_batch_pending_uris(&self, batch_id: &str) -> Result<Vec<String>, sqlx::Error> {
876
+
sqlx::query_scalar::<_, String>(
877
+
r#"
878
+
SELECT uri FROM batch_flags
879
+
WHERE batch_id = $1 AND reviewed = false
880
+
"#,
881
+
)
882
+
.bind(batch_id)
883
+
.fetch_all(&self.pool)
884
+
.await
634
885
}
635
886
636
887
// -------------------------------------------------------------------------
+6
moderation/src/main.rs
+6
moderation/src/main.rs
···
25
25
mod db;
26
26
mod handlers;
27
27
mod labels;
28
+
mod review;
28
29
mod state;
29
30
mod xrpc;
30
31
···
91
92
"/admin/sensitive-images/remove",
92
93
post(admin::remove_sensitive_image),
93
94
)
95
+
.route("/admin/batches", post(admin::create_batch))
96
+
// Review endpoints (under admin, auth protected)
97
+
.route("/admin/review/:id", get(review::review_page))
98
+
.route("/admin/review/:id/data", get(review::review_data))
99
+
.route("/admin/review/:id/submit", post(review::submit_review))
94
100
// Static files (CSS, JS for admin UI)
95
101
.nest_service("/static", ServeDir::new("static"))
96
102
// ATProto XRPC endpoints (public)
+526
moderation/src/review.rs
+526
moderation/src/review.rs
···
1
+
//! Review endpoints for batch flag review.
2
+
//!
3
+
//! These endpoints are behind the same auth as admin endpoints.
4
+
5
+
use axum::{
6
+
extract::{Path, State},
7
+
http::header::CONTENT_TYPE,
8
+
response::{IntoResponse, Response},
9
+
Json,
10
+
};
11
+
use serde::{Deserialize, Serialize};
12
+
13
+
use crate::admin::FlaggedTrack;
14
+
use crate::state::{AppError, AppState};
15
+
16
+
/// Response for review page data.
17
+
#[derive(Debug, Serialize)]
18
+
pub struct ReviewPageData {
19
+
pub batch_id: String,
20
+
pub flags: Vec<FlaggedTrack>,
21
+
pub status: String,
22
+
}
23
+
24
+
/// Request to submit review decisions.
25
+
#[derive(Debug, Deserialize)]
26
+
pub struct SubmitReviewRequest {
27
+
pub decisions: Vec<ReviewDecision>,
28
+
}
29
+
30
+
/// A single review decision.
31
+
#[derive(Debug, Deserialize)]
32
+
pub struct ReviewDecision {
33
+
pub uri: String,
34
+
/// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation)
35
+
pub decision: String,
36
+
}
37
+
38
+
/// Response after submitting review.
39
+
#[derive(Debug, Serialize)]
40
+
pub struct SubmitReviewResponse {
41
+
pub resolved_count: usize,
42
+
pub message: String,
43
+
}
44
+
45
+
/// Get review page HTML.
46
+
pub async fn review_page(
47
+
State(state): State<AppState>,
48
+
Path(batch_id): Path<String>,
49
+
) -> Result<Response, AppError> {
50
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
51
+
52
+
let batch = db
53
+
.get_batch(&batch_id)
54
+
.await?
55
+
.ok_or(AppError::NotFound("batch not found".to_string()))?;
56
+
57
+
let flags = db.get_batch_flags(&batch_id).await?;
58
+
let html = render_review_page(&batch_id, &flags, &batch.status);
59
+
60
+
Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response())
61
+
}
62
+
63
+
/// Get review data as JSON.
64
+
pub async fn review_data(
65
+
State(state): State<AppState>,
66
+
Path(batch_id): Path<String>,
67
+
) -> Result<Json<ReviewPageData>, AppError> {
68
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
69
+
70
+
let batch = db
71
+
.get_batch(&batch_id)
72
+
.await?
73
+
.ok_or(AppError::NotFound("batch not found".to_string()))?;
74
+
75
+
let flags = db.get_batch_flags(&batch_id).await?;
76
+
77
+
Ok(Json(ReviewPageData {
78
+
batch_id,
79
+
flags,
80
+
status: batch.status,
81
+
}))
82
+
}
83
+
84
+
/// Submit review decisions.
85
+
pub async fn submit_review(
86
+
State(state): State<AppState>,
87
+
Path(batch_id): Path<String>,
88
+
Json(request): Json<SubmitReviewRequest>,
89
+
) -> Result<Json<SubmitReviewResponse>, AppError> {
90
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
91
+
let signer = state
92
+
.signer
93
+
.as_ref()
94
+
.ok_or(AppError::LabelerNotConfigured)?;
95
+
96
+
let _batch = db
97
+
.get_batch(&batch_id)
98
+
.await?
99
+
.ok_or(AppError::NotFound("batch not found".to_string()))?;
100
+
101
+
let mut resolved_count = 0;
102
+
103
+
for decision in &request.decisions {
104
+
tracing::info!(
105
+
batch_id = %batch_id,
106
+
uri = %decision.uri,
107
+
decision = %decision.decision,
108
+
"processing review decision"
109
+
);
110
+
111
+
db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision)
112
+
.await?;
113
+
114
+
match decision.decision.as_str() {
115
+
"clear" => {
116
+
// False positive - emit negation label to clear the flag
117
+
let label =
118
+
crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation")
119
+
.negated();
120
+
let label = signer.sign_label(label)?;
121
+
let seq = db.store_label(&label).await?;
122
+
123
+
db.store_resolution(
124
+
&decision.uri,
125
+
crate::db::ResolutionReason::FingerprintNoise,
126
+
Some("batch review: cleared"),
127
+
)
128
+
.await?;
129
+
130
+
if let Some(tx) = &state.label_tx {
131
+
let _ = tx.send((seq, label));
132
+
}
133
+
134
+
resolved_count += 1;
135
+
}
136
+
"defer" => {
137
+
// Acknowledge but take no action - flag stays active
138
+
// Just mark as reviewed in the batch, no label changes
139
+
tracing::info!(uri = %decision.uri, "deferred - no action taken");
140
+
}
141
+
"confirm" => {
142
+
// Real violation - flag stays active, could add enforcement later
143
+
tracing::info!(uri = %decision.uri, "confirmed as violation");
144
+
}
145
+
_ => {
146
+
tracing::warn!(uri = %decision.uri, decision = %decision.decision, "unknown decision type");
147
+
}
148
+
}
149
+
}
150
+
151
+
let pending = db.get_batch_pending_uris(&batch_id).await?;
152
+
if pending.is_empty() {
153
+
db.update_batch_status(&batch_id, "completed").await?;
154
+
}
155
+
156
+
Ok(Json(SubmitReviewResponse {
157
+
resolved_count,
158
+
message: format!(
159
+
"processed {} decisions, resolved {} flags",
160
+
request.decisions.len(),
161
+
resolved_count
162
+
),
163
+
}))
164
+
}
165
+
166
+
/// Render the review page.
167
+
fn render_review_page(batch_id: &str, flags: &[FlaggedTrack], status: &str) -> String {
168
+
let pending: Vec<_> = flags.iter().filter(|f| !f.resolved).collect();
169
+
let resolved: Vec<_> = flags.iter().filter(|f| f.resolved).collect();
170
+
171
+
let pending_cards: Vec<String> = pending.iter().map(|f| render_review_card(f)).collect();
172
+
let resolved_cards: Vec<String> = resolved.iter().map(|f| render_review_card(f)).collect();
173
+
174
+
let pending_html = if pending_cards.is_empty() {
175
+
"<div class=\"empty\">all flags reviewed!</div>".to_string()
176
+
} else {
177
+
pending_cards.join("\n")
178
+
};
179
+
180
+
let resolved_html = if resolved_cards.is_empty() {
181
+
String::new()
182
+
} else {
183
+
format!(
184
+
r#"<details class="resolved-section">
185
+
<summary>{} resolved</summary>
186
+
{}
187
+
</details>"#,
188
+
resolved_cards.len(),
189
+
resolved_cards.join("\n")
190
+
)
191
+
};
192
+
193
+
let status_badge = if status == "completed" {
194
+
r#"<span class="badge resolved">completed</span>"#
195
+
} else {
196
+
""
197
+
};
198
+
199
+
format!(
200
+
r#"<!DOCTYPE html>
201
+
<html lang="en">
202
+
<head>
203
+
<meta charset="utf-8">
204
+
<meta name="viewport" content="width=device-width, initial-scale=1">
205
+
<title>review batch - plyr.fm</title>
206
+
<link rel="stylesheet" href="/static/admin.css">
207
+
<style>{}</style>
208
+
</head>
209
+
<body>
210
+
<h1>plyr.fm moderation</h1>
211
+
<p class="subtitle">
212
+
<a href="/admin">← back to dashboard</a>
213
+
<span style="margin: 0 12px; color: var(--text-muted);">|</span>
214
+
batch review: {} pending {}
215
+
</p>
216
+
217
+
<div class="auth-section" id="auth-section">
218
+
<input type="password" id="auth-token" placeholder="auth token"
219
+
onkeyup="if(event.key==='Enter')authenticate()">
220
+
<button class="btn btn-primary" onclick="authenticate()">authenticate</button>
221
+
</div>
222
+
223
+
<form id="review-form" style="display: none;">
224
+
<div class="flags-list">
225
+
{}
226
+
</div>
227
+
228
+
{}
229
+
230
+
<div class="submit-bar">
231
+
<button type="submit" class="btn btn-primary" id="submit-btn" disabled>
232
+
submit decisions
233
+
</button>
234
+
</div>
235
+
</form>
236
+
237
+
<script>
238
+
const form = document.getElementById('review-form');
239
+
const submitBtn = document.getElementById('submit-btn');
240
+
const authSection = document.getElementById('auth-section');
241
+
const batchId = '{}';
242
+
243
+
let currentToken = '';
244
+
const decisions = {{}};
245
+
246
+
function authenticate() {{
247
+
const token = document.getElementById('auth-token').value;
248
+
if (token && token !== '••••••••') {{
249
+
localStorage.setItem('mod_token', token);
250
+
currentToken = token;
251
+
showReviewForm();
252
+
}}
253
+
}}
254
+
255
+
function showReviewForm() {{
256
+
authSection.style.display = 'none';
257
+
form.style.display = 'block';
258
+
}}
259
+
260
+
// Check for saved token on load
261
+
const savedToken = localStorage.getItem('mod_token');
262
+
if (savedToken) {{
263
+
currentToken = savedToken;
264
+
document.getElementById('auth-token').value = '••••••••';
265
+
showReviewForm();
266
+
}}
267
+
268
+
function updateSubmitBtn() {{
269
+
const count = Object.keys(decisions).length;
270
+
submitBtn.disabled = count === 0;
271
+
submitBtn.textContent = count > 0 ? `submit ${{count}} decision${{count > 1 ? 's' : ''}}` : 'submit decisions';
272
+
}}
273
+
274
+
function setDecision(uri, decision) {{
275
+
// Toggle off if clicking the same decision
276
+
if (decisions[uri] === decision) {{
277
+
delete decisions[uri];
278
+
const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
279
+
if (card) card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm');
280
+
}} else {{
281
+
decisions[uri] = decision;
282
+
const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`);
283
+
if (card) {{
284
+
card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm');
285
+
card.classList.add('decision-' + decision);
286
+
}}
287
+
}}
288
+
updateSubmitBtn();
289
+
}}
290
+
291
+
form.addEventListener('submit', async (e) => {{
292
+
e.preventDefault();
293
+
submitBtn.disabled = true;
294
+
submitBtn.textContent = 'submitting...';
295
+
296
+
try {{
297
+
const response = await fetch(`/admin/review/${{batchId}}/submit`, {{
298
+
method: 'POST',
299
+
headers: {{
300
+
'Content-Type': 'application/json',
301
+
'X-Moderation-Key': currentToken
302
+
}},
303
+
body: JSON.stringify({{
304
+
decisions: Object.entries(decisions).map(([uri, decision]) => ({{ uri, decision }}))
305
+
}})
306
+
}});
307
+
308
+
if (response.status === 401) {{
309
+
localStorage.removeItem('mod_token');
310
+
currentToken = '';
311
+
authSection.style.display = 'block';
312
+
form.style.display = 'none';
313
+
document.getElementById('auth-token').value = '';
314
+
alert('invalid token');
315
+
return;
316
+
}}
317
+
318
+
if (response.ok) {{
319
+
const result = await response.json();
320
+
alert(result.message);
321
+
location.reload();
322
+
}} else {{
323
+
const err = await response.json();
324
+
alert('error: ' + (err.message || 'unknown error'));
325
+
submitBtn.disabled = false;
326
+
updateSubmitBtn();
327
+
}}
328
+
}} catch (err) {{
329
+
alert('network error: ' + err.message);
330
+
submitBtn.disabled = false;
331
+
updateSubmitBtn();
332
+
}}
333
+
}});
334
+
</script>
335
+
</body>
336
+
</html>"#,
337
+
REVIEW_CSS,
338
+
pending.len(),
339
+
status_badge,
340
+
pending_html,
341
+
resolved_html,
342
+
html_escape(batch_id)
343
+
)
344
+
}
345
+
346
+
/// Render a single review card.
347
+
fn render_review_card(track: &FlaggedTrack) -> String {
348
+
let ctx = track.context.as_ref();
349
+
350
+
let title = ctx
351
+
.and_then(|c| c.track_title.as_deref())
352
+
.unwrap_or("unknown track");
353
+
let artist = ctx
354
+
.and_then(|c| c.artist_handle.as_deref())
355
+
.unwrap_or("unknown");
356
+
let track_id = ctx.and_then(|c| c.track_id);
357
+
358
+
let title_html = if let Some(id) = track_id {
359
+
format!(
360
+
r#"<a href="https://plyr.fm/track/{}" target="_blank">{}</a>"#,
361
+
id,
362
+
html_escape(title)
363
+
)
364
+
} else {
365
+
html_escape(title)
366
+
};
367
+
368
+
let matches_html = ctx
369
+
.and_then(|c| c.matches.as_ref())
370
+
.filter(|m| !m.is_empty())
371
+
.map(|matches| {
372
+
let items: Vec<String> = matches
373
+
.iter()
374
+
.take(3)
375
+
.map(|m| {
376
+
format!(
377
+
r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#,
378
+
html_escape(&m.title),
379
+
html_escape(&m.artist)
380
+
)
381
+
})
382
+
.collect();
383
+
format!(
384
+
r#"<div class="matches"><h4>potential matches</h4>{}</div>"#,
385
+
items.join("\n")
386
+
)
387
+
})
388
+
.unwrap_or_default();
389
+
390
+
let resolved_badge = if track.resolved {
391
+
r#"<span class="badge resolved">resolved</span>"#
392
+
} else {
393
+
r#"<span class="badge pending">pending</span>"#
394
+
};
395
+
396
+
let action_buttons = if !track.resolved {
397
+
format!(
398
+
r#"<div class="flag-actions">
399
+
<button type="button" class="btn btn-clear" onclick="setDecision('{}', 'clear')">clear</button>
400
+
<button type="button" class="btn btn-defer" onclick="setDecision('{}', 'defer')">defer</button>
401
+
<button type="button" class="btn btn-confirm" onclick="setDecision('{}', 'confirm')">confirm</button>
402
+
</div>"#,
403
+
html_escape(&track.uri),
404
+
html_escape(&track.uri),
405
+
html_escape(&track.uri)
406
+
)
407
+
} else {
408
+
String::new()
409
+
};
410
+
411
+
format!(
412
+
r#"<div class="flag-card{}" data-uri="{}">
413
+
<div class="flag-header">
414
+
<div class="track-info">
415
+
<h3>{}</h3>
416
+
<div class="artist">@{}</div>
417
+
</div>
418
+
<div class="flag-badges">
419
+
{}
420
+
</div>
421
+
</div>
422
+
{}
423
+
{}
424
+
</div>"#,
425
+
if track.resolved { " resolved" } else { "" },
426
+
html_escape(&track.uri),
427
+
title_html,
428
+
html_escape(artist),
429
+
resolved_badge,
430
+
matches_html,
431
+
action_buttons
432
+
)
433
+
}
434
+
435
+
fn html_escape(s: &str) -> String {
436
+
s.replace('&', "&")
437
+
.replace('<', "<")
438
+
.replace('>', ">")
439
+
.replace('"', """)
440
+
.replace('\'', "'")
441
+
}
442
+
443
+
/// Additional CSS for review page (supplements admin.css)
444
+
const REVIEW_CSS: &str = r#"
445
+
/* review page specific styles */
446
+
body { padding-bottom: 80px; }
447
+
448
+
.subtitle a {
449
+
color: var(--accent);
450
+
text-decoration: none;
451
+
}
452
+
.subtitle a:hover { text-decoration: underline; }
453
+
454
+
/* action buttons */
455
+
.btn-clear {
456
+
background: rgba(74, 222, 128, 0.15);
457
+
color: var(--success);
458
+
border: 1px solid rgba(74, 222, 128, 0.3);
459
+
}
460
+
.btn-clear:hover {
461
+
background: rgba(74, 222, 128, 0.25);
462
+
}
463
+
464
+
.btn-defer {
465
+
background: rgba(251, 191, 36, 0.15);
466
+
color: var(--warning);
467
+
border: 1px solid rgba(251, 191, 36, 0.3);
468
+
}
469
+
.btn-defer:hover {
470
+
background: rgba(251, 191, 36, 0.25);
471
+
}
472
+
473
+
.btn-confirm {
474
+
background: rgba(239, 68, 68, 0.15);
475
+
color: var(--error);
476
+
border: 1px solid rgba(239, 68, 68, 0.3);
477
+
}
478
+
.btn-confirm:hover {
479
+
background: rgba(239, 68, 68, 0.25);
480
+
}
481
+
482
+
/* card selection states */
483
+
.flag-card.decision-clear {
484
+
border-color: var(--success);
485
+
background: rgba(74, 222, 128, 0.05);
486
+
}
487
+
.flag-card.decision-defer {
488
+
border-color: var(--warning);
489
+
background: rgba(251, 191, 36, 0.05);
490
+
}
491
+
.flag-card.decision-confirm {
492
+
border-color: var(--error);
493
+
background: rgba(239, 68, 68, 0.05);
494
+
}
495
+
496
+
/* submit bar */
497
+
.submit-bar {
498
+
position: fixed;
499
+
bottom: 0;
500
+
left: 0;
501
+
right: 0;
502
+
padding: 16px 24px;
503
+
background: var(--bg-secondary);
504
+
border-top: 1px solid var(--border-subtle);
505
+
}
506
+
.submit-bar .btn {
507
+
width: 100%;
508
+
max-width: 900px;
509
+
margin: 0 auto;
510
+
display: block;
511
+
padding: 14px;
512
+
}
513
+
514
+
/* resolved section */
515
+
.resolved-section {
516
+
margin-top: 24px;
517
+
padding-top: 16px;
518
+
border-top: 1px solid var(--border-subtle);
519
+
}
520
+
.resolved-section summary {
521
+
cursor: pointer;
522
+
color: var(--text-tertiary);
523
+
font-size: 0.85rem;
524
+
margin-bottom: 12px;
525
+
}
526
+
"#;
+4
moderation/src/state.rs
+4
moderation/src/state.rs
···
35
35
#[error("bad request: {0}")]
36
36
BadRequest(String),
37
37
38
+
#[error("not found: {0}")]
39
+
NotFound(String),
40
+
38
41
#[error("label error: {0}")]
39
42
Label(#[from] LabelError),
40
43
···
54
57
(StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured")
55
58
}
56
59
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"),
60
+
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "NotFound"),
57
61
AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"),
58
62
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"),
59
63
AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
+41
redis/README.md
+41
redis/README.md
···
1
+
# plyr-redis
2
+
3
+
self-hosted Redis on Fly.io for docket background tasks.
4
+
5
+
## deployment
6
+
7
+
```bash
8
+
# first time: create app and volume
9
+
fly apps create plyr-redis
10
+
fly volumes create redis_data --region iad --size 1 -a plyr-redis
11
+
12
+
# deploy
13
+
fly deploy -a plyr-redis
14
+
```
15
+
16
+
## connecting from other fly apps
17
+
18
+
Redis is accessible via Fly's private network:
19
+
20
+
```
21
+
redis://plyr-redis.internal:6379
22
+
```
23
+
24
+
Update `DOCKET_URL` secret on backend apps:
25
+
26
+
```bash
27
+
fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api
28
+
fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api-staging
29
+
```
30
+
31
+
## configuration
32
+
33
+
- **persistence**: AOF (append-only file) enabled for durability
34
+
- **memory**: 200MB max with LRU eviction
35
+
- **storage**: 1GB volume mounted at /data
36
+
37
+
## cost
38
+
39
+
~$1.94/month (256MB shared-cpu VM) + $0.15/month (1GB volume) = ~$2.09/month
40
+
41
+
vs. Upstash pay-as-you-go which was costing ~$75/month at 37M commands.
+29
redis/fly.staging.toml
+29
redis/fly.staging.toml
···
1
+
app = "plyr-redis-stg"
2
+
primary_region = "iad"
3
+
4
+
[build]
5
+
image = "redis:7-alpine"
6
+
7
+
[mounts]
8
+
source = "redis_data"
9
+
destination = "/data"
10
+
11
+
[env]
12
+
# redis config via command line args in [processes]
13
+
14
+
[processes]
15
+
app = "--appendonly yes --maxmemory 200mb --maxmemory-policy allkeys-lru"
16
+
17
+
[[services]]
18
+
protocol = "tcp"
19
+
internal_port = 6379
20
+
processes = ["app"]
21
+
22
+
# only accessible within private network
23
+
[[services.ports]]
24
+
port = 6379
25
+
26
+
[[vm]]
27
+
memory = "256mb"
28
+
cpu_kind = "shared"
29
+
cpus = 1
+29
redis/fly.toml
+29
redis/fly.toml
···
1
+
app = "plyr-redis"
2
+
primary_region = "iad"
3
+
4
+
[build]
5
+
image = "redis:7-alpine"
6
+
7
+
[mounts]
8
+
source = "redis_data"
9
+
destination = "/data"
10
+
11
+
[env]
12
+
# redis config via command line args in [processes]
13
+
14
+
[processes]
15
+
app = "--appendonly yes --maxmemory 200mb --maxmemory-policy allkeys-lru"
16
+
17
+
[[services]]
18
+
protocol = "tcp"
19
+
internal_port = 6379
20
+
processes = ["app"]
21
+
22
+
# only accessible within private network
23
+
[[services.ports]]
24
+
port = 6379
25
+
26
+
[[vm]]
27
+
memory = "256mb"
28
+
cpu_kind = "shared"
29
+
cpus = 1
+1
-10
scripts/costs/export_costs.py
+1
-10
scripts/costs/export_costs.py
···
39
39
# fly.io: manually updated from cost explorer (TODO: use fly billing API)
40
40
# neon: fixed $5/month
41
41
# cloudflare: mostly free tier
42
-
# upstash: free tier (256MB, 500K commands/month)
42
+
# redis: self-hosted on fly (included in fly_io costs)
43
43
FIXED_COSTS = {
44
44
"fly_io": {
45
45
"breakdown": {
···
60
60
"domain": 1.00,
61
61
"total": 1.16,
62
62
"note": "r2 egress is free, pages free tier",
63
-
},
64
-
"upstash": {
65
-
"total": 0.00,
66
-
"note": "redis for docket + caching (free tier: 256MB, 500K commands/month)",
67
63
},
68
64
}
69
65
···
206
202
plyr_fly
207
203
+ FIXED_COSTS["neon"]["total"]
208
204
+ FIXED_COSTS["cloudflare"]["total"]
209
-
+ FIXED_COSTS["upstash"]["total"]
210
205
+ audd_stats["estimated_cost"]
211
206
)
212
207
···
231
226
"domain": FIXED_COSTS["cloudflare"]["domain"],
232
227
},
233
228
"note": FIXED_COSTS["cloudflare"]["note"],
234
-
},
235
-
"upstash": {
236
-
"amount": FIXED_COSTS["upstash"]["total"],
237
-
"note": FIXED_COSTS["upstash"]["note"],
238
229
},
239
230
"audd": {
240
231
"amount": audd_stats["estimated_cost"],
+4
-6
scripts/docket_runs.py
+4
-6
scripts/docket_runs.py
···
38
38
url = os.environ.get("DOCKET_URL_STAGING")
39
39
if not url:
40
40
print("error: DOCKET_URL_STAGING not set")
41
-
print(
42
-
"hint: export DOCKET_URL_STAGING=rediss://default:xxx@xxx.upstash.io:6379"
43
-
)
41
+
print("hint: flyctl proxy 6380:6379 -a plyr-redis-stg")
42
+
print(" export DOCKET_URL_STAGING=redis://localhost:6380")
44
43
return 1
45
44
elif args.env == "production":
46
45
url = os.environ.get("DOCKET_URL_PRODUCTION")
47
46
if not url:
48
47
print("error: DOCKET_URL_PRODUCTION not set")
49
-
print(
50
-
"hint: export DOCKET_URL_PRODUCTION=rediss://default:xxx@xxx.upstash.io:6379"
51
-
)
48
+
print("hint: flyctl proxy 6381:6379 -a plyr-redis")
49
+
print(" export DOCKET_URL_PRODUCTION=redis://localhost:6381")
52
50
return 1
53
51
54
52
print(f"connecting to {args.env}...")
+348
scripts/moderation_loop.py
+348
scripts/moderation_loop.py
···
1
+
#!/usr/bin/env -S uv run --script --quiet
2
+
# /// script
3
+
# requires-python = ">=3.12"
4
+
# dependencies = [
5
+
# "pydantic-ai>=0.1.0",
6
+
# "anthropic",
7
+
# "httpx",
8
+
# "pydantic>=2.0",
9
+
# "pydantic-settings",
10
+
# "atproto>=0.0.55",
11
+
# "rich",
12
+
# ]
13
+
# ///
14
+
"""autonomous moderation loop for plyr.fm.
15
+
16
+
workflow:
17
+
1. fetch pending flags from moderation service
18
+
2. analyze each flag with LLM (FALSE_POSITIVE, VIOLATION, NEEDS_HUMAN)
19
+
3. auto-resolve false positives
20
+
4. create review batch for needs_human flags
21
+
5. send DM with link to review UI
22
+
23
+
the review UI handles human decisions - DM is just a notification channel.
24
+
"""
25
+
26
+
import argparse
27
+
import asyncio
28
+
from dataclasses import dataclass, field
29
+
from pathlib import Path
30
+
31
+
import httpx
32
+
from atproto import AsyncClient, models
33
+
from pydantic import BaseModel, Field
34
+
from pydantic_ai import Agent
35
+
from pydantic_ai.models.anthropic import AnthropicModel
36
+
from pydantic_settings import BaseSettings, SettingsConfigDict
37
+
from rich.console import Console
38
+
39
+
console = Console()
40
+
41
+
42
+
class LoopSettings(BaseSettings):
43
+
model_config = SettingsConfigDict(
44
+
env_file=Path(__file__).parent.parent / ".env",
45
+
case_sensitive=False,
46
+
extra="ignore",
47
+
)
48
+
moderation_service_url: str = Field(
49
+
default="https://moderation.plyr.fm", validation_alias="MODERATION_SERVICE_URL"
50
+
)
51
+
moderation_auth_token: str = Field(
52
+
default="", validation_alias="MODERATION_AUTH_TOKEN"
53
+
)
54
+
anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY")
55
+
anthropic_model: str = Field(
56
+
default="claude-sonnet-4-20250514", validation_alias="ANTHROPIC_MODEL"
57
+
)
58
+
bot_handle: str = Field(default="", validation_alias="NOTIFY_BOT_HANDLE")
59
+
bot_password: str = Field(default="", validation_alias="NOTIFY_BOT_PASSWORD")
60
+
recipient_handle: str = Field(
61
+
default="", validation_alias="NOTIFY_RECIPIENT_HANDLE"
62
+
)
63
+
64
+
65
+
class FlagAnalysis(BaseModel):
66
+
"""result of analyzing a single flag."""
67
+
68
+
uri: str
69
+
category: str = Field(description="FALSE_POSITIVE, VIOLATION, or NEEDS_HUMAN")
70
+
reason: str
71
+
72
+
73
+
@dataclass
74
+
class DMClient:
75
+
handle: str
76
+
password: str
77
+
recipient_handle: str
78
+
_client: AsyncClient = field(init=False, repr=False)
79
+
_dm_client: AsyncClient = field(init=False, repr=False)
80
+
_recipient_did: str = field(init=False, repr=False)
81
+
_convo_id: str = field(init=False, repr=False)
82
+
83
+
async def setup(self) -> None:
84
+
self._client = AsyncClient()
85
+
await self._client.login(self.handle, self.password)
86
+
self._dm_client = self._client.with_bsky_chat_proxy()
87
+
profile = await self._client.app.bsky.actor.get_profile(
88
+
{"actor": self.recipient_handle}
89
+
)
90
+
self._recipient_did = profile.did
91
+
convo = await self._dm_client.chat.bsky.convo.get_convo_for_members(
92
+
models.ChatBskyConvoGetConvoForMembers.Params(members=[self._recipient_did])
93
+
)
94
+
self._convo_id = convo.convo.id
95
+
96
+
async def get_messages(self, limit: int = 30) -> list[dict]:
97
+
response = await self._dm_client.chat.bsky.convo.get_messages(
98
+
models.ChatBskyConvoGetMessages.Params(convo_id=self._convo_id, limit=limit)
99
+
)
100
+
return [
101
+
{
102
+
"text": m.text,
103
+
"is_bot": m.sender.did != self._recipient_did,
104
+
"sent_at": getattr(m, "sent_at", None),
105
+
}
106
+
for m in response.messages
107
+
if hasattr(m, "text") and hasattr(m, "sender")
108
+
]
109
+
110
+
async def send(self, text: str) -> None:
111
+
await self._dm_client.chat.bsky.convo.send_message(
112
+
models.ChatBskyConvoSendMessage.Data(
113
+
convo_id=self._convo_id,
114
+
message=models.ChatBskyConvoDefs.MessageInput(text=text),
115
+
)
116
+
)
117
+
118
+
119
+
@dataclass
120
+
class PlyrClient:
121
+
"""client for checking track existence in plyr.fm."""
122
+
123
+
env: str = "prod"
124
+
_client: httpx.AsyncClient = field(init=False, repr=False)
125
+
126
+
def __post_init__(self) -> None:
127
+
base_url = {
128
+
"prod": "https://api.plyr.fm",
129
+
"staging": "https://api-stg.plyr.fm",
130
+
"dev": "http://localhost:8001",
131
+
}.get(self.env, "https://api.plyr.fm")
132
+
self._client = httpx.AsyncClient(base_url=base_url, timeout=10.0)
133
+
134
+
async def close(self) -> None:
135
+
await self._client.aclose()
136
+
137
+
async def track_exists(self, track_id: int) -> bool:
138
+
"""check if a track exists (returns False if 404)."""
139
+
try:
140
+
r = await self._client.get(f"/tracks/{track_id}")
141
+
return r.status_code == 200
142
+
except Exception:
143
+
return True # assume exists on error (don't accidentally delete labels)
144
+
145
+
146
+
@dataclass
147
+
class ModClient:
148
+
base_url: str
149
+
auth_token: str
150
+
_client: httpx.AsyncClient = field(init=False, repr=False)
151
+
152
+
def __post_init__(self) -> None:
153
+
self._client = httpx.AsyncClient(
154
+
base_url=self.base_url,
155
+
headers={"X-Moderation-Key": self.auth_token},
156
+
timeout=30.0,
157
+
)
158
+
159
+
async def close(self) -> None:
160
+
await self._client.aclose()
161
+
162
+
async def list_pending(self) -> list[dict]:
163
+
r = await self._client.get("/admin/flags", params={"filter": "pending"})
164
+
r.raise_for_status()
165
+
return r.json().get("tracks", [])
166
+
167
+
async def resolve(self, uri: str, reason: str, notes: str = "") -> None:
168
+
r = await self._client.post(
169
+
"/admin/resolve",
170
+
json={
171
+
"uri": uri,
172
+
"val": "copyright-violation",
173
+
"reason": reason,
174
+
"notes": notes,
175
+
},
176
+
)
177
+
r.raise_for_status()
178
+
179
+
async def create_batch(
180
+
self, uris: list[str], created_by: str | None = None
181
+
) -> dict:
182
+
"""create a review batch and return {id, url, flag_count}."""
183
+
r = await self._client.post(
184
+
"/admin/batches",
185
+
json={"uris": uris, "created_by": created_by},
186
+
)
187
+
r.raise_for_status()
188
+
return r.json()
189
+
190
+
191
+
def get_header(env: str) -> str:
192
+
return f"[PLYR-MOD:{env.upper()}]"
193
+
194
+
195
+
def create_flag_analyzer(api_key: str, model: str) -> Agent[None, list[FlagAnalysis]]:
196
+
from pydantic_ai.providers.anthropic import AnthropicProvider
197
+
198
+
return Agent(
199
+
model=AnthropicModel(model, provider=AnthropicProvider(api_key=api_key)),
200
+
output_type=list[FlagAnalysis],
201
+
system_prompt="""\
202
+
analyze each copyright flag. categorize as:
203
+
- FALSE_POSITIVE: fingerprint noise, uploader is the artist, unrelated matches
204
+
- VIOLATION: clearly copyrighted commercial content
205
+
- NEEDS_HUMAN: ambiguous, need human review
206
+
207
+
return a FlagAnalysis for each flag with uri, category, and brief reason.
208
+
""",
209
+
)
210
+
211
+
212
+
async def run_loop(
213
+
dry_run: bool = False, limit: int | None = None, env: str = "prod"
214
+
) -> None:
215
+
settings = LoopSettings()
216
+
for attr in [
217
+
"moderation_auth_token",
218
+
"anthropic_api_key",
219
+
"bot_handle",
220
+
"bot_password",
221
+
"recipient_handle",
222
+
]:
223
+
if not getattr(settings, attr):
224
+
console.print(f"[red]missing {attr}[/red]")
225
+
return
226
+
227
+
console.print(f"[bold]moderation loop[/bold] ({settings.anthropic_model})")
228
+
if dry_run:
229
+
console.print("[yellow]DRY RUN[/yellow]")
230
+
231
+
dm = DMClient(settings.bot_handle, settings.bot_password, settings.recipient_handle)
232
+
mod = ModClient(settings.moderation_service_url, settings.moderation_auth_token)
233
+
plyr = PlyrClient(env=env)
234
+
235
+
try:
236
+
await dm.setup()
237
+
238
+
# get pending flags
239
+
pending = await mod.list_pending()
240
+
if not pending:
241
+
console.print("[green]no pending flags[/green]")
242
+
return
243
+
244
+
console.print(f"[bold]{len(pending)} pending flags[/bold]")
245
+
246
+
# check for deleted tracks and auto-resolve them
247
+
console.print("[dim]checking for deleted tracks...[/dim]")
248
+
active_flags = []
249
+
deleted_count = 0
250
+
for flag in pending:
251
+
track_id = flag.get("context", {}).get("track_id")
252
+
if track_id and not await plyr.track_exists(track_id):
253
+
# track was deleted - resolve the flag
254
+
if not dry_run:
255
+
try:
256
+
await mod.resolve(
257
+
flag["uri"], "content_deleted", "track no longer exists"
258
+
)
259
+
console.print(
260
+
f" [yellow]⌫[/yellow] deleted: {flag['uri'][-40:]}"
261
+
)
262
+
deleted_count += 1
263
+
except Exception as e:
264
+
console.print(f" [red]✗[/red] {e}")
265
+
active_flags.append(flag)
266
+
else:
267
+
console.print(
268
+
f" [yellow]would resolve deleted:[/yellow] {flag['uri'][-40:]}"
269
+
)
270
+
deleted_count += 1
271
+
else:
272
+
active_flags.append(flag)
273
+
274
+
if deleted_count > 0:
275
+
console.print(f"[yellow]{deleted_count} deleted tracks resolved[/yellow]")
276
+
277
+
pending = active_flags
278
+
if not pending:
279
+
console.print("[green]all flags were for deleted tracks[/green]")
280
+
return
281
+
282
+
# analyze remaining flags
283
+
if limit:
284
+
pending = pending[:limit]
285
+
286
+
analyzer = create_flag_analyzer(
287
+
settings.anthropic_api_key, settings.anthropic_model
288
+
)
289
+
desc = "\n---\n".join(
290
+
f"URI: {f['uri']}\nTrack: {f.get('context', {}).get('track_title', '?')}\n"
291
+
f"Uploader: @{f.get('context', {}).get('artist_handle', '?')}\n"
292
+
f"Matches: {', '.join(m['artist'] for m in f.get('context', {}).get('matches', [])[:3])}"
293
+
for f in pending
294
+
)
295
+
result = await analyzer.run(f"analyze {len(pending)} flags:\n\n{desc}")
296
+
analyses = result.output
297
+
298
+
# auto-resolve false positives
299
+
auto = [a for a in analyses if a.category == "FALSE_POSITIVE"]
300
+
human = [a for a in analyses if a.category == "NEEDS_HUMAN"]
301
+
console.print(f"analysis: {len(auto)} auto-resolve, {len(human)} need human")
302
+
303
+
for a in auto:
304
+
if not dry_run:
305
+
try:
306
+
await mod.resolve(
307
+
a.uri, "fingerprint_noise", f"auto: {a.reason[:50]}"
308
+
)
309
+
console.print(f" [green]✓[/green] {a.uri[-40:]}")
310
+
except Exception as e:
311
+
console.print(f" [red]✗[/red] {e}")
312
+
313
+
# create batch and send link for needs_human (if any)
314
+
if human:
315
+
human_uris = [h.uri for h in human]
316
+
console.print(f"[dim]creating batch for {len(human_uris)} flags...[/dim]")
317
+
318
+
if not dry_run:
319
+
batch = await mod.create_batch(human_uris, created_by="moderation_loop")
320
+
full_url = f"{mod.base_url.rstrip('/')}{batch['url']}"
321
+
msg = (
322
+
f"{get_header(env)} {batch['flag_count']} need review:\n{full_url}"
323
+
)
324
+
await dm.send(msg)
325
+
console.print(f"[green]sent batch {batch['id']}[/green]")
326
+
else:
327
+
console.print(
328
+
f"[yellow]would create batch with {len(human_uris)} flags[/yellow]"
329
+
)
330
+
331
+
console.print("[bold]done[/bold]")
332
+
333
+
finally:
334
+
await mod.close()
335
+
await plyr.close()
336
+
337
+
338
+
def main() -> None:
339
+
parser = argparse.ArgumentParser()
340
+
parser.add_argument("--dry-run", action="store_true")
341
+
parser.add_argument("--limit", type=int, default=None)
342
+
parser.add_argument("--env", default="prod", choices=["dev", "staging", "prod"])
343
+
args = parser.parse_args()
344
+
asyncio.run(run_loop(dry_run=args.dry_run, limit=args.limit, env=args.env))
345
+
346
+
347
+
if __name__ == "__main__":
348
+
main()