+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
+27
-11
STATUS.md
+27
-11
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":
···
168
180
169
181
## immediate priorities
170
182
171
-
### end-of-year sprint (Dec 20-31)
183
+
### quality of life mode (Dec 29-31)
172
184
173
-
see [sprint tracking issue #625](https://github.com/zzstoatzz/plyr.fm/issues/625) for details.
185
+
end-of-year sprint [#625](https://github.com/zzstoatzz/plyr.fm/issues/625) complete. remaining days before 2026 are for minor polish and bug fixes as they arise.
174
186
175
-
| track | focus | status |
176
-
|-------|-------|--------|
177
-
| moderation | consolidate architecture, add rules engine | planning |
178
-
| atprotofans | supporter validation, content gating | shipped |
187
+
**what shipped in the sprint:**
188
+
- moderation consolidation: sensitive images moved to moderation service (#644)
189
+
- atprotofans: supporter badges (#627) and content gating (#637)
190
+
191
+
**aspirational (deferred until scale justifies):**
192
+
- configurable rules engine for moderation
193
+
- time-release gating (#642)
179
194
180
195
### known issues
181
196
- playback auto-start on refresh (#225)
···
262
277
263
278
## cost structure
264
279
265
-
current monthly costs: ~$18/month (plyr.fm specific)
280
+
current monthly costs: ~$20/month (plyr.fm specific)
266
281
267
282
see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
268
283
269
-
- fly.io (plyr apps only): ~$12/month
284
+
- fly.io (backend + redis + moderation): ~$14/month
270
285
- neon postgres: $5/month
271
-
- cloudflare (R2 + pages + domain): ~$1.16/month
272
-
- 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)
273
288
- logfire: $0 (free tier)
274
289
275
290
## admin tooling
···
320
335
│ └── src/routes/ # pages
321
336
├── moderation/ # Rust moderation service (ATProto labeler)
322
337
├── transcoder/ # Rust audio transcoding service
338
+
├── redis/ # self-hosted Redis config
323
339
├── docs/ # documentation
324
340
└── justfile # task runner
325
341
```
···
335
351
336
352
---
337
353
338
-
this is a living document. last updated 2025-12-23.
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
backend/src/backend/_internal/background.py
+2
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,
+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`)
+16
-16
frontend/src/lib/components/AddToMenu.svelte
+16
-16
frontend/src/lib/components/AddToMenu.svelte
···
439
439
justify-content: center;
440
440
background: transparent;
441
441
border: 1px solid var(--border-default);
442
-
border-radius: 4px;
442
+
border-radius: var(--radius-sm);
443
443
color: var(--text-tertiary);
444
444
cursor: pointer;
445
445
transition: all 0.2s;
···
490
490
min-width: 200px;
491
491
background: var(--bg-secondary);
492
492
border: 1px solid var(--border-default);
493
-
border-radius: 8px;
493
+
border-radius: var(--radius-md);
494
494
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
495
495
overflow: hidden;
496
496
z-index: 10;
···
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;
···
567
567
568
568
.playlist-list::-webkit-scrollbar-track {
569
569
background: transparent;
570
-
border-radius: 4px;
570
+
border-radius: var(--radius-sm);
571
571
}
572
572
573
573
.playlist-list::-webkit-scrollbar-thumb {
574
574
background: var(--border-default);
575
-
border-radius: 4px;
575
+
border-radius: var(--radius-sm);
576
576
}
577
577
578
578
.playlist-list::-webkit-scrollbar-thumb:hover {
···
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;
···
607
607
.playlist-thumb-placeholder {
608
608
width: 32px;
609
609
height: 32px;
610
-
border-radius: 4px;
610
+
border-radius: var(--radius-sm);
611
611
flex-shrink: 0;
612
612
}
613
613
···
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;
···
675
675
padding: 0.625rem 0.75rem;
676
676
background: var(--bg-tertiary);
677
677
border: 1px solid var(--border-default);
678
-
border-radius: 6px;
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 {
···
698
698
padding: 0.625rem 1rem;
699
699
background: var(--accent);
700
700
border: none;
701
-
border-radius: 6px;
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;
···
721
721
height: 16px;
722
722
border: 2px solid var(--border-default);
723
723
border-top-color: var(--accent);
724
-
border-radius: 50%;
724
+
border-radius: var(--radius-full);
725
725
animation: spin 0.8s linear infinite;
726
726
}
727
727
···
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 {
+7
-7
frontend/src/lib/components/AlbumSelect.svelte
+7
-7
frontend/src/lib/components/AlbumSelect.svelte
···
102
102
padding: 0.75rem;
103
103
background: var(--bg-primary);
104
104
border: 1px solid var(--border-default);
105
-
border-radius: 4px;
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
}
···
127
127
overflow-y: auto;
128
128
background: var(--bg-tertiary);
129
129
border: 1px solid var(--border-default);
130
-
border-radius: 4px;
130
+
border-radius: var(--radius-sm);
131
131
margin-top: 0.25rem;
132
132
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
133
133
}
···
139
139
140
140
.album-results::-webkit-scrollbar-track {
141
141
background: var(--bg-primary);
142
-
border-radius: 4px;
142
+
border-radius: var(--radius-sm);
143
143
}
144
144
145
145
.album-results::-webkit-scrollbar-thumb {
146
146
background: var(--border-default);
147
-
border-radius: 4px;
147
+
border-radius: var(--radius-sm);
148
148
}
149
149
150
150
.album-results::-webkit-scrollbar-thumb:hover {
···
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;
+15
-15
frontend/src/lib/components/BrokenTracks.svelte
+15
-15
frontend/src/lib/components/BrokenTracks.svelte
···
194
194
margin-bottom: 3rem;
195
195
background: color-mix(in srgb, var(--warning) 5%, transparent);
196
196
border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent);
197
-
border-radius: 8px;
197
+
border-radius: var(--radius-md);
198
198
padding: 1.5rem;
199
199
}
200
200
···
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
}
···
222
222
padding: 0.5rem 1rem;
223
223
background: color-mix(in srgb, var(--warning) 20%, transparent);
224
224
border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent);
225
-
border-radius: 4px;
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;
···
248
248
background: color-mix(in srgb, var(--warning) 20%, transparent);
249
249
color: var(--warning);
250
250
padding: 0.25rem 0.6rem;
251
-
border-radius: 12px;
252
-
font-size: 0.85rem;
251
+
border-radius: var(--radius-lg);
252
+
font-size: var(--text-sm);
253
253
font-weight: 600;
254
254
}
255
255
···
263
263
.broken-track-item {
264
264
background: var(--bg-tertiary);
265
265
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
266
-
border-radius: 6px;
266
+
border-radius: var(--radius-base);
267
267
padding: 1rem;
268
268
display: flex;
269
269
align-items: center;
···
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
···
311
311
padding: 0.5rem 1rem;
312
312
background: color-mix(in srgb, var(--warning) 15%, transparent);
313
313
border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent);
314
-
border-radius: 4px;
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;
···
337
337
.info-box {
338
338
background: var(--bg-primary);
339
339
border: 1px solid var(--border-subtle);
340
-
border-radius: 6px;
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
+8
-8
frontend/src/lib/components/ColorSettings.svelte
+8
-8
frontend/src/lib/components/ColorSettings.svelte
···
115
115
border: 1px solid var(--border-default);
116
116
color: var(--text-secondary);
117
117
padding: 0.5rem;
118
-
border-radius: 4px;
118
+
border-radius: var(--radius-sm);
119
119
cursor: pointer;
120
120
transition: all 0.2s;
121
121
display: flex;
···
134
134
right: 0;
135
135
background: var(--bg-secondary);
136
136
border: 1px solid var(--border-default);
137
-
border-radius: 6px;
137
+
border-radius: var(--radius-base);
138
138
padding: 1rem;
139
139
min-width: 240px;
140
140
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
···
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;
···
182
182
width: 48px;
183
183
height: 32px;
184
184
border: 1px solid var(--border-default);
185
-
border-radius: 4px;
185
+
border-radius: var(--radius-sm);
186
186
cursor: pointer;
187
187
background: transparent;
188
188
}
···
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
···
222
222
.preset-btn {
223
223
width: 32px;
224
224
height: 32px;
225
-
border-radius: 4px;
225
+
border-radius: var(--radius-sm);
226
226
border: 2px solid transparent;
227
227
cursor: pointer;
228
228
transition: all 0.2s;
+9
-9
frontend/src/lib/components/HandleAutocomplete.svelte
+9
-9
frontend/src/lib/components/HandleAutocomplete.svelte
···
131
131
padding: 0.75rem;
132
132
background: var(--bg-primary);
133
133
border: 1px solid var(--border-default);
134
-
border-radius: 4px;
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 {
···
170
170
overflow-y: auto;
171
171
background: var(--bg-tertiary);
172
172
border: 1px solid var(--border-default);
173
-
border-radius: 4px;
173
+
border-radius: var(--radius-sm);
174
174
margin-top: 0.25rem;
175
175
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
176
176
scrollbar-width: thin;
···
183
183
184
184
.results::-webkit-scrollbar-track {
185
185
background: var(--bg-primary);
186
-
border-radius: 4px;
186
+
border-radius: var(--radius-sm);
187
187
}
188
188
189
189
.results::-webkit-scrollbar-thumb {
190
190
background: var(--border-default);
191
-
border-radius: 4px;
191
+
border-radius: var(--radius-sm);
192
192
}
193
193
194
194
.results::-webkit-scrollbar-thumb:hover {
···
222
222
.avatar {
223
223
width: 36px;
224
224
height: 36px;
225
-
border-radius: 50%;
225
+
border-radius: var(--radius-full);
226
226
object-fit: cover;
227
227
border: 2px solid var(--border-default);
228
228
flex-shrink: 0;
···
231
231
.avatar-placeholder {
232
232
width: 36px;
233
233
height: 36px;
234
-
border-radius: 50%;
234
+
border-radius: var(--radius-full);
235
235
background: var(--border-default);
236
236
flex-shrink: 0;
237
237
}
···
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;
+15
-15
frontend/src/lib/components/HandleSearch.svelte
+15
-15
frontend/src/lib/components/HandleSearch.svelte
···
179
179
padding: 0.75rem;
180
180
background: var(--bg-primary);
181
181
border: 1px solid var(--border-default);
182
-
border-radius: 4px;
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
···
213
213
overflow-y: auto;
214
214
background: var(--bg-tertiary);
215
215
border: 1px solid var(--border-default);
216
-
border-radius: 4px;
216
+
border-radius: var(--radius-sm);
217
217
margin-top: 0.25rem;
218
218
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
219
219
}
···
225
225
226
226
.search-results::-webkit-scrollbar-track {
227
227
background: var(--bg-primary);
228
-
border-radius: 4px;
228
+
border-radius: var(--radius-sm);
229
229
}
230
230
231
231
.search-results::-webkit-scrollbar-thumb {
232
232
background: var(--border-default);
233
-
border-radius: 4px;
233
+
border-radius: var(--radius-sm);
234
234
}
235
235
236
236
.search-results::-webkit-scrollbar-thumb:hover {
···
277
277
.result-avatar {
278
278
width: 36px;
279
279
height: 36px;
280
-
border-radius: 50%;
280
+
border-radius: var(--radius-full);
281
281
object-fit: cover;
282
282
border: 2px solid var(--border-default);
283
283
flex-shrink: 0;
···
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;
···
320
320
padding: 0.5rem 0.75rem;
321
321
background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary));
322
322
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle));
323
-
border-radius: 20px;
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 {
329
329
width: 24px;
330
330
height: 24px;
331
-
border-radius: 50%;
331
+
border-radius: var(--radius-full);
332
332
object-fit: cover;
333
333
border: 1px solid var(--border-default);
334
334
}
···
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
···
374
374
padding: 0.75rem;
375
375
background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary));
376
376
border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle));
377
-
border-radius: 4px;
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 {
+14
-14
frontend/src/lib/components/Header.svelte
+14
-14
frontend/src/lib/components/Header.svelte
···
228
228
justify-content: center;
229
229
width: 44px;
230
230
height: 44px;
231
-
border-radius: 10px;
231
+
border-radius: var(--radius-md);
232
232
background: transparent;
233
233
border: none;
234
234
color: var(--text-secondary);
···
275
275
border: 1px solid var(--border-emphasis);
276
276
color: var(--text-secondary);
277
277
padding: 0.5rem 1rem;
278
-
border-radius: 6px;
279
-
font-size: 0.9rem;
278
+
border-radius: var(--radius-base);
279
+
font-size: var(--text-base);
280
280
font-family: inherit;
281
281
cursor: pointer;
282
282
transition: all 0.2s;
···
309
309
}
310
310
311
311
.tangled-icon {
312
-
border-radius: 4px;
312
+
border-radius: var(--radius-sm);
313
313
opacity: 0.7;
314
314
transition: opacity 0.2s, box-shadow 0.2s;
315
315
}
···
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;
360
360
align-items: center;
361
361
gap: 0.4rem;
362
362
padding: 0.4rem 0.75rem;
363
-
border-radius: 6px;
363
+
border-radius: var(--radius-base);
364
364
border: 1px solid transparent;
365
365
}
366
366
···
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
-
border-radius: 6px;
394
+
border-radius: var(--radius-base);
395
395
border: 1px solid var(--border-default);
396
396
transition: all 0.2s;
397
397
white-space: nowrap;
···
408
408
border: 1px solid var(--accent);
409
409
color: var(--accent);
410
410
padding: 0.5rem 1rem;
411
-
border-radius: 6px;
412
-
font-size: 0.9rem;
411
+
border-radius: var(--radius-base);
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
}
+11
-11
frontend/src/lib/components/HiddenTagsFilter.svelte
+11
-11
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 {
···
139
139
color: var(--text-tertiary);
140
140
cursor: pointer;
141
141
transition: all 0.15s;
142
-
border-radius: 6px;
142
+
border-radius: var(--radius-base);
143
143
}
144
144
145
145
.filter-toggle:hover {
···
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
···
183
183
background: transparent;
184
184
border: 1px solid var(--border-default);
185
185
color: var(--text-secondary);
186
-
border-radius: 3px;
187
-
font-size: 0.75rem;
186
+
border-radius: var(--radius-sm);
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
}
···
219
219
padding: 0;
220
220
background: transparent;
221
221
border: 1px dashed var(--border-default);
222
-
border-radius: 3px;
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;
243
243
outline: none;
244
-
border-radius: 3px;
244
+
border-radius: var(--radius-sm);
245
245
}
246
246
247
247
.add-input:focus {
+1
-1
frontend/src/lib/components/LikeButton.svelte
+1
-1
frontend/src/lib/components/LikeButton.svelte
+9
-9
frontend/src/lib/components/LikersTooltip.svelte
+9
-9
frontend/src/lib/components/LikersTooltip.svelte
···
134
134
margin-bottom: 0.5rem;
135
135
background: var(--bg-secondary);
136
136
border: 1px solid var(--border-default);
137
-
border-radius: 8px;
137
+
border-radius: var(--radius-md);
138
138
padding: 0.75rem;
139
139
min-width: 240px;
140
140
max-width: 320px;
···
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
}
···
177
177
align-items: center;
178
178
gap: 0.75rem;
179
179
padding: 0.5rem;
180
-
border-radius: 6px;
180
+
border-radius: var(--radius-base);
181
181
text-decoration: none;
182
182
transition: background 0.2s;
183
183
}
···
190
190
.avatar-placeholder {
191
191
width: 32px;
192
192
height: 32px;
193
-
border-radius: 50%;
193
+
border-radius: var(--radius-full);
194
194
flex-shrink: 0;
195
195
}
196
196
···
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
···
248
248
249
249
.likers-list::-webkit-scrollbar-thumb {
250
250
background: var(--border-default);
251
-
border-radius: 3px;
251
+
border-radius: var(--radius-sm);
252
252
}
253
253
254
254
.likers-list::-webkit-scrollbar-thumb:hover {
+8
-8
frontend/src/lib/components/LinksMenu.svelte
+8
-8
frontend/src/lib/components/LinksMenu.svelte
···
140
140
height: 32px;
141
141
background: transparent;
142
142
border: 1px solid var(--border-default);
143
-
border-radius: 6px;
143
+
border-radius: var(--radius-base);
144
144
color: var(--text-secondary);
145
145
cursor: pointer;
146
146
transition: all 0.2s;
···
171
171
width: min(320px, calc(100vw - 2rem));
172
172
background: var(--bg-secondary);
173
173
border: 1px solid var(--border-default);
174
-
border-radius: 12px;
174
+
border-radius: var(--radius-lg);
175
175
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
176
176
z-index: 101;
177
177
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
···
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;
···
201
201
height: 28px;
202
202
background: transparent;
203
203
border: none;
204
-
border-radius: 4px;
204
+
border-radius: var(--radius-sm);
205
205
color: var(--text-secondary);
206
206
cursor: pointer;
207
207
transition: all 0.2s;
···
224
224
gap: 1rem;
225
225
padding: 1rem;
226
226
background: transparent;
227
-
border-radius: 8px;
227
+
border-radius: var(--radius-md);
228
228
text-decoration: none;
229
229
color: var(--text-primary);
230
230
transition: all 0.2s;
···
245
245
}
246
246
247
247
.tangled-menu-icon {
248
-
border-radius: 4px;
248
+
border-radius: var(--radius-sm);
249
249
opacity: 0.7;
250
250
transition: opacity 0.2s, box-shadow 0.2s;
251
251
}
···
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
+4
-4
frontend/src/lib/components/MigrationBanner.svelte
+4
-4
frontend/src/lib/components/MigrationBanner.svelte
···
152
152
.migration-banner {
153
153
background: var(--bg-tertiary);
154
154
border: 1px solid var(--border-default);
155
-
border-radius: 8px;
155
+
border-radius: var(--radius-md);
156
156
padding: 1rem;
157
157
margin-bottom: 1.5rem;
158
158
}
···
190
190
gap: 1rem;
191
191
background: color-mix(in srgb, var(--success) 10%, transparent);
192
192
border: 1px solid color-mix(in srgb, var(--success) 30%, transparent);
193
-
border-radius: 6px;
193
+
border-radius: var(--radius-base);
194
194
padding: 1rem;
195
195
animation: slideIn 0.3s ease-out;
196
196
}
···
239
239
.collection-name {
240
240
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
241
241
padding: 0.15em 0.4em;
242
-
border-radius: 3px;
242
+
border-radius: var(--radius-sm);
243
243
font-family: monospace;
244
244
font-size: 0.95em;
245
245
color: var(--text-primary);
···
265
265
.migrate-button,
266
266
.dismiss-button {
267
267
padding: 0.5rem 1rem;
268
-
border-radius: 4px;
268
+
border-radius: var(--radius-sm);
269
269
font-size: 0.9em;
270
270
font-family: inherit;
271
271
cursor: pointer;
+4
-4
frontend/src/lib/components/PlatformStats.svelte
+4
-4
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;
···
203
203
background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%);
204
204
background-size: 200% 100%;
205
205
animation: shimmer 1.5s ease-in-out infinite;
206
-
border-radius: 6px;
206
+
border-radius: var(--radius-base);
207
207
}
208
208
209
209
.stats-menu-grid {
···
219
219
gap: 0.15rem;
220
220
padding: 0.6rem 0.4rem;
221
221
background: var(--bg-tertiary, #1a1a1a);
222
-
border-radius: 6px;
222
+
border-radius: var(--radius-base);
223
223
}
224
224
225
225
.menu-stat-icon {
···
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;
+19
-19
frontend/src/lib/components/ProfileMenu.svelte
+19
-19
frontend/src/lib/components/ProfileMenu.svelte
···
276
276
height: 44px;
277
277
background: transparent;
278
278
border: 1px solid var(--border-default);
279
-
border-radius: 8px;
279
+
border-radius: var(--radius-md);
280
280
color: var(--text-secondary);
281
281
cursor: pointer;
282
282
transition: all 0.15s;
···
311
311
overflow-y: auto;
312
312
background: var(--bg-secondary);
313
313
border: 1px solid var(--border-default);
314
-
border-radius: 16px;
314
+
border-radius: var(--radius-xl);
315
315
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
316
316
z-index: 101;
317
317
animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
···
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;
···
341
341
height: 36px;
342
342
background: transparent;
343
343
border: none;
344
-
border-radius: 8px;
344
+
border-radius: var(--radius-md);
345
345
color: var(--text-secondary);
346
346
cursor: pointer;
347
347
transition: all 0.15s;
···
371
371
min-height: 56px;
372
372
background: transparent;
373
373
border: none;
374
-
border-radius: 12px;
374
+
border-radius: var(--radius-lg);
375
375
text-decoration: none;
376
376
color: var(--text-primary);
377
377
font-family: inherit;
···
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;
···
440
440
padding: 0.5rem 0.75rem;
441
441
background: transparent;
442
442
border: none;
443
-
border-radius: 6px;
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);
···
490
490
min-height: 54px;
491
491
background: var(--bg-tertiary);
492
492
border: 1px solid var(--border-default);
493
-
border-radius: 8px;
493
+
border-radius: var(--radius-md);
494
494
color: var(--text-secondary);
495
495
cursor: pointer;
496
496
transition: all 0.15s;
···
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
}
···
533
533
width: 44px;
534
534
height: 44px;
535
535
border: 1px solid var(--border-default);
536
-
border-radius: 8px;
536
+
border-radius: var(--radius-md);
537
537
cursor: pointer;
538
538
background: transparent;
539
539
flex-shrink: 0;
···
544
544
}
545
545
546
546
.color-input::-webkit-color-swatch {
547
-
border-radius: 4px;
547
+
border-radius: var(--radius-sm);
548
548
border: none;
549
549
}
550
550
···
557
557
.preset-btn {
558
558
width: 36px;
559
559
height: 36px;
560
-
border-radius: 6px;
560
+
border-radius: var(--radius-base);
561
561
border: 2px solid transparent;
562
562
cursor: pointer;
563
563
transition: all 0.15s;
···
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
}
···
592
592
appearance: none;
593
593
width: 48px;
594
594
height: 28px;
595
-
border-radius: 999px;
595
+
border-radius: var(--radius-full);
596
596
background: var(--border-default);
597
597
position: relative;
598
598
cursor: pointer;
···
608
608
left: 3px;
609
609
width: 20px;
610
610
height: 20px;
611
-
border-radius: 50%;
611
+
border-radius: var(--radius-full);
612
612
background: var(--text-secondary);
613
613
transition: transform 0.15s, background 0.15s;
614
614
}
···
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
+16
-16
frontend/src/lib/components/Queue.svelte
+16
-16
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;
270
270
background: transparent;
271
271
border: 1px solid var(--border-subtle);
272
272
color: var(--text-tertiary);
273
-
border-radius: 4px;
273
+
border-radius: var(--radius-sm);
274
274
cursor: pointer;
275
275
transition: all 0.15s ease;
276
276
}
···
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);
···
302
302
align-items: center;
303
303
justify-content: space-between;
304
304
padding: 1rem 1.1rem;
305
-
border-radius: 10px;
305
+
border-radius: var(--radius-md);
306
306
background: var(--bg-secondary);
307
307
border: 1px solid var(--border-default);
308
308
gap: 1rem;
···
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;
···
372
372
align-items: center;
373
373
gap: 0.5rem;
374
374
padding: 0.85rem 0.9rem;
375
-
border-radius: 8px;
375
+
border-radius: var(--radius-md);
376
376
cursor: pointer;
377
377
transition: all 0.2s;
378
378
border: 1px solid var(--border-subtle);
···
412
412
color: var(--text-muted);
413
413
cursor: grab;
414
414
touch-action: none;
415
-
border-radius: 4px;
415
+
border-radius: var(--radius-sm);
416
416
transition: all 0.2s;
417
417
flex-shrink: 0;
418
418
}
···
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;
···
476
476
align-items: center;
477
477
justify-content: center;
478
478
transition: all 0.2s;
479
-
border-radius: 4px;
479
+
border-radius: var(--radius-sm);
480
480
opacity: 0;
481
481
flex-shrink: 0;
482
482
}
···
499
499
500
500
.empty-up-next {
501
501
border: 1px dashed var(--border-subtle);
502
-
border-radius: 6px;
502
+
border-radius: var(--radius-base);
503
503
padding: 1.25rem;
504
504
text-align: center;
505
505
color: var(--text-tertiary);
···
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 {
···
542
542
543
543
.queue-tracks::-webkit-scrollbar-thumb {
544
544
background: var(--border-default);
545
-
border-radius: 4px;
545
+
border-radius: var(--radius-sm);
546
546
}
547
547
548
548
.queue-tracks::-webkit-scrollbar-thumb:hover {
+20
-20
frontend/src/lib/components/SearchModal.svelte
+20
-20
frontend/src/lib/components/SearchModal.svelte
···
276
276
backdrop-filter: blur(20px) saturate(180%);
277
277
-webkit-backdrop-filter: blur(20px) saturate(180%);
278
278
border: 1px solid var(--border-subtle);
279
-
border-radius: 16px;
279
+
border-radius: var(--radius-xl);
280
280
box-shadow:
281
281
0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent),
282
282
0 0 1px var(--border-subtle) inset;
···
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);
320
-
border-radius: 5px;
320
+
border-radius: var(--radius-sm);
321
321
color: var(--text-muted);
322
322
font-family: inherit;
323
323
}
···
327
327
height: 16px;
328
328
border: 2px solid var(--border-default);
329
329
border-top-color: var(--accent);
330
-
border-radius: 50%;
330
+
border-radius: var(--radius-full);
331
331
animation: spin 0.6s linear infinite;
332
332
}
333
333
···
351
351
352
352
.search-results::-webkit-scrollbar-track {
353
353
background: transparent;
354
-
border-radius: 4px;
354
+
border-radius: var(--radius-sm);
355
355
}
356
356
357
357
.search-results::-webkit-scrollbar-thumb {
358
358
background: var(--border-default);
359
-
border-radius: 4px;
359
+
border-radius: var(--radius-sm);
360
360
}
361
361
362
362
.search-results::-webkit-scrollbar-thumb:hover {
···
371
371
padding: 0.75rem;
372
372
background: transparent;
373
373
border: none;
374
-
border-radius: 8px;
374
+
border-radius: var(--radius-md);
375
375
cursor: pointer;
376
376
text-align: left;
377
377
font-family: inherit;
···
396
396
align-items: center;
397
397
justify-content: center;
398
398
background: var(--bg-tertiary);
399
-
border-radius: 8px;
400
-
font-size: 0.9rem;
399
+
border-radius: var(--radius-md);
400
+
font-size: var(--text-base);
401
401
flex-shrink: 0;
402
402
position: relative;
403
403
overflow: hidden;
···
409
409
width: 100%;
410
410
height: 100%;
411
411
object-fit: cover;
412
-
border-radius: 8px;
412
+
border-radius: var(--radius-md);
413
413
}
414
414
415
415
.result-icon[data-type='track'] {
···
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;
···
463
463
color: var(--text-muted);
464
464
padding: 0.2rem 0.45rem;
465
465
background: var(--bg-tertiary);
466
-
border-radius: 4px;
466
+
border-radius: var(--radius-sm);
467
467
flex-shrink: 0;
468
468
}
469
469
···
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 {
···
504
504
padding: 0.15rem 0.35rem;
505
505
background: var(--bg-tertiary);
506
506
border: 1px solid var(--border-default);
507
-
border-radius: 4px;
507
+
border-radius: var(--radius-sm);
508
508
font-family: inherit;
509
509
}
510
510
···
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 {
+1
-1
frontend/src/lib/components/SearchTrigger.svelte
+1
-1
frontend/src/lib/components/SearchTrigger.svelte
+3
-3
frontend/src/lib/components/SensitiveImage.svelte
+3
-3
frontend/src/lib/components/SensitiveImage.svelte
···
70
70
margin-bottom: 4px;
71
71
background: var(--bg-primary);
72
72
border: 1px solid var(--border-default);
73
-
border-radius: 4px;
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 {
+14
-14
frontend/src/lib/components/SettingsMenu.svelte
+14
-14
frontend/src/lib/components/SettingsMenu.svelte
···
181
181
border: 1px solid var(--border-default);
182
182
color: var(--text-secondary);
183
183
padding: 0.5rem;
184
-
border-radius: 4px;
184
+
border-radius: var(--radius-sm);
185
185
cursor: pointer;
186
186
transition: all 0.2s;
187
187
display: flex;
···
200
200
right: 0;
201
201
background: var(--bg-secondary);
202
202
border: 1px solid var(--border-default);
203
-
border-radius: 8px;
203
+
border-radius: var(--radius-md);
204
204
padding: 1.25rem;
205
205
min-width: 280px;
206
206
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
···
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);
···
265
265
padding: 0.6rem 0.5rem;
266
266
background: var(--bg-tertiary);
267
267
border: 1px solid var(--border-default);
268
-
border-radius: 6px;
268
+
border-radius: var(--radius-base);
269
269
color: var(--text-secondary);
270
270
cursor: pointer;
271
271
transition: all 0.2s;
···
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
}
···
303
303
width: 48px;
304
304
height: 32px;
305
305
border: 1px solid var(--border-default);
306
-
border-radius: 4px;
306
+
border-radius: var(--radius-sm);
307
307
cursor: pointer;
308
308
background: transparent;
309
309
}
···
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
···
332
332
.preset-btn {
333
333
width: 32px;
334
334
height: 32px;
335
-
border-radius: 4px;
335
+
border-radius: var(--radius-sm);
336
336
border: 2px solid transparent;
337
337
cursor: pointer;
338
338
transition: all 0.2s;
···
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 {
361
361
appearance: none;
362
362
width: 42px;
363
363
height: 22px;
364
-
border-radius: 999px;
364
+
border-radius: var(--radius-full);
365
365
background: var(--border-default);
366
366
position: relative;
367
367
cursor: pointer;
···
377
377
left: 2px;
378
378
width: 16px;
379
379
height: 16px;
380
-
border-radius: 50%;
380
+
border-radius: var(--radius-full);
381
381
background: var(--text-secondary);
382
382
transition: transform 0.2s, background 0.2s;
383
383
}
···
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
+2
-2
frontend/src/lib/components/SupporterBadge.svelte
+2
-2
frontend/src/lib/components/SupporterBadge.svelte
···
22
22
padding: 0.2rem 0.5rem;
23
23
background: color-mix(in srgb, var(--accent) 15%, transparent);
24
24
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
25
-
border-radius: 4px;
25
+
border-radius: var(--radius-sm);
26
26
color: var(--accent);
27
-
font-size: 0.75rem;
27
+
font-size: var(--text-xs);
28
28
font-weight: 500;
29
29
text-transform: lowercase;
30
30
white-space: nowrap;
+10
-10
frontend/src/lib/components/TagInput.svelte
+10
-10
frontend/src/lib/components/TagInput.svelte
···
178
178
padding: 0.75rem;
179
179
background: var(--bg-primary);
180
180
border: 1px solid var(--border-default);
181
-
border-radius: 4px;
181
+
border-radius: var(--radius-sm);
182
182
min-height: 48px;
183
183
transition: all 0.2s;
184
184
}
···
195
195
background: color-mix(in srgb, var(--accent) 10%, transparent);
196
196
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
197
197
color: var(--accent-hover);
198
-
border-radius: 20px;
199
-
font-size: 0.9rem;
198
+
border-radius: var(--radius-xl);
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
···
261
261
overflow-y: auto;
262
262
background: var(--bg-secondary);
263
263
border: 1px solid var(--border-default);
264
-
border-radius: 4px;
264
+
border-radius: var(--radius-sm);
265
265
margin-top: 0.25rem;
266
266
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
267
267
scrollbar-width: thin;
···
274
274
275
275
.suggestions::-webkit-scrollbar-track {
276
276
background: var(--bg-primary);
277
-
border-radius: 4px;
277
+
border-radius: var(--radius-sm);
278
278
}
279
279
280
280
.suggestions::-webkit-scrollbar-thumb {
281
281
background: var(--border-default);
282
-
border-radius: 4px;
282
+
border-radius: var(--radius-sm);
283
283
}
284
284
285
285
.suggestions::-webkit-scrollbar-thumb:hover {
···
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>
+5
-5
frontend/src/lib/components/Toast.svelte
+5
-5
frontend/src/lib/components/Toast.svelte
···
61
61
backdrop-filter: blur(12px);
62
62
-webkit-backdrop-filter: blur(12px);
63
63
border: 1px solid rgba(255, 255, 255, 0.06);
64
-
border-radius: 8px;
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 {
+15
-15
frontend/src/lib/components/TrackActionsMenu.svelte
+15
-15
frontend/src/lib/components/TrackActionsMenu.svelte
···
402
402
justify-content: center;
403
403
background: transparent;
404
404
border: 1px solid var(--border-default);
405
-
border-radius: 4px;
405
+
border-radius: var(--radius-sm);
406
406
color: var(--text-tertiary);
407
407
cursor: pointer;
408
408
transition: all 0.2s;
···
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;
···
558
558
.playlist-thumb-placeholder {
559
559
width: 36px;
560
560
height: 36px;
561
-
border-radius: 4px;
561
+
border-radius: var(--radius-sm);
562
562
flex-shrink: 0;
563
563
}
564
564
···
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;
···
627
627
padding: 0.75rem 1rem;
628
628
background: var(--bg-tertiary);
629
629
border: 1px solid var(--border-default);
630
-
border-radius: 8px;
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 {
···
650
650
padding: 0.75rem 1rem;
651
651
background: var(--accent);
652
652
border: none;
653
-
border-radius: 8px;
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;
···
673
673
height: 18px;
674
674
border: 2px solid var(--border-default);
675
675
border-top-color: var(--accent);
676
-
border-radius: 50%;
676
+
border-radius: var(--radius-full);
677
677
animation: spin 0.8s linear infinite;
678
678
}
679
679
···
700
700
top: 50%;
701
701
transform: translateY(-50%);
702
702
margin-right: 0.5rem;
703
-
border-radius: 8px;
703
+
border-radius: var(--radius-md);
704
704
min-width: 180px;
705
705
max-height: none;
706
706
animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
···
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,
+20
-20
frontend/src/lib/components/TrackItem.svelte
+20
-20
frontend/src/lib/components/TrackItem.svelte
···
359
359
gap: 0.75rem;
360
360
background: var(--track-bg, var(--bg-secondary));
361
361
border: 1px solid var(--track-border, var(--border-subtle));
362
-
border-radius: 8px;
362
+
border-radius: var(--radius-md);
363
363
padding: 1rem;
364
364
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
365
365
transition:
···
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;
···
428
428
position: absolute;
429
429
inset: 0;
430
430
background: rgba(0, 0, 0, 0.3);
431
-
border-radius: 4px;
431
+
border-radius: var(--radius-sm);
432
432
pointer-events: none;
433
433
}
434
434
···
443
443
justify-content: center;
444
444
background: var(--accent);
445
445
border: 2px solid var(--bg-secondary);
446
-
border-radius: 50%;
446
+
border-radius: var(--radius-full);
447
447
color: white;
448
448
z-index: 1;
449
449
}
···
456
456
display: flex;
457
457
align-items: center;
458
458
justify-content: center;
459
-
border-radius: 4px;
459
+
border-radius: var(--radius-sm);
460
460
overflow: hidden;
461
461
background: var(--bg-tertiary);
462
462
border: 1px solid var(--border-subtle);
···
490
490
}
491
491
492
492
.track-avatar img {
493
-
border-radius: 50%;
493
+
border-radius: var(--radius-full);
494
494
border: 2px solid var(--border-default);
495
495
transition: border-color 0.2s;
496
496
}
···
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 {
···
659
659
padding: 0.1rem 0.4rem;
660
660
background: color-mix(in srgb, var(--accent) 15%, transparent);
661
661
color: var(--accent-hover);
662
-
border-radius: 3px;
663
-
font-size: 0.75rem;
662
+
border-radius: var(--radius-sm);
663
+
font-size: var(--text-xs);
664
664
font-weight: 500;
665
665
text-decoration: none;
666
666
transition: all 0.15s;
···
679
679
background: var(--bg-tertiary);
680
680
color: var(--text-muted);
681
681
border: none;
682
-
border-radius: 3px;
683
-
font-size: 0.75rem;
682
+
border-radius: var(--radius-sm);
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 {
···
756
756
justify-content: center;
757
757
background: transparent;
758
758
border: 1px solid var(--border-default);
759
-
border-radius: 4px;
759
+
border-radius: var(--radius-sm);
760
760
color: var(--text-tertiary);
761
761
cursor: pointer;
762
762
transition: all 0.2s;
···
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
+6
-6
frontend/src/lib/components/player/PlaybackControls.svelte
+6
-6
frontend/src/lib/components/player/PlaybackControls.svelte
···
244
244
align-items: center;
245
245
justify-content: center;
246
246
transition: all 0.2s;
247
-
border-radius: 50%;
247
+
border-radius: var(--radius-full);
248
248
}
249
249
250
250
.control-btn svg {
···
282
282
display: flex;
283
283
align-items: center;
284
284
justify-content: center;
285
-
border-radius: 6px;
285
+
border-radius: var(--radius-base);
286
286
transition: all 0.2s;
287
287
position: relative;
288
288
}
···
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;
···
382
382
background: var(--accent);
383
383
height: 14px;
384
384
width: 14px;
385
-
border-radius: 50%;
385
+
border-radius: var(--radius-full);
386
386
margin-top: -5px;
387
387
transition: all 0.2s;
388
388
box-shadow: 0 0 0 8px transparent;
···
419
419
background: var(--accent);
420
420
height: 14px;
421
421
width: 14px;
422
-
border-radius: 50%;
422
+
border-radius: var(--radius-full);
423
423
border: none;
424
424
transition: all 0.2s;
425
425
box-shadow: 0 0 0 8px transparent;
···
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
+4
-4
frontend/src/lib/components/player/TrackInfo.svelte
+4
-4
frontend/src/lib/components/player/TrackInfo.svelte
···
156
156
flex-shrink: 0;
157
157
width: 56px;
158
158
height: 56px;
159
-
border-radius: 4px;
159
+
border-radius: var(--radius-sm);
160
160
overflow: hidden;
161
161
background: var(--bg-tertiary);
162
162
border: 1px solid var(--border-default);
···
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,
+50
-4
frontend/src/lib/uploader.svelte.ts
+50
-4
frontend/src/lib/uploader.svelte.ts
···
23
23
onError?: (_error: string) => void;
24
24
}
25
25
26
+
function isMobileDevice(): boolean {
27
+
if (!browser) return false;
28
+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
29
+
}
30
+
31
+
const MOBILE_LARGE_FILE_THRESHOLD_MB = 50;
32
+
33
+
function buildNetworkErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string {
34
+
const progressInfo = progressPercent > 0 ? ` (failed at ${progressPercent}%)` : '';
35
+
36
+
if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) {
37
+
return `upload failed${progressInfo}: large files often fail on mobile networks. try uploading from a desktop or use WiFi`;
38
+
}
39
+
40
+
if (progressPercent > 0 && progressPercent < 100) {
41
+
return `upload failed${progressInfo}: connection was interrupted. check your network and try again`;
42
+
}
43
+
44
+
return `upload failed${progressInfo}: connection failed. check your internet connection and try again`;
45
+
}
46
+
47
+
function buildTimeoutErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string {
48
+
const progressInfo = progressPercent > 0 ? ` (stopped at ${progressPercent}%)` : '';
49
+
50
+
if (isMobile) {
51
+
return `upload timed out${progressInfo}: mobile uploads can be slow. try WiFi or a desktop browser`;
52
+
}
53
+
54
+
if (fileSizeMB > 100) {
55
+
return `upload timed out${progressInfo}: large file (${Math.round(fileSizeMB)}MB) - try a faster connection`;
56
+
}
57
+
58
+
return `upload timed out${progressInfo}: try again with a better connection`;
59
+
}
60
+
26
61
// global upload manager using Svelte 5 runes
27
62
class UploaderState {
28
63
activeUploads = $state<Map<string, UploadTask>>(new Map());
···
40
75
): void {
41
76
const taskId = crypto.randomUUID();
42
77
const fileSizeMB = file.size / 1024 / 1024;
78
+
const isMobile = isMobileDevice();
79
+
80
+
// warn about large files on mobile
81
+
if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) {
82
+
toast.info(`uploading ${Math.round(fileSizeMB)}MB file on mobile - ensure stable connection`, 5000);
83
+
}
84
+
43
85
const uploadMessage = fileSizeMB > 10
44
86
? 'uploading track... (large file, this may take a moment)'
45
87
: 'uploading track...';
46
88
// 0 means infinite/persist until dismissed
47
89
const toastId = toast.info(uploadMessage, 0);
90
+
91
+
// track upload progress for error messages
92
+
let lastProgressPercent = 0;
48
93
49
94
if (!browser) return;
50
95
const formData = new FormData();
···
74
119
xhr.upload.addEventListener('progress', (e) => {
75
120
if (e.lengthComputable && !uploadComplete) {
76
121
const percent = Math.round((e.loaded / e.total) * 100);
122
+
lastProgressPercent = percent;
77
123
const progressMsg = `retrieving your file... ${percent}%`;
78
124
toast.update(toastId, progressMsg);
79
125
if (callbacks?.onProgress) {
···
172
218
errorMsg = error.detail || errorMsg;
173
219
} catch {
174
220
if (xhr.status === 0) {
175
-
errorMsg = 'network error: connection failed. check your internet connection and try again';
221
+
errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
176
222
} else if (xhr.status >= 500) {
177
223
errorMsg = 'server error: please try again in a moment';
178
224
} else if (xhr.status === 413) {
179
225
errorMsg = 'file too large: please use a smaller file';
180
226
} else if (xhr.status === 408 || xhr.status === 504) {
181
-
errorMsg = 'upload timed out: please try again with a better connection';
227
+
errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
182
228
}
183
229
}
184
230
toast.error(errorMsg);
···
190
236
191
237
xhr.addEventListener('error', () => {
192
238
toast.dismiss(toastId);
193
-
const errorMsg = 'network error: connection failed. check your internet connection and try again';
239
+
const errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
194
240
toast.error(errorMsg);
195
241
if (callbacks?.onError) {
196
242
callbacks.onError(errorMsg);
···
199
245
200
246
xhr.addEventListener('timeout', () => {
201
247
toast.dismiss(toastId);
202
-
const errorMsg = 'upload timed out: please try again with a better connection';
248
+
const errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
203
249
toast.error(errorMsg);
204
250
if (callbacks?.onError) {
205
251
callbacks.onError(errorMsg);
+5
-5
frontend/src/routes/+error.svelte
+5
-5
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
-
border-radius: 6px;
82
+
border-radius: var(--radius-base);
83
83
transition: all 0.2s;
84
84
}
85
85
···
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>
+32
-4
frontend/src/routes/+layout.svelte
+32
-4
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);
466
+
467
+
/* border radius scale */
468
+
--radius-sm: 4px;
469
+
--radius-base: 6px;
470
+
--radius-md: 8px;
471
+
--radius-lg: 12px;
472
+
--radius-xl: 16px;
473
+
--radius-2xl: 24px;
474
+
--radius-full: 9999px;
457
475
458
476
/* semantic */
459
477
--success: #4ade80;
···
516
534
color: var(--accent-muted);
517
535
}
518
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
+
}
545
+
}
546
+
519
547
:global(body) {
520
548
margin: 0;
521
549
padding: 0;
···
589
617
right: 20px;
590
618
width: 48px;
591
619
height: 48px;
592
-
border-radius: 50%;
620
+
border-radius: var(--radius-full);
593
621
background: var(--bg-secondary);
594
622
border: 1px solid var(--border-default);
595
623
color: var(--text-secondary);
+1
-1
frontend/src/routes/+page.svelte
+1
-1
frontend/src/routes/+page.svelte
+27
-27
frontend/src/routes/costs/+page.svelte
+27
-27
frontend/src/routes/costs/+page.svelte
···
352
352
353
353
.subtitle {
354
354
color: var(--text-tertiary);
355
-
font-size: 0.9rem;
355
+
font-size: var(--text-base);
356
356
margin: 0;
357
357
}
358
358
···
370
370
371
371
.error-state .hint {
372
372
color: var(--text-tertiary);
373
-
font-size: 0.85rem;
373
+
font-size: var(--text-sm);
374
374
margin-top: 0.5rem;
375
375
}
376
376
···
386
386
padding: 2rem;
387
387
background: var(--bg-tertiary);
388
388
border: 1px solid var(--border-subtle);
389
-
border-radius: 12px;
389
+
border-radius: var(--radius-lg);
390
390
}
391
391
392
392
.total-label {
393
-
font-size: 0.8rem;
393
+
font-size: var(--text-sm);
394
394
text-transform: uppercase;
395
395
letter-spacing: 0.08em;
396
396
color: var(--text-tertiary);
···
405
405
406
406
.updated {
407
407
text-align: center;
408
-
font-size: 0.75rem;
408
+
font-size: var(--text-xs);
409
409
color: var(--text-tertiary);
410
410
margin-top: 0.75rem;
411
411
}
···
417
417
418
418
.breakdown-section h2,
419
419
.audd-section h2 {
420
-
font-size: 0.8rem;
420
+
font-size: var(--text-sm);
421
421
text-transform: uppercase;
422
422
letter-spacing: 0.08em;
423
423
color: var(--text-tertiary);
···
433
433
.cost-item {
434
434
background: var(--bg-tertiary);
435
435
border: 1px solid var(--border-subtle);
436
-
border-radius: 8px;
436
+
border-radius: var(--radius-md);
437
437
padding: 1rem;
438
438
}
439
439
···
458
458
.cost-bar-bg {
459
459
height: 8px;
460
460
background: var(--bg-primary);
461
-
border-radius: 4px;
461
+
border-radius: var(--radius-sm);
462
462
overflow: hidden;
463
463
margin-bottom: 0.5rem;
464
464
}
···
466
466
.cost-bar {
467
467
height: 100%;
468
468
background: var(--accent);
469
-
border-radius: 4px;
469
+
border-radius: var(--radius-sm);
470
470
transition: width 0.3s ease;
471
471
}
472
472
···
475
475
}
476
476
477
477
.cost-note {
478
-
font-size: 0.75rem;
478
+
font-size: var(--text-xs);
479
479
color: var(--text-tertiary);
480
480
}
481
481
···
501
501
gap: 0.25rem;
502
502
background: var(--bg-tertiary);
503
503
border: 1px solid var(--border-subtle);
504
-
border-radius: 6px;
504
+
border-radius: var(--radius-base);
505
505
padding: 0.25rem;
506
506
}
507
507
508
508
.time-range-toggle button {
509
509
padding: 0.35rem 0.75rem;
510
510
font-family: inherit;
511
-
font-size: 0.75rem;
511
+
font-size: var(--text-xs);
512
512
font-weight: 500;
513
513
background: transparent;
514
514
border: none;
515
-
border-radius: 4px;
515
+
border-radius: var(--radius-sm);
516
516
color: var(--text-secondary);
517
517
cursor: pointer;
518
518
transition: all 0.15s;
···
530
530
.no-data {
531
531
text-align: center;
532
532
color: var(--text-tertiary);
533
-
font-size: 0.85rem;
533
+
font-size: var(--text-sm);
534
534
padding: 2rem;
535
535
background: var(--bg-tertiary);
536
536
border: 1px solid var(--border-subtle);
537
-
border-radius: 8px;
537
+
border-radius: var(--radius-md);
538
538
}
539
539
540
540
.audd-stats {
···
545
545
}
546
546
547
547
.audd-explainer {
548
-
font-size: 0.8rem;
548
+
font-size: var(--text-sm);
549
549
color: var(--text-secondary);
550
550
margin-bottom: 1.5rem;
551
551
line-height: 1.5;
···
562
562
padding: 1rem;
563
563
background: var(--bg-tertiary);
564
564
border: 1px solid var(--border-subtle);
565
-
border-radius: 8px;
565
+
border-radius: var(--radius-md);
566
566
}
567
567
568
568
.stat-value {
569
-
font-size: 1.25rem;
569
+
font-size: var(--text-2xl);
570
570
font-weight: 700;
571
571
color: var(--text-primary);
572
572
font-variant-numeric: tabular-nums;
573
573
}
574
574
575
575
.stat-label {
576
-
font-size: 0.7rem;
576
+
font-size: var(--text-xs);
577
577
color: var(--text-tertiary);
578
578
text-align: center;
579
579
margin-top: 0.25rem;
···
583
583
.daily-chart {
584
584
background: var(--bg-tertiary);
585
585
border: 1px solid var(--border-subtle);
586
-
border-radius: 8px;
586
+
border-radius: var(--radius-md);
587
587
padding: 1rem;
588
588
overflow: hidden;
589
589
}
590
590
591
591
.daily-chart h3 {
592
-
font-size: 0.75rem;
592
+
font-size: var(--text-xs);
593
593
text-transform: uppercase;
594
594
letter-spacing: 0.05em;
595
595
color: var(--text-tertiary);
···
652
652
var(--bg-tertiary)
653
653
);
654
654
border: 1px solid var(--border-subtle);
655
-
border-radius: 12px;
655
+
border-radius: var(--radius-lg);
656
656
}
657
657
658
658
.support-icon {
···
662
662
663
663
.support-text h3 {
664
664
margin: 0 0 0.5rem;
665
-
font-size: 1.1rem;
665
+
font-size: var(--text-xl);
666
666
color: var(--text-primary);
667
667
}
668
668
669
669
.support-text p {
670
670
margin: 0 0 1.5rem;
671
671
color: var(--text-secondary);
672
-
font-size: 0.9rem;
672
+
font-size: var(--text-base);
673
673
}
674
674
675
675
.support-button {
···
679
679
padding: 0.75rem 1.5rem;
680
680
background: var(--accent);
681
681
color: white;
682
-
border-radius: 8px;
682
+
border-radius: var(--radius-md);
683
683
text-decoration: none;
684
684
font-weight: 600;
685
-
font-size: 0.9rem;
685
+
font-size: var(--text-base);
686
686
transition: transform 0.15s, box-shadow 0.15s;
687
687
}
688
688
···
694
694
/* footer */
695
695
.footer-note {
696
696
text-align: center;
697
-
font-size: 0.8rem;
697
+
font-size: var(--text-sm);
698
698
color: var(--text-tertiary);
699
699
padding-bottom: 1rem;
700
700
}
+1
-1
frontend/src/routes/embed/track/[id]/+page.svelte
+1
-1
frontend/src/routes/embed/track/[id]/+page.svelte
+25
-25
frontend/src/routes/library/+page.svelte
+25
-25
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
}
···
282
282
padding: 1rem 1.25rem;
283
283
background: var(--bg-secondary);
284
284
border: 1px solid var(--border-default);
285
-
border-radius: 12px;
285
+
border-radius: var(--radius-lg);
286
286
text-decoration: none;
287
287
color: inherit;
288
288
transition: all 0.15s;
···
300
300
.collection-icon {
301
301
width: 48px;
302
302
height: 48px;
303
-
border-radius: 10px;
303
+
border-radius: var(--radius-md);
304
304
display: flex;
305
305
align-items: center;
306
306
justify-content: center;
···
320
320
.playlist-artwork {
321
321
width: 48px;
322
322
height: 48px;
323
-
border-radius: 10px;
323
+
border-radius: var(--radius-md);
324
324
object-fit: cover;
325
325
flex-shrink: 0;
326
326
}
···
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;
···
384
384
background: var(--accent);
385
385
color: white;
386
386
border: none;
387
-
border-radius: 8px;
387
+
border-radius: var(--radius-md);
388
388
font-family: inherit;
389
389
font-size: 0.875rem;
390
390
font-weight: 500;
···
415
415
padding: 3rem 2rem;
416
416
background: var(--bg-secondary);
417
417
border: 1px dashed var(--border-default);
418
-
border-radius: 12px;
418
+
border-radius: var(--radius-lg);
419
419
text-align: center;
420
420
}
421
421
422
422
.empty-icon {
423
423
width: 64px;
424
424
height: 64px;
425
-
border-radius: 16px;
425
+
border-radius: var(--radius-xl);
426
426
display: flex;
427
427
align-items: center;
428
428
justify-content: center;
···
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
···
461
461
.modal {
462
462
background: var(--bg-primary);
463
463
border: 1px solid var(--border-default);
464
-
border-radius: 16px;
464
+
border-radius: var(--radius-xl);
465
465
width: 100%;
466
466
max-width: 400px;
467
467
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
···
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;
···
490
490
height: 32px;
491
491
background: transparent;
492
492
border: none;
493
-
border-radius: 8px;
493
+
border-radius: var(--radius-md);
494
494
color: var(--text-secondary);
495
495
cursor: pointer;
496
496
transition: all 0.15s;
···
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;
···
518
518
padding: 0.75rem 1rem;
519
519
background: var(--bg-secondary);
520
520
border: 1px solid var(--border-default);
521
-
border-radius: 8px;
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
···
550
550
.cancel-btn,
551
551
.confirm-btn {
552
552
padding: 0.625rem 1.25rem;
553
-
border-radius: 8px;
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 {
+12
-12
frontend/src/routes/liked/+page.svelte
+12
-12
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);
352
352
padding: 0.2rem 0.55rem;
353
-
border-radius: 4px;
353
+
border-radius: var(--radius-sm);
354
354
}
355
355
356
356
.header-actions {
···
362
362
.queue-button,
363
363
.reorder-button {
364
364
padding: 0.75rem 1.5rem;
365
-
border-radius: 24px;
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
···
441
441
display: flex;
442
442
align-items: center;
443
443
gap: 0.5rem;
444
-
border-radius: 8px;
444
+
border-radius: var(--radius-md);
445
445
transition: all 0.2s;
446
446
position: relative;
447
447
}
···
473
473
color: var(--text-muted);
474
474
cursor: grab;
475
475
touch-action: none;
476
-
border-radius: 4px;
476
+
border-radius: var(--radius-sm);
477
477
transition: all 0.2s;
478
478
flex-shrink: 0;
479
479
}
···
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,
+16
-16
frontend/src/routes/liked/[handle]/+page.svelte
+16
-16
frontend/src/routes/liked/[handle]/+page.svelte
···
126
126
.avatar {
127
127
width: 64px;
128
128
height: 64px;
129
-
border-radius: 50%;
129
+
border-radius: var(--radius-full);
130
130
object-fit: cover;
131
131
flex-shrink: 0;
132
132
}
···
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
}
···
208
208
background: transparent;
209
209
border: 1px solid var(--border-default);
210
210
color: var(--text-secondary);
211
-
border-radius: 6px;
212
-
font-size: 0.85rem;
211
+
border-radius: var(--radius-base);
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 {
+8
-8
frontend/src/routes/login/+page.svelte
+8
-8
frontend/src/routes/login/+page.svelte
···
142
142
.login-card {
143
143
background: var(--bg-tertiary);
144
144
border: 1px solid var(--border-subtle);
145
-
border-radius: 12px;
145
+
border-radius: var(--radius-lg);
146
146
padding: 2.5rem;
147
147
max-width: 420px;
148
148
width: 100%;
···
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 {
···
180
180
background: var(--accent);
181
181
color: white;
182
182
border: none;
183
-
border-radius: 8px;
184
-
font-size: 0.95rem;
183
+
border-radius: var(--radius-md);
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
···
259
259
.faq-content code {
260
260
background: var(--bg-secondary);
261
261
padding: 0.15rem 0.4rem;
262
-
border-radius: 4px;
262
+
border-radius: var(--radius-sm);
263
263
font-size: 0.85em;
264
264
}
265
265
···
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>
+66
-47
frontend/src/routes/playlist/[id]/+page.svelte
+66
-47
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
···
1341
1356
.playlist-art {
1342
1357
width: 200px;
1343
1358
height: 200px;
1344
-
border-radius: 8px;
1359
+
border-radius: var(--radius-md);
1345
1360
object-fit: cover;
1346
1361
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1347
1362
}
···
1349
1364
.playlist-art-placeholder {
1350
1365
width: 200px;
1351
1366
height: 200px;
1352
-
border-radius: 8px;
1367
+
border-radius: var(--radius-md);
1353
1368
background: var(--bg-tertiary);
1354
1369
border: 1px solid var(--border-subtle);
1355
1370
display: flex;
···
1394
1409
opacity: 0;
1395
1410
transition: opacity 0.2s;
1396
1411
pointer-events: none;
1397
-
border-radius: 8px;
1412
+
border-radius: var(--radius-md);
1398
1413
font-family: inherit;
1399
1414
}
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
···
1526
1541
height: 32px;
1527
1542
background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75));
1528
1543
border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1));
1529
-
border-radius: 6px;
1544
+
border-radius: var(--radius-base);
1530
1545
color: var(--text-secondary);
1531
1546
cursor: pointer;
1532
1547
transition: all 0.15s;
···
1560
1575
.play-button,
1561
1576
.queue-button {
1562
1577
padding: 0.75rem 1.5rem;
1563
-
border-radius: 24px;
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;
···
1634
1653
display: flex;
1635
1654
align-items: center;
1636
1655
gap: 0.5rem;
1637
-
border-radius: 8px;
1656
+
border-radius: var(--radius-md);
1638
1657
transition: all 0.2s;
1639
1658
position: relative;
1640
1659
}
···
1666
1685
color: var(--text-muted);
1667
1686
cursor: grab;
1668
1687
touch-action: none;
1669
-
border-radius: 4px;
1688
+
border-radius: var(--radius-sm);
1670
1689
transition: all 0.2s;
1671
1690
flex-shrink: 0;
1672
1691
}
···
1699
1718
padding: 0.5rem;
1700
1719
background: transparent;
1701
1720
border: 1px solid var(--border-default);
1702
-
border-radius: 4px;
1721
+
border-radius: var(--radius-sm);
1703
1722
color: var(--text-muted);
1704
1723
cursor: pointer;
1705
1724
transition: all 0.2s;
···
1732
1751
margin-top: 0.5rem;
1733
1752
background: transparent;
1734
1753
border: 1px dashed var(--border-default);
1735
-
border-radius: 8px;
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
}
···
1759
1778
.empty-icon {
1760
1779
width: 64px;
1761
1780
height: 64px;
1762
-
border-radius: 16px;
1781
+
border-radius: var(--radius-xl);
1763
1782
display: flex;
1764
1783
align-items: center;
1765
1784
justify-content: center;
···
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
}
···
1786
1805
background: var(--accent);
1787
1806
color: white;
1788
1807
border: none;
1789
-
border-radius: 8px;
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;
···
1816
1835
.modal {
1817
1836
background: var(--bg-primary);
1818
1837
border: 1px solid var(--border-default);
1819
-
border-radius: 16px;
1838
+
border-radius: var(--radius-xl);
1820
1839
width: 100%;
1821
1840
max-width: 400px;
1822
1841
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
···
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;
···
1852
1871
height: 32px;
1853
1872
background: transparent;
1854
1873
border: none;
1855
-
border-radius: 8px;
1874
+
border-radius: var(--radius-md);
1856
1875
color: var(--text-secondary);
1857
1876
cursor: pointer;
1858
1877
transition: all 0.15s;
···
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
···
1922
1941
.result-image-placeholder {
1923
1942
width: 40px;
1924
1943
height: 40px;
1925
-
border-radius: 6px;
1944
+
border-radius: var(--radius-base);
1926
1945
flex-shrink: 0;
1927
1946
}
1928
1947
···
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;
···
1971
1990
height: 36px;
1972
1991
background: var(--accent);
1973
1992
border: none;
1974
-
border-radius: 8px;
1993
+
border-radius: var(--radius-md);
1975
1994
color: white;
1976
1995
cursor: pointer;
1977
1996
transition: all 0.15s;
···
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
···
2008
2027
.cancel-btn,
2009
2028
.confirm-btn {
2010
2029
padding: 0.625rem 1.25rem;
2011
-
border-radius: 8px;
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;
···
2053
2072
height: 16px;
2054
2073
border: 2px solid currentColor;
2055
2074
border-top-color: transparent;
2056
-
border-radius: 50%;
2075
+
border-radius: var(--radius-full);
2057
2076
animation: spin 0.6s linear infinite;
2058
2077
}
2059
2078
···
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
}
+113
-124
frontend/src/routes/portal/+page.svelte
+113
-124
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
-
border-radius: 5px;
1150
+
border-radius: var(--radius-sm);
1149
1151
border: 1px solid var(--border-default);
1150
1152
transition: all 0.15s;
1151
1153
white-space: nowrap;
···
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
-
border-radius: 5px;
1168
+
border-radius: var(--radius-sm);
1167
1169
border: 1px solid var(--border-default);
1168
1170
transition: all 0.15s;
1169
1171
white-space: nowrap;
···
1183
1185
padding: 1rem 1.25rem;
1184
1186
background: var(--bg-tertiary);
1185
1187
border: 1px solid var(--border-default);
1186
-
border-radius: 8px;
1188
+
border-radius: var(--radius-md);
1187
1189
text-decoration: none;
1188
1190
color: var(--text-primary);
1189
1191
transition: all 0.15s;
···
1206
1208
width: 44px;
1207
1209
height: 44px;
1208
1210
background: color-mix(in srgb, var(--accent) 15%, transparent);
1209
-
border-radius: 10px;
1211
+
border-radius: var(--radius-md);
1210
1212
color: var(--accent);
1211
1213
flex-shrink: 0;
1212
1214
}
···
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
···
1243
1245
form {
1244
1246
background: var(--bg-tertiary);
1245
1247
padding: 1.25rem;
1246
-
border-radius: 8px;
1248
+
border-radius: var(--radius-md);
1247
1249
border: 1px solid var(--border-subtle);
1248
1250
}
1249
1251
···
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
-
border-radius: 4px;
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: 4px;
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
-
}
1300
-
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
1298
}
1310
1299
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 {
···
1345
1334
padding: 0.6rem 0.75rem;
1346
1335
background: var(--bg-primary);
1347
1336
border: 1px solid var(--border-default);
1348
-
border-radius: 6px;
1337
+
border-radius: var(--radius-base);
1349
1338
cursor: pointer;
1350
1339
transition: all 0.15s;
1351
1340
margin-bottom: 0;
···
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
···
1412
1401
padding: 0.6rem 0.75rem;
1413
1402
background: var(--bg-primary);
1414
1403
border: 1px solid var(--border-default);
1415
-
border-radius: 4px;
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;
···
1440
1429
.avatar-preview img {
1441
1430
width: 64px;
1442
1431
height: 64px;
1443
-
border-radius: 50%;
1432
+
border-radius: var(--radius-full);
1444
1433
object-fit: cover;
1445
1434
border: 2px solid var(--border-default);
1446
1435
}
···
1450
1439
padding: 0.75rem;
1451
1440
background: var(--bg-primary);
1452
1441
border: 1px solid var(--border-default);
1453
-
border-radius: 4px;
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
···
1474
1463
background: var(--accent);
1475
1464
color: var(--text-primary);
1476
1465
border: none;
1477
-
border-radius: 4px;
1478
-
font-size: 1rem;
1466
+
border-radius: var(--radius-sm);
1467
+
font-size: var(--text-lg);
1479
1468
font-weight: 600;
1480
1469
font-family: inherit;
1481
1470
cursor: pointer;
···
1512
1501
padding: 2rem;
1513
1502
text-align: center;
1514
1503
background: var(--bg-tertiary);
1515
-
border-radius: 8px;
1504
+
border-radius: var(--radius-md);
1516
1505
border: 1px solid var(--border-subtle);
1517
1506
}
1518
1507
···
1529
1518
gap: 1rem;
1530
1519
background: var(--bg-tertiary);
1531
1520
border: 1px solid var(--border-subtle);
1532
-
border-radius: 6px;
1521
+
border-radius: var(--radius-base);
1533
1522
padding: 1rem;
1534
1523
transition: all 0.2s;
1535
1524
}
···
1564
1553
.track-artwork {
1565
1554
width: 48px;
1566
1555
height: 48px;
1567
-
border-radius: 4px;
1556
+
border-radius: var(--radius-sm);
1568
1557
overflow: hidden;
1569
1558
background: var(--bg-primary);
1570
1559
border: 1px solid var(--border-subtle);
···
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;
···
1773
1762
padding: 0.1rem 0.4rem;
1774
1763
background: color-mix(in srgb, var(--accent) 15%, transparent);
1775
1764
color: var(--accent-hover);
1776
-
border-radius: 3px;
1777
-
font-size: 0.8rem;
1765
+
border-radius: var(--radius-sm);
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
···
1807
1796
padding: 0;
1808
1797
background: transparent;
1809
1798
border: 1px solid var(--border-default);
1810
-
border-radius: 6px;
1799
+
border-radius: var(--radius-base);
1811
1800
color: var(--text-tertiary);
1812
1801
cursor: pointer;
1813
1802
transition: all 0.15s;
···
1852
1841
padding: 0.5rem;
1853
1842
background: var(--bg-primary);
1854
1843
border: 1px solid var(--border-default);
1855
-
border-radius: 4px;
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
···
1865
1854
padding: 0.5rem;
1866
1855
background: var(--bg-primary);
1867
1856
border: 1px solid var(--border-default);
1868
-
border-radius: 4px;
1857
+
border-radius: var(--radius-sm);
1869
1858
margin-bottom: 0.5rem;
1870
1859
}
1871
1860
1872
1861
.current-image-preview img {
1873
1862
width: 48px;
1874
1863
height: 48px;
1875
-
border-radius: 4px;
1864
+
border-radius: var(--radius-sm);
1876
1865
object-fit: cover;
1877
1866
}
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 {
···
1910
1899
.album-card {
1911
1900
background: var(--bg-tertiary);
1912
1901
border: 1px solid var(--border-subtle);
1913
-
border-radius: 8px;
1902
+
border-radius: var(--radius-md);
1914
1903
padding: 1rem;
1915
1904
transition: all 0.2s;
1916
1905
display: flex;
···
1928
1917
.album-cover {
1929
1918
width: 100%;
1930
1919
aspect-ratio: 1;
1931
-
border-radius: 6px;
1920
+
border-radius: var(--radius-base);
1932
1921
object-fit: cover;
1933
1922
}
1934
1923
1935
1924
.album-cover-placeholder {
1936
1925
width: 100%;
1937
1926
aspect-ratio: 1;
1938
-
border-radius: 6px;
1927
+
border-radius: var(--radius-base);
1939
1928
background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05));
1940
1929
display: flex;
1941
1930
align-items: center;
···
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
-
border-radius: 5px;
1972
+
border-radius: var(--radius-sm);
1984
1973
border: 1px solid var(--border-default);
1985
1974
transition: all 0.15s;
1986
1975
white-space: nowrap;
···
2001
1990
.playlist-card {
2002
1991
background: var(--bg-tertiary);
2003
1992
border: 1px solid var(--border-subtle);
2004
-
border-radius: 8px;
1993
+
border-radius: var(--radius-md);
2005
1994
padding: 1rem;
2006
1995
transition: all 0.2s;
2007
1996
display: flex;
···
2019
2008
.playlist-cover {
2020
2009
width: 100%;
2021
2010
aspect-ratio: 1;
2022
-
border-radius: 6px;
2011
+
border-radius: var(--radius-base);
2023
2012
object-fit: cover;
2024
2013
}
2025
2014
2026
2015
.playlist-cover-placeholder {
2027
2016
width: 100%;
2028
2017
aspect-ratio: 1;
2029
-
border-radius: 6px;
2018
+
border-radius: var(--radius-base);
2030
2019
background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05));
2031
2020
display: flex;
2032
2021
align-items: center;
···
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
}
···
2069
2058
padding: 1rem 1.25rem;
2070
2059
background: var(--bg-tertiary);
2071
2060
border: 1px solid var(--border-subtle);
2072
-
border-radius: 8px;
2061
+
border-radius: var(--radius-md);
2073
2062
display: flex;
2074
2063
justify-content: space-between;
2075
2064
align-items: center;
···
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;
···
2105
2094
background: var(--accent);
2106
2095
color: var(--text-primary);
2107
2096
border: none;
2108
-
border-radius: 6px;
2109
-
font-size: 0.9rem;
2097
+
border-radius: var(--radius-base);
2098
+
font-size: var(--text-base);
2110
2099
font-weight: 600;
2111
2100
cursor: pointer;
2112
2101
transition: all 0.2s;
···
2150
2139
background: transparent;
2151
2140
color: var(--error);
2152
2141
border: 1px solid var(--error);
2153
-
border-radius: 6px;
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;
···
2168
2157
padding: 1rem;
2169
2158
background: var(--bg-primary);
2170
2159
border: 1px solid var(--border-default);
2171
-
border-radius: 8px;
2160
+
border-radius: var(--radius-md);
2172
2161
}
2173
2162
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
···
2182
2171
margin-bottom: 1rem;
2183
2172
padding: 0.75rem;
2184
2173
background: var(--bg-tertiary);
2185
-
border-radius: 6px;
2174
+
border-radius: var(--radius-base);
2186
2175
}
2187
2176
2188
2177
.atproto-option {
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
···
2219
2208
margin: 0.5rem 0 0;
2220
2209
padding: 0.5rem;
2221
2210
background: color-mix(in srgb, var(--warning) 10%, transparent);
2222
-
border-radius: 4px;
2223
-
font-size: 0.8rem;
2211
+
border-radius: var(--radius-sm);
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
···
2235
2224
padding: 0.6rem 0.75rem;
2236
2225
background: var(--bg-tertiary);
2237
2226
border: 1px solid var(--border-default);
2238
-
border-radius: 6px;
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
}
···
2257
2246
padding: 0.6rem;
2258
2247
background: transparent;
2259
2248
border: 1px solid var(--border-default);
2260
-
border-radius: 6px;
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
}
···
2279
2268
padding: 0.6rem;
2280
2269
background: var(--error);
2281
2270
border: none;
2282
-
border-radius: 6px;
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
}
+8
-8
frontend/src/routes/profile/setup/+page.svelte
+8
-8
frontend/src/routes/profile/setup/+page.svelte
···
222
222
223
223
.error {
224
224
padding: 1rem;
225
-
border-radius: 4px;
225
+
border-radius: var(--radius-sm);
226
226
margin-bottom: 1.5rem;
227
227
background: color-mix(in srgb, var(--error) 10%, transparent);
228
228
border: 1px solid color-mix(in srgb, var(--error) 30%, transparent);
···
232
232
form {
233
233
background: var(--bg-tertiary);
234
234
padding: 2rem;
235
-
border-radius: 8px;
235
+
border-radius: var(--radius-md);
236
236
border: 1px solid var(--border-subtle);
237
237
}
238
238
···
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
···
259
259
padding: 0.75rem;
260
260
background: var(--bg-primary);
261
261
border: 1px solid var(--border-default);
262
-
border-radius: 4px;
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
···
297
297
background: var(--accent);
298
298
color: white;
299
299
border: none;
300
-
border-radius: 4px;
301
-
font-size: 1rem;
300
+
border-radius: var(--radius-sm);
301
+
font-size: var(--text-lg);
302
302
font-weight: 600;
303
303
cursor: pointer;
304
304
transition: all 0.2s;
+46
-46
frontend/src/routes/settings/+page.svelte
+46
-46
frontend/src/routes/settings/+page.svelte
···
774
774
.token-overlay-content {
775
775
background: var(--bg-secondary);
776
776
border: 1px solid var(--border-default);
777
-
border-radius: 16px;
777
+
border-radius: var(--radius-xl);
778
778
padding: 2rem;
779
779
max-width: 500px;
780
780
width: 100%;
···
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
}
···
804
804
gap: 0.5rem;
805
805
background: var(--bg-primary);
806
806
border: 1px solid var(--border-default);
807
-
border-radius: 8px;
807
+
border-radius: var(--radius-md);
808
808
padding: 1rem;
809
809
margin-bottom: 1rem;
810
810
}
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;
···
822
822
padding: 0.5rem 1rem;
823
823
background: var(--accent);
824
824
border: none;
825
-
border-radius: 6px;
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
}
···
855
855
padding: 0.75rem 2rem;
856
856
background: var(--bg-tertiary);
857
857
border: 1px solid var(--border-default);
858
-
border-radius: 8px;
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
-
border-radius: 6px;
907
+
border-radius: var(--radius-base);
908
908
border: 1px solid var(--border-default);
909
909
transition: all 0.15s;
910
910
}
···
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);
···
930
930
.settings-card {
931
931
background: var(--bg-tertiary);
932
932
border: 1px solid var(--border-subtle);
933
-
border-radius: 10px;
933
+
border-radius: var(--radius-md);
934
934
padding: 1rem 1.25rem;
935
935
}
936
936
···
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
}
···
993
993
padding: 0.6rem 0.75rem;
994
994
background: var(--bg-primary);
995
995
border: 1px solid var(--border-default);
996
-
border-radius: 8px;
996
+
border-radius: var(--radius-md);
997
997
color: var(--text-secondary);
998
998
cursor: pointer;
999
999
transition: all 0.15s;
···
1034
1034
width: 40px;
1035
1035
height: 40px;
1036
1036
border: 1px solid var(--border-default);
1037
-
border-radius: 8px;
1037
+
border-radius: var(--radius-md);
1038
1038
cursor: pointer;
1039
1039
background: transparent;
1040
1040
}
···
1044
1044
}
1045
1045
1046
1046
.color-input::-webkit-color-swatch {
1047
-
border-radius: 4px;
1047
+
border-radius: var(--radius-sm);
1048
1048
border: none;
1049
1049
}
1050
1050
···
1056
1056
.preset-btn {
1057
1057
width: 32px;
1058
1058
height: 32px;
1059
-
border-radius: 6px;
1059
+
border-radius: var(--radius-base);
1060
1060
border: 2px solid transparent;
1061
1061
cursor: pointer;
1062
1062
transition: all 0.15s;
···
1085
1085
padding: 0.5rem 0.75rem;
1086
1086
background: var(--bg-primary);
1087
1087
border: 1px solid var(--border-default);
1088
-
border-radius: 6px;
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
}
···
1132
1132
width: 48px;
1133
1133
height: 28px;
1134
1134
background: var(--border-default);
1135
-
border-radius: 999px;
1135
+
border-radius: var(--radius-full);
1136
1136
position: relative;
1137
1137
cursor: pointer;
1138
1138
transition: background 0.2s;
···
1145
1145
left: 4px;
1146
1146
width: 20px;
1147
1147
height: 20px;
1148
-
border-radius: 50%;
1148
+
border-radius: var(--radius-full);
1149
1149
background: var(--text-secondary);
1150
1150
transition: transform 0.2s, background 0.2s;
1151
1151
}
···
1167
1167
padding: 0.75rem;
1168
1168
background: color-mix(in srgb, var(--warning) 10%, transparent);
1169
1169
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
1170
-
border-radius: 6px;
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);
···
1220
1220
padding: 0.75rem;
1221
1221
background: var(--bg-primary);
1222
1222
border: 1px solid var(--border-default);
1223
-
border-radius: 6px;
1223
+
border-radius: var(--radius-base);
1224
1224
}
1225
1225
1226
1226
.token-info {
···
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
···
1245
1245
padding: 0.4rem 0.75rem;
1246
1246
background: transparent;
1247
1247
border: 1px solid var(--border-emphasis);
1248
-
border-radius: 4px;
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;
···
1272
1272
padding: 0.75rem;
1273
1273
background: var(--bg-primary);
1274
1274
border: 1px solid var(--border-default);
1275
-
border-radius: 6px;
1275
+
border-radius: var(--radius-base);
1276
1276
}
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
}
···
1287
1287
padding: 0.4rem 0.6rem;
1288
1288
background: var(--bg-tertiary);
1289
1289
border: 1px solid var(--border-default);
1290
-
border-radius: 4px;
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
···
1320
1320
padding: 0.6rem 0.75rem;
1321
1321
background: var(--bg-primary);
1322
1322
border: 1px solid var(--border-default);
1323
-
border-radius: 6px;
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
···
1343
1343
padding: 0.5rem 0.75rem;
1344
1344
background: var(--bg-primary);
1345
1345
border: 1px solid var(--border-default);
1346
-
border-radius: 6px;
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
}
···
1359
1359
padding: 0.6rem 1rem;
1360
1360
background: var(--accent);
1361
1361
border: none;
1362
-
border-radius: 6px;
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;
+10
-10
frontend/src/routes/tag/[name]/+page.svelte
+10
-10
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);
···
211
211
background: var(--glass-btn-bg, transparent);
212
212
border: 1px solid var(--glass-btn-border, var(--accent));
213
213
color: var(--accent);
214
-
border-radius: 6px;
215
-
font-size: 0.9rem;
214
+
border-radius: var(--radius-base);
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 {
+42
-42
frontend/src/routes/track/[id]/+page.svelte
+42
-42
frontend/src/routes/track/[id]/+page.svelte
···
697
697
width: 100%;
698
698
max-width: 300px;
699
699
aspect-ratio: 1;
700
-
border-radius: 8px;
700
+
border-radius: var(--radius-md);
701
701
overflow: hidden;
702
702
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
703
703
}
···
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 {
···
878
878
padding: 0.25rem 0.6rem;
879
879
background: color-mix(in srgb, var(--accent) 15%, transparent);
880
880
color: var(--accent-hover);
881
-
border-radius: 4px;
882
-
font-size: 0.85rem;
881
+
border-radius: var(--radius-sm);
882
+
font-size: var(--text-sm);
883
883
font-weight: 500;
884
884
text-decoration: none;
885
885
transition: all 0.15s;
···
914
914
background: var(--accent);
915
915
color: var(--bg-primary);
916
916
border: none;
917
-
border-radius: 24px;
918
-
font-size: 0.95rem;
917
+
border-radius: var(--radius-2xl);
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 {
···
939
939
background: transparent;
940
940
color: var(--text-primary);
941
941
border: 1px solid var(--border-emphasis);
942
-
border-radius: 24px;
943
-
font-size: 0.95rem;
942
+
border-radius: var(--radius-2xl);
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;
···
1067
1067
padding: 0.6rem 0.8rem;
1068
1068
background: var(--bg-tertiary);
1069
1069
border: 1px solid var(--border-default);
1070
-
border-radius: 6px;
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
···
1087
1087
background: var(--accent);
1088
1088
color: var(--bg-primary);
1089
1089
border: none;
1090
-
border-radius: 6px;
1091
-
font-size: 0.9rem;
1090
+
border-radius: var(--radius-base);
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
}
···
1142
1142
1143
1143
.comments-list::-webkit-scrollbar-track {
1144
1144
background: var(--bg-primary);
1145
-
border-radius: 4px;
1145
+
border-radius: var(--radius-sm);
1146
1146
}
1147
1147
1148
1148
.comments-list::-webkit-scrollbar-thumb {
1149
1149
background: var(--border-default);
1150
-
border-radius: 4px;
1150
+
border-radius: var(--radius-sm);
1151
1151
}
1152
1152
1153
1153
.comments-list::-webkit-scrollbar-thumb:hover {
···
1160
1160
gap: 0.6rem;
1161
1161
padding: 0.5rem 0.6rem;
1162
1162
background: var(--bg-tertiary);
1163
-
border-radius: 6px;
1163
+
border-radius: var(--radius-base);
1164
1164
transition: background 0.15s;
1165
1165
}
1166
1166
···
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);
1176
1176
padding: 0.2rem 0.5rem;
1177
-
border-radius: 4px;
1177
+
border-radius: var(--radius-sm);
1178
1178
white-space: nowrap;
1179
1179
height: fit-content;
1180
1180
border: none;
···
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
1214
1214
.comment-avatar {
1215
1215
width: 20px;
1216
1216
height: 20px;
1217
-
border-radius: 50%;
1217
+
border-radius: var(--radius-full);
1218
1218
object-fit: cover;
1219
1219
}
1220
1220
1221
1221
.comment-avatar-placeholder {
1222
1222
width: 20px;
1223
1223
height: 20px;
1224
-
border-radius: 50%;
1224
+
border-radius: var(--radius-full);
1225
1225
background: var(--border-default);
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;
···
1310
1310
padding: 0.5rem;
1311
1311
background: var(--bg-primary);
1312
1312
border: 1px solid var(--border-default);
1313
-
border-radius: 4px;
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
-
border-radius: 4px;
1334
+
border-radius: var(--radius-sm);
1335
1335
cursor: pointer;
1336
1336
transition: all 0.15s;
1337
1337
}
···
1381
1381
);
1382
1382
background-size: 200% 100%;
1383
1383
animation: shimmer 1.5s ease-in-out infinite;
1384
-
border-radius: 4px;
1384
+
border-radius: var(--radius-sm);
1385
1385
}
1386
1386
1387
1387
.comment-timestamp-skeleton {
···
1393
1393
.comment-avatar-skeleton {
1394
1394
width: 20px;
1395
1395
height: 20px;
1396
-
border-radius: 50%;
1396
+
border-radius: var(--radius-full);
1397
1397
}
1398
1398
1399
1399
.comment-author-skeleton {
···
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
}
+5
-5
frontend/src/routes/u/[handle]/+error.svelte
+5
-5
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
-
border-radius: 6px;
124
+
border-radius: var(--radius-base);
125
125
transition: all 0.2s;
126
126
display: inline-block;
127
127
}
···
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 {
+29
-29
frontend/src/routes/u/[handle]/+page.svelte
+29
-29
frontend/src/routes/u/[handle]/+page.svelte
···
613
613
padding: 2rem;
614
614
background: var(--bg-secondary);
615
615
border: 1px solid var(--border-subtle);
616
-
border-radius: 8px;
616
+
border-radius: var(--radius-md);
617
617
}
618
618
619
619
.artist-details {
···
651
651
padding: 0 0.75rem;
652
652
background: color-mix(in srgb, var(--accent) 15%, transparent);
653
653
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
654
-
border-radius: 4px;
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
}
···
671
671
.artist-avatar {
672
672
width: 120px;
673
673
height: 120px;
674
-
border-radius: 50%;
674
+
border-radius: var(--radius-full);
675
675
object-fit: cover;
676
676
border: 3px solid var(--border-default);
677
677
}
···
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
}
···
756
756
padding: 1rem;
757
757
background: var(--bg-secondary);
758
758
border: 1px solid var(--border-subtle);
759
-
border-radius: 10px;
759
+
border-radius: var(--radius-md);
760
760
color: inherit;
761
761
text-decoration: none;
762
762
transition: transform 0.15s ease, border-color 0.15s ease;
···
772
772
.album-cover-wrapper {
773
773
width: 72px;
774
774
height: 72px;
775
-
border-radius: 6px;
775
+
border-radius: var(--radius-base);
776
776
overflow: hidden;
777
777
flex-shrink: 0;
778
778
background: var(--bg-tertiary);
···
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;
···
834
834
.stat-card {
835
835
background: var(--bg-secondary);
836
836
border: 1px solid var(--border-subtle);
837
-
border-radius: 8px;
837
+
border-radius: var(--radius-md);
838
838
padding: 1.5rem;
839
839
transition: border-color 0.2s;
840
840
}
···
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
···
908
908
);
909
909
background-size: 200% 100%;
910
910
animation: shimmer 1.5s ease-in-out infinite;
911
-
border-radius: 4px;
911
+
border-radius: var(--radius-sm);
912
912
}
913
913
914
914
/* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */
···
960
960
padding: 0.75rem 1.5rem;
961
961
background: var(--bg-secondary);
962
962
border: 1px solid var(--border-subtle);
963
-
border-radius: 8px;
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;
···
998
998
padding: 3rem;
999
999
background: var(--bg-secondary);
1000
1000
border: 1px solid var(--border-subtle);
1001
-
border-radius: 8px;
1001
+
border-radius: var(--radius-md);
1002
1002
}
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
-
border-radius: 6px;
1021
+
border-radius: var(--radius-base);
1022
1022
transition: all 0.2s;
1023
1023
display: inline-block;
1024
1024
}
···
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
···
1112
1112
.album-cover-wrapper {
1113
1113
width: 56px;
1114
1114
height: 56px;
1115
-
border-radius: 4px;
1115
+
border-radius: var(--radius-sm);
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
···
1148
1148
padding: 1.25rem 1.5rem;
1149
1149
background: var(--bg-secondary);
1150
1150
border: 1px solid var(--border-subtle);
1151
-
border-radius: 10px;
1151
+
border-radius: var(--radius-md);
1152
1152
color: inherit;
1153
1153
text-decoration: none;
1154
1154
transition: transform 0.15s ease, border-color 0.15s ease;
···
1162
1162
.collection-icon {
1163
1163
width: 48px;
1164
1164
height: 48px;
1165
-
border-radius: 8px;
1165
+
border-radius: var(--radius-md);
1166
1166
display: flex;
1167
1167
align-items: center;
1168
1168
justify-content: center;
···
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
+51
-27
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
+51
-27
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">
···
766
786
.album-art {
767
787
width: 200px;
768
788
height: 200px;
769
-
border-radius: 8px;
789
+
border-radius: var(--radius-md);
770
790
object-fit: cover;
771
791
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
772
792
}
···
774
794
.album-art-placeholder {
775
795
width: 200px;
776
796
height: 200px;
777
-
border-radius: 8px;
797
+
border-radius: var(--radius-md);
778
798
background: var(--bg-tertiary);
779
799
border: 1px solid var(--border-subtle);
780
800
display: flex;
···
817
837
height: 32px;
818
838
background: transparent;
819
839
border: 1px solid var(--border-default);
820
-
border-radius: 4px;
840
+
border-radius: var(--radius-sm);
821
841
color: var(--text-tertiary);
822
842
cursor: pointer;
823
843
transition: all 0.15s;
···
858
878
position: absolute;
859
879
inset: 0;
860
880
background: rgba(0, 0, 0, 0.6);
861
-
border-radius: 8px;
881
+
border-radius: var(--radius-md);
862
882
display: flex;
863
883
flex-direction: column;
864
884
align-items: center;
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 {
···
948
968
.play-button,
949
969
.queue-button {
950
970
padding: 0.75rem 1.5rem;
951
-
border-radius: 24px;
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;
···
1018
1042
display: flex;
1019
1043
align-items: center;
1020
1044
gap: 0.5rem;
1021
-
border-radius: 8px;
1045
+
border-radius: var(--radius-md);
1022
1046
transition: all 0.2s;
1023
1047
position: relative;
1024
1048
}
···
1050
1074
color: var(--text-muted);
1051
1075
cursor: grab;
1052
1076
touch-action: none;
1053
-
border-radius: 4px;
1077
+
border-radius: var(--radius-sm);
1054
1078
transition: all 0.2s;
1055
1079
flex-shrink: 0;
1056
1080
}
···
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
}
···
1155
1179
justify-content: center;
1156
1180
width: 32px;
1157
1181
height: 32px;
1158
-
border-radius: 50%;
1182
+
border-radius: var(--radius-full);
1159
1183
background: transparent;
1160
1184
border: none;
1161
1185
color: var(--text-muted);
···
1188
1212
1189
1213
.modal {
1190
1214
background: var(--bg-secondary);
1191
-
border-radius: 12px;
1215
+
border-radius: var(--radius-lg);
1192
1216
padding: 1.5rem;
1193
1217
max-width: 400px;
1194
1218
width: calc(100% - 2rem);
···
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
}
···
1226
1250
.cancel-btn,
1227
1251
.confirm-btn {
1228
1252
padding: 0.625rem 1.25rem;
1229
-
border-radius: 8px;
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;
+18
-17
frontend/src/routes/upload/+page.svelte
+18
-17
frontend/src/routes/upload/+page.svelte
···
379
379
>
380
380
<span>upload track</span>
381
381
</button>
382
+
382
383
</form>
383
384
</main>
384
385
{/if}
···
422
423
form {
423
424
background: var(--bg-tertiary);
424
425
padding: 2rem;
425
-
border-radius: 8px;
426
+
border-radius: var(--radius-md);
426
427
border: 1px solid var(--border-subtle);
427
428
}
428
429
···
434
435
display: block;
435
436
color: var(--text-secondary);
436
437
margin-bottom: 0.5rem;
437
-
font-size: 0.9rem;
438
+
font-size: var(--text-base);
438
439
}
439
440
440
441
input[type="text"] {
···
442
443
padding: 0.75rem;
443
444
background: var(--bg-primary);
444
445
border: 1px solid var(--border-default);
445
-
border-radius: 4px;
446
+
border-radius: var(--radius-sm);
446
447
color: var(--text-primary);
447
-
font-size: 1rem;
448
+
font-size: var(--text-lg);
448
449
font-family: inherit;
449
450
transition: all 0.2s;
450
451
}
···
459
460
padding: 0.75rem;
460
461
background: var(--bg-primary);
461
462
border: 1px solid var(--border-default);
462
-
border-radius: 4px;
463
+
border-radius: var(--radius-sm);
463
464
color: var(--text-primary);
464
-
font-size: 0.9rem;
465
+
font-size: var(--text-base);
465
466
font-family: inherit;
466
467
cursor: pointer;
467
468
}
468
469
469
470
.format-hint {
470
471
margin-top: 0.25rem;
471
-
font-size: 0.8rem;
472
+
font-size: var(--text-sm);
472
473
color: var(--text-tertiary);
473
474
}
474
475
475
476
.file-info {
476
477
margin-top: 0.5rem;
477
-
font-size: 0.85rem;
478
+
font-size: var(--text-sm);
478
479
color: var(--text-muted);
479
480
}
480
481
···
484
485
background: var(--accent);
485
486
color: var(--text-primary);
486
487
border: none;
487
-
border-radius: 4px;
488
-
font-size: 1rem;
488
+
border-radius: var(--radius-sm);
489
+
font-size: var(--text-lg);
489
490
font-weight: 600;
490
491
font-family: inherit;
491
492
cursor: pointer;
···
519
520
.attestation {
520
521
background: var(--bg-primary);
521
522
padding: 1rem;
522
-
border-radius: 4px;
523
+
border-radius: var(--radius-sm);
523
524
border: 1px solid var(--border-default);
524
525
}
525
526
···
541
542
}
542
543
543
544
.checkbox-text {
544
-
font-size: 0.95rem;
545
+
font-size: var(--text-base);
545
546
color: var(--text-primary);
546
547
line-height: 1.4;
547
548
}
···
549
550
.attestation-note {
550
551
margin-top: 0.75rem;
551
552
margin-left: 2rem;
552
-
font-size: 0.8rem;
553
+
font-size: var(--text-sm);
553
554
color: var(--text-tertiary);
554
555
line-height: 1.4;
555
556
}
···
566
567
.supporter-gating {
567
568
background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary));
568
569
padding: 1rem;
569
-
border-radius: 4px;
570
+
border-radius: var(--radius-sm);
570
571
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-default));
571
572
}
572
573
···
583
584
.gating-note {
584
585
margin-top: 0.5rem;
585
586
margin-left: 2rem;
586
-
font-size: 0.8rem;
587
+
font-size: var(--text-sm);
587
588
color: var(--text-tertiary);
588
589
line-height: 1.4;
589
590
}
···
610
611
}
611
612
612
613
.gating-disabled-text {
613
-
font-size: 0.85rem;
614
+
font-size: var(--text-sm);
614
615
line-height: 1.4;
615
616
}
616
617
···
637
638
}
638
639
639
640
.section-header h2 {
640
-
font-size: 1.25rem;
641
+
font-size: var(--text-2xl);
641
642
}
642
643
}
643
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
+2
-1
scripts/costs/export_costs.py
+2
-1
scripts/costs/export_costs.py
···
35
35
AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests
36
36
AUDD_BASE_COST = 5.00 # $5/month base
37
37
38
-
# fixed monthly costs (updated 2025-12-16)
38
+
# fixed monthly costs (updated 2025-12-26)
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
+
# redis: self-hosted on fly (included in fly_io costs)
42
43
FIXED_COSTS = {
43
44
"fly_io": {
44
45
"breakdown": {
+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()