Compare changes

Choose any two refs to compare.

+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
··· 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
··· 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
··· 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
··· 119 119 │ └── src/routes/ # pages 120 120 ├── moderation/ # Rust labeler service 121 121 ├── transcoder/ # Rust audio service 122 + ├── redis/ # self-hosted Redis config 122 123 ├── docs/ # documentation 123 124 └── justfile # task runner 124 125 ``` ··· 128 129 <details> 129 130 <summary>costs</summary> 130 131 131 - ~$35-40/month: 132 - - fly.io backend (prod + staging): ~$10/month 133 - - fly.io transcoder: ~$0-5/month (auto-scales to zero) 132 + ~$20/month: 133 + - fly.io (backend + redis + moderation): ~$14/month 134 134 - neon postgres: $5/month 135 - - audd audio fingerprinting: ~$10/month 136 - - cloudflare (pages + r2): ~$0.16/month 135 + - cloudflare (pages + r2): ~$1/month 136 + - audd audio fingerprinting: $5-10/month (usage-based) 137 + 138 + live dashboard: https://plyr.fm/costs 137 139 138 140 </details> 139 141
+18 -5
STATUS.md
··· 47 47 48 48 ### December 2025 49 49 50 + #### self-hosted redis (PR #674-675, Dec 30) 51 + 52 + **replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month: 53 + - Upstash pay-as-you-go was charging per command (37M commands = $75) 54 + - self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment 55 + - deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging) 56 + - added CI workflow for redis deployments on merge 57 + 58 + **no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres. 59 + 60 + --- 61 + 50 62 #### supporter-gated content (PR #637, Dec 22-23) 51 63 52 64 **atprotofans paywall integration** - artists can now mark tracks as "supporters only": ··· 265 277 266 278 ## cost structure 267 279 268 - current monthly costs: ~$18/month (plyr.fm specific) 280 + current monthly costs: ~$20/month (plyr.fm specific) 269 281 270 282 see live dashboard: [plyr.fm/costs](https://plyr.fm/costs) 271 283 272 - - fly.io (plyr apps only): ~$12/month 284 + - fly.io (backend + redis + moderation): ~$14/month 273 285 - neon postgres: $5/month 274 - - cloudflare (R2 + pages + domain): ~$1.16/month 275 - - audd audio fingerprinting: $0-10/month (6000 free/month) 286 + - cloudflare (R2 + pages + domain): ~$1/month 287 + - audd audio fingerprinting: $5-10/month (usage-based) 276 288 - logfire: $0 (free tier) 277 289 278 290 ## admin tooling ··· 323 335 │ └── src/routes/ # pages 324 336 ├── moderation/ # Rust moderation service (ATProto labeler) 325 337 ├── transcoder/ # Rust audio transcoding service 338 + ├── redis/ # self-hosted Redis config 326 339 ├── docs/ # documentation 327 340 └── justfile # task runner 328 341 ``` ··· 338 351 339 352 --- 340 353 341 - this is a living document. last updated 2025-12-29. 354 + this is a living document. last updated 2025-12-30.
+1 -1
backend/fly.staging.toml
··· 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
··· 39 39 # - AWS_ACCESS_KEY_ID (cloudflare R2) 40 40 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 41 41 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 42 - # - DOCKET_URL (upstash redis: rediss://default:xxx@xxx.upstash.io:6379) 42 + # - DOCKET_URL (self-hosted redis: redis://plyr-redis.internal:6379)
+2 -3
backend/src/backend/_internal/background.py
··· 55 55 extra={"docket_name": settings.docket.name, "url": settings.docket.url}, 56 56 ) 57 57 58 + # WARNING: do not modify Docket() or Worker() constructor args without 59 + # reading docs/backend/background-tasks.md - see 2025-12-30 incident 58 60 async with Docket( 59 61 name=settings.docket.name, 60 62 url=settings.docket.url, 61 - # default 2s is for systems needing fast worker failure detection 62 - # with our 5-minute perpetual task, 30s is plenty responsive 63 - heartbeat_interval=timedelta(seconds=30), 64 63 ) as docket: 65 64 _docket = docket 66 65
+29 -17
docs/backend/background-tasks.md
··· 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
··· 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
··· 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
··· 6 6 - **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache) 7 7 - **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc) 8 8 - **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading 9 + - **design tokens**: use CSS variables from `+layout.svelte` - never hardcode colors, radii, or font sizes (see `docs/frontend/design-tokens.md`) 9 10 10 11 gotchas: 11 12 - **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
+8 -8
frontend/src/lib/components/AddToMenu.svelte
··· 510 510 background: transparent; 511 511 border: none; 512 512 color: var(--text-primary); 513 - font-size: 0.9rem; 513 + font-size: var(--text-base); 514 514 font-family: inherit; 515 515 cursor: pointer; 516 516 transition: background 0.15s; ··· 544 544 border: none; 545 545 border-bottom: 1px solid var(--border-subtle); 546 546 color: var(--text-secondary); 547 - font-size: 0.85rem; 547 + font-size: var(--text-sm); 548 548 font-family: inherit; 549 549 cursor: pointer; 550 550 transition: background 0.15s; ··· 588 588 background: transparent; 589 589 border: none; 590 590 color: var(--text-primary); 591 - font-size: 0.9rem; 591 + font-size: var(--text-base); 592 592 font-family: inherit; 593 593 cursor: pointer; 594 594 transition: background 0.15s; ··· 639 639 gap: 0.5rem; 640 640 padding: 1.5rem 1rem; 641 641 color: var(--text-tertiary); 642 - font-size: 0.85rem; 642 + font-size: var(--text-sm); 643 643 } 644 644 645 645 .create-playlist-btn { ··· 652 652 border: none; 653 653 border-top: 1px solid var(--border-subtle); 654 654 color: var(--accent); 655 - font-size: 0.9rem; 655 + font-size: var(--text-base); 656 656 font-family: inherit; 657 657 cursor: pointer; 658 658 transition: background 0.15s; ··· 678 678 border-radius: var(--radius-base); 679 679 color: var(--text-primary); 680 680 font-family: inherit; 681 - font-size: 0.9rem; 681 + font-size: var(--text-base); 682 682 } 683 683 684 684 .create-form input:focus { ··· 701 701 border-radius: var(--radius-base); 702 702 color: white; 703 703 font-family: inherit; 704 - font-size: 0.9rem; 704 + font-size: var(--text-base); 705 705 font-weight: 500; 706 706 cursor: pointer; 707 707 transition: opacity 0.15s; ··· 783 783 784 784 .menu-item { 785 785 padding: 1rem 1.25rem; 786 - font-size: 1rem; 786 + font-size: var(--text-lg); 787 787 } 788 788 789 789 .back-button {
+3 -3
frontend/src/lib/components/AlbumSelect.svelte
··· 104 104 border: 1px solid var(--border-default); 105 105 border-radius: var(--radius-sm); 106 106 color: var(--text-primary); 107 - font-size: 1rem; 107 + font-size: var(--text-lg); 108 108 font-family: inherit; 109 109 transition: all 0.2s; 110 110 } ··· 203 203 } 204 204 205 205 .album-stats { 206 - font-size: 0.85rem; 206 + font-size: var(--text-sm); 207 207 color: var(--text-tertiary); 208 208 overflow: hidden; 209 209 text-overflow: ellipsis; ··· 212 212 213 213 .similar-hint { 214 214 margin-top: 0.5rem; 215 - font-size: 0.85rem; 215 + font-size: var(--text-sm); 216 216 color: var(--warning); 217 217 font-style: italic; 218 218 margin-bottom: 0;
+9 -9
frontend/src/lib/components/BrokenTracks.svelte
··· 213 213 } 214 214 215 215 .section-header h2 { 216 - font-size: 1.5rem; 216 + font-size: var(--text-3xl); 217 217 margin: 0; 218 218 color: var(--warning); 219 219 } ··· 225 225 border-radius: var(--radius-sm); 226 226 color: var(--warning); 227 227 font-family: inherit; 228 - font-size: 0.9rem; 228 + font-size: var(--text-base); 229 229 font-weight: 600; 230 230 cursor: pointer; 231 231 transition: all 0.2s; ··· 249 249 color: var(--warning); 250 250 padding: 0.25rem 0.6rem; 251 251 border-radius: var(--radius-lg); 252 - font-size: 0.85rem; 252 + font-size: var(--text-sm); 253 253 font-weight: 600; 254 254 } 255 255 ··· 280 280 } 281 281 282 282 .warning-icon { 283 - font-size: 1.25rem; 283 + font-size: var(--text-2xl); 284 284 flex-shrink: 0; 285 285 } 286 286 ··· 291 291 292 292 .track-title { 293 293 font-weight: 600; 294 - font-size: 1rem; 294 + font-size: var(--text-lg); 295 295 margin-bottom: 0.25rem; 296 296 color: var(--text-primary); 297 297 } 298 298 299 299 .track-meta { 300 - font-size: 0.9rem; 300 + font-size: var(--text-base); 301 301 color: var(--text-secondary); 302 302 margin-bottom: 0.5rem; 303 303 } 304 304 305 305 .issue-description { 306 - font-size: 0.85rem; 306 + font-size: var(--text-sm); 307 307 color: var(--warning); 308 308 } 309 309 ··· 314 314 border-radius: var(--radius-sm); 315 315 color: var(--warning); 316 316 font-family: inherit; 317 - font-size: 0.9rem; 317 + font-size: var(--text-base); 318 318 font-weight: 500; 319 319 cursor: pointer; 320 320 transition: all 0.2s; ··· 339 339 border: 1px solid var(--border-subtle); 340 340 border-radius: var(--radius-base); 341 341 padding: 1rem; 342 - font-size: 0.9rem; 342 + font-size: var(--text-base); 343 343 color: var(--text-secondary); 344 344 } 345 345
+4 -4
frontend/src/lib/components/ColorSettings.svelte
··· 147 147 align-items: center; 148 148 margin-bottom: 1rem; 149 149 color: var(--text-primary); 150 - font-size: 0.9rem; 150 + font-size: var(--text-base); 151 151 } 152 152 153 153 .close-btn { 154 154 background: transparent; 155 155 border: none; 156 156 color: var(--text-secondary); 157 - font-size: 1.5rem; 157 + font-size: var(--text-3xl); 158 158 cursor: pointer; 159 159 padding: 0; 160 160 width: 24px; ··· 198 198 199 199 .color-value { 200 200 font-family: monospace; 201 - font-size: 0.85rem; 201 + font-size: var(--text-sm); 202 202 color: var(--text-secondary); 203 203 } 204 204 ··· 209 209 } 210 210 211 211 .presets-label { 212 - font-size: 0.85rem; 212 + font-size: var(--text-sm); 213 213 color: var(--text-tertiary); 214 214 } 215 215
+3 -3
frontend/src/lib/components/HandleAutocomplete.svelte
··· 133 133 border: 1px solid var(--border-default); 134 134 border-radius: var(--radius-sm); 135 135 color: var(--text-primary); 136 - font-size: 1rem; 136 + font-size: var(--text-lg); 137 137 font-family: inherit; 138 138 transition: border-color 0.2s; 139 139 box-sizing: border-box; ··· 159 159 top: 50%; 160 160 transform: translateY(-50%); 161 161 color: var(--text-muted); 162 - font-size: 0.85rem; 162 + font-size: var(--text-sm); 163 163 } 164 164 165 165 .results { ··· 252 252 } 253 253 254 254 .handle { 255 - font-size: 0.85rem; 255 + font-size: var(--text-sm); 256 256 color: var(--text-tertiary); 257 257 overflow: hidden; 258 258 text-overflow: ellipsis;
+7 -7
frontend/src/lib/components/HandleSearch.svelte
··· 181 181 border: 1px solid var(--border-default); 182 182 border-radius: var(--radius-sm); 183 183 color: var(--text-primary); 184 - font-size: 1rem; 184 + font-size: var(--text-lg); 185 185 font-family: inherit; 186 186 transition: all 0.2s; 187 187 } ··· 201 201 right: 0.75rem; 202 202 top: 50%; 203 203 transform: translateY(-50%); 204 - font-size: 0.85rem; 204 + font-size: var(--text-sm); 205 205 color: var(--text-muted); 206 206 } 207 207 ··· 299 299 } 300 300 301 301 .result-handle { 302 - font-size: 0.85rem; 302 + font-size: var(--text-sm); 303 303 color: var(--text-tertiary); 304 304 overflow: hidden; 305 305 text-overflow: ellipsis; ··· 322 322 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle)); 323 323 border-radius: var(--radius-xl); 324 324 color: var(--text-primary); 325 - font-size: 0.9rem; 325 + font-size: var(--text-base); 326 326 } 327 327 328 328 .chip-avatar { ··· 365 365 366 366 .max-features-message { 367 367 margin-top: 0.5rem; 368 - font-size: 0.85rem; 368 + font-size: var(--text-sm); 369 369 color: var(--warning); 370 370 } 371 371 ··· 376 376 border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle)); 377 377 border-radius: var(--radius-sm); 378 378 color: var(--warning); 379 - font-size: 0.9rem; 379 + font-size: var(--text-base); 380 380 text-align: center; 381 381 } 382 382 ··· 401 401 402 402 .selected-artist-chip { 403 403 padding: 0.4rem 0.6rem; 404 - font-size: 0.85rem; 404 + font-size: var(--text-sm); 405 405 } 406 406 407 407 .chip-avatar {
+8 -8
frontend/src/lib/components/Header.svelte
··· 276 276 color: var(--text-secondary); 277 277 padding: 0.5rem 1rem; 278 278 border-radius: var(--radius-base); 279 - font-size: 0.9rem; 279 + font-size: var(--text-base); 280 280 font-family: inherit; 281 281 cursor: pointer; 282 282 transition: all 0.2s; ··· 320 320 } 321 321 322 322 h1 { 323 - font-size: 1.5rem; 323 + font-size: var(--text-3xl); 324 324 margin: 0; 325 325 color: var(--text-primary); 326 326 transition: color 0.2s; ··· 353 353 .nav-link { 354 354 color: var(--text-secondary); 355 355 text-decoration: none; 356 - font-size: 0.9rem; 356 + font-size: var(--text-base); 357 357 transition: all 0.2s; 358 358 white-space: nowrap; 359 359 display: flex; ··· 388 388 .user-handle { 389 389 color: var(--text-secondary); 390 390 text-decoration: none; 391 - font-size: 0.9rem; 391 + font-size: var(--text-base); 392 392 padding: 0.4rem 0.75rem; 393 393 background: var(--bg-tertiary); 394 394 border-radius: var(--radius-base); ··· 409 409 color: var(--accent); 410 410 padding: 0.5rem 1rem; 411 411 border-radius: var(--radius-base); 412 - font-size: 0.9rem; 412 + font-size: var(--text-base); 413 413 text-decoration: none; 414 414 transition: all 0.2s; 415 415 cursor: pointer; ··· 467 467 468 468 .nav-link { 469 469 padding: 0.3rem 0.5rem; 470 - font-size: 0.8rem; 470 + font-size: var(--text-sm); 471 471 } 472 472 473 473 .nav-link span { ··· 475 475 } 476 476 477 477 .user-handle { 478 - font-size: 0.8rem; 478 + font-size: var(--text-sm); 479 479 padding: 0.3rem 0.5rem; 480 480 } 481 481 482 482 .btn-primary { 483 - font-size: 0.8rem; 483 + font-size: var(--text-sm); 484 484 padding: 0.3rem 0.65rem; 485 485 } 486 486 }
+7 -7
frontend/src/lib/components/HiddenTagsFilter.svelte
··· 126 126 align-items: center; 127 127 gap: 0.5rem; 128 128 flex-wrap: wrap; 129 - font-size: 0.8rem; 129 + font-size: var(--text-sm); 130 130 } 131 131 132 132 .filter-toggle { ··· 157 157 } 158 158 159 159 .filter-count { 160 - font-size: 0.7rem; 160 + font-size: var(--text-xs); 161 161 color: var(--text-tertiary); 162 162 } 163 163 164 164 .filter-label { 165 165 color: var(--text-tertiary); 166 166 white-space: nowrap; 167 - font-size: 0.75rem; 167 + font-size: var(--text-xs); 168 168 font-family: inherit; 169 169 } 170 170 ··· 184 184 border: 1px solid var(--border-default); 185 185 color: var(--text-secondary); 186 186 border-radius: var(--radius-sm); 187 - font-size: 0.75rem; 187 + font-size: var(--text-xs); 188 188 font-family: inherit; 189 189 cursor: pointer; 190 190 transition: all 0.15s; ··· 201 201 } 202 202 203 203 .remove-icon { 204 - font-size: 0.8rem; 204 + font-size: var(--text-sm); 205 205 line-height: 1; 206 206 opacity: 0.5; 207 207 } ··· 221 221 border: 1px dashed var(--border-default); 222 222 border-radius: var(--radius-sm); 223 223 color: var(--text-tertiary); 224 - font-size: 0.8rem; 224 + font-size: var(--text-sm); 225 225 cursor: pointer; 226 226 transition: all 0.15s; 227 227 } ··· 236 236 background: transparent; 237 237 border: 1px solid var(--border-default); 238 238 color: var(--text-primary); 239 - font-size: 0.75rem; 239 + font-size: var(--text-xs); 240 240 font-family: inherit; 241 241 min-height: 24px; 242 242 width: 70px;
+5 -5
frontend/src/lib/components/LikersTooltip.svelte
··· 155 155 .error, 156 156 .empty { 157 157 color: var(--text-tertiary); 158 - font-size: 0.85rem; 158 + font-size: var(--text-sm); 159 159 text-align: center; 160 160 padding: 0.5rem; 161 161 } ··· 206 206 justify-content: center; 207 207 color: var(--text-tertiary); 208 208 font-weight: 600; 209 - font-size: 0.9rem; 209 + font-size: var(--text-base); 210 210 } 211 211 212 212 .liker-info { ··· 217 217 .display-name { 218 218 color: var(--text-primary); 219 219 font-weight: 500; 220 - font-size: 0.9rem; 220 + font-size: var(--text-base); 221 221 white-space: nowrap; 222 222 overflow: hidden; 223 223 text-overflow: ellipsis; ··· 225 225 226 226 .handle { 227 227 color: var(--text-tertiary); 228 - font-size: 0.8rem; 228 + font-size: var(--text-sm); 229 229 white-space: nowrap; 230 230 overflow: hidden; 231 231 text-overflow: ellipsis; ··· 233 233 234 234 .liked-time { 235 235 color: var(--text-muted); 236 - font-size: 0.75rem; 236 + font-size: var(--text-xs); 237 237 flex-shrink: 0; 238 238 } 239 239
+3 -3
frontend/src/lib/components/LinksMenu.svelte
··· 186 186 } 187 187 188 188 .menu-header span { 189 - font-size: 0.9rem; 189 + font-size: var(--text-base); 190 190 font-weight: 600; 191 191 color: var(--text-primary); 192 192 text-transform: uppercase; ··· 263 263 } 264 264 265 265 .link-title { 266 - font-size: 0.95rem; 266 + font-size: var(--text-base); 267 267 font-weight: 500; 268 268 color: var(--text-primary); 269 269 } 270 270 271 271 .link-subtitle { 272 - font-size: 0.8rem; 272 + font-size: var(--text-sm); 273 273 color: var(--text-tertiary); 274 274 } 275 275
+2 -2
frontend/src/lib/components/PlatformStats.svelte
··· 182 182 gap: 0.5rem; 183 183 margin-bottom: 0.75rem; 184 184 color: var(--text-secondary); 185 - font-size: 0.7rem; 185 + font-size: var(--text-xs); 186 186 font-weight: 600; 187 187 text-transform: uppercase; 188 188 letter-spacing: 0.05em; ··· 229 229 } 230 230 231 231 .stats-menu-value { 232 - font-size: 0.95rem; 232 + font-size: var(--text-base); 233 233 font-weight: 600; 234 234 color: var(--text-primary); 235 235 font-variant-numeric: tabular-nums;
+8 -8
frontend/src/lib/components/ProfileMenu.svelte
··· 326 326 } 327 327 328 328 .menu-header span { 329 - font-size: 0.9rem; 329 + font-size: var(--text-base); 330 330 font-weight: 600; 331 331 color: var(--text-primary); 332 332 text-transform: uppercase; ··· 414 414 } 415 415 416 416 .item-title { 417 - font-size: 0.95rem; 417 + font-size: var(--text-base); 418 418 font-weight: 500; 419 419 color: var(--text-primary); 420 420 } 421 421 422 422 .item-subtitle { 423 - font-size: 0.8rem; 423 + font-size: var(--text-sm); 424 424 color: var(--text-tertiary); 425 425 overflow: hidden; 426 426 text-overflow: ellipsis; ··· 443 443 border-radius: var(--radius-base); 444 444 color: var(--text-secondary); 445 445 font-family: inherit; 446 - font-size: 0.85rem; 446 + font-size: var(--text-sm); 447 447 cursor: pointer; 448 448 transition: all 0.15s; 449 449 -webkit-tap-highlight-color: transparent; ··· 469 469 470 470 .settings-section h3 { 471 471 margin: 0; 472 - font-size: 0.75rem; 472 + font-size: var(--text-xs); 473 473 text-transform: uppercase; 474 474 letter-spacing: 0.08em; 475 475 color: var(--text-tertiary); ··· 518 518 } 519 519 520 520 .theme-btn span { 521 - font-size: 0.7rem; 521 + font-size: var(--text-xs); 522 522 text-transform: uppercase; 523 523 letter-spacing: 0.05em; 524 524 } ··· 583 583 align-items: center; 584 584 gap: 0.75rem; 585 585 color: var(--text-primary); 586 - font-size: 0.9rem; 586 + font-size: var(--text-base); 587 587 cursor: pointer; 588 588 padding: 0.5rem 0; 589 589 } ··· 635 635 border-top: 1px solid var(--border-subtle); 636 636 color: var(--text-secondary); 637 637 text-decoration: none; 638 - font-size: 0.9rem; 638 + font-size: var(--text-base); 639 639 transition: color 0.15s; 640 640 } 641 641
+9 -9
frontend/src/lib/components/Queue.svelte
··· 249 249 250 250 .queue-header h2 { 251 251 margin: 0; 252 - font-size: 1rem; 252 + font-size: var(--text-lg); 253 253 text-transform: uppercase; 254 254 letter-spacing: 0.12em; 255 255 color: var(--text-tertiary); ··· 263 263 264 264 .clear-btn { 265 265 padding: 0.25rem 0.75rem; 266 - font-size: 0.75rem; 266 + font-size: var(--text-xs); 267 267 font-family: inherit; 268 268 text-transform: uppercase; 269 269 letter-spacing: 0.08em; ··· 290 290 } 291 291 292 292 .section-label { 293 - font-size: 0.75rem; 293 + font-size: var(--text-xs); 294 294 text-transform: uppercase; 295 295 letter-spacing: 0.08em; 296 296 color: var(--text-tertiary); ··· 316 316 } 317 317 318 318 .now-playing-card .track-artist { 319 - font-size: 0.9rem; 319 + font-size: var(--text-base); 320 320 color: var(--text-secondary); 321 321 } 322 322 ··· 344 344 justify-content: space-between; 345 345 align-items: center; 346 346 color: var(--text-tertiary); 347 - font-size: 0.85rem; 347 + font-size: var(--text-sm); 348 348 text-transform: uppercase; 349 349 letter-spacing: 0.08em; 350 350 } 351 351 352 352 .section-header h3 { 353 353 margin: 0; 354 - font-size: 0.85rem; 354 + font-size: var(--text-sm); 355 355 font-weight: 600; 356 356 color: var(--text-secondary); 357 357 text-transform: uppercase; ··· 449 449 } 450 450 451 451 .track-artist { 452 - font-size: 0.85rem; 452 + font-size: var(--text-sm); 453 453 color: var(--text-tertiary); 454 454 white-space: nowrap; 455 455 overflow: hidden; ··· 524 524 525 525 .empty-state p { 526 526 margin: 0.5rem 0 0.25rem; 527 - font-size: 1.1rem; 527 + font-size: var(--text-xl); 528 528 color: var(--text-secondary); 529 529 } 530 530 531 531 .empty-state span { 532 - font-size: 0.9rem; 532 + font-size: var(--text-base); 533 533 } 534 534 535 535 .queue-tracks::-webkit-scrollbar {
+10 -10
frontend/src/lib/components/SearchModal.svelte
··· 303 303 background: transparent; 304 304 border: none; 305 305 outline: none; 306 - font-size: 1rem; 306 + font-size: var(--text-lg); 307 307 font-family: inherit; 308 308 color: var(--text-primary); 309 309 } ··· 313 313 } 314 314 315 315 .search-shortcut { 316 - font-size: 0.7rem; 316 + font-size: var(--text-xs); 317 317 padding: 0.25rem 0.5rem; 318 318 background: var(--bg-tertiary); 319 319 border: 1px solid var(--border-default); ··· 397 397 justify-content: center; 398 398 background: var(--bg-tertiary); 399 399 border-radius: var(--radius-md); 400 - font-size: 0.9rem; 400 + font-size: var(--text-base); 401 401 flex-shrink: 0; 402 402 position: relative; 403 403 overflow: hidden; ··· 441 441 } 442 442 443 443 .result-title { 444 - font-size: 0.9rem; 444 + font-size: var(--text-base); 445 445 font-weight: 500; 446 446 white-space: nowrap; 447 447 overflow: hidden; ··· 449 449 } 450 450 451 451 .result-subtitle { 452 - font-size: 0.75rem; 452 + font-size: var(--text-xs); 453 453 color: var(--text-secondary); 454 454 white-space: nowrap; 455 455 overflow: hidden; ··· 471 471 padding: 2rem; 472 472 text-align: center; 473 473 color: var(--text-secondary); 474 - font-size: 0.9rem; 474 + font-size: var(--text-base); 475 475 } 476 476 477 477 .search-hints { ··· 482 482 .search-hints p { 483 483 margin: 0 0 1rem 0; 484 484 color: var(--text-secondary); 485 - font-size: 0.85rem; 485 + font-size: var(--text-sm); 486 486 } 487 487 488 488 .hint-shortcuts { ··· 490 490 justify-content: center; 491 491 gap: 1.5rem; 492 492 color: var(--text-muted); 493 - font-size: 0.75rem; 493 + font-size: var(--text-xs); 494 494 } 495 495 496 496 .hint-shortcuts span { ··· 512 512 padding: 1rem; 513 513 text-align: center; 514 514 color: var(--error); 515 - font-size: 0.85rem; 515 + font-size: var(--text-sm); 516 516 } 517 517 518 518 /* mobile optimizations */ ··· 535 535 } 536 536 537 537 .search-input::placeholder { 538 - font-size: 0.85rem; 538 + font-size: var(--text-sm); 539 539 } 540 540 541 541 .search-results {
+2 -2
frontend/src/lib/components/SensitiveImage.svelte
··· 72 72 border: 1px solid var(--border-default); 73 73 border-radius: var(--radius-sm); 74 74 padding: 0.25rem 0.5rem; 75 - font-size: 0.7rem; 75 + font-size: var(--text-xs); 76 76 color: var(--text-tertiary); 77 77 white-space: nowrap; 78 78 opacity: 0; ··· 89 89 transform: translate(-50%, -50%); 90 90 margin-bottom: 0; 91 91 padding: 0.5rem 0.75rem; 92 - font-size: 0.8rem; 92 + font-size: var(--text-sm); 93 93 } 94 94 95 95 .sensitive-wrapper.blur:hover .sensitive-tooltip {
+7 -7
frontend/src/lib/components/SettingsMenu.svelte
··· 216 216 align-items: center; 217 217 color: var(--text-primary); 218 218 font-weight: 600; 219 - font-size: 0.95rem; 219 + font-size: var(--text-base); 220 220 } 221 221 222 222 .close-btn { ··· 245 245 246 246 .settings-section h3 { 247 247 margin: 0; 248 - font-size: 0.85rem; 248 + font-size: var(--text-sm); 249 249 text-transform: uppercase; 250 250 letter-spacing: 0.08em; 251 251 color: var(--text-tertiary); ··· 288 288 } 289 289 290 290 .theme-btn span { 291 - font-size: 0.7rem; 291 + font-size: var(--text-xs); 292 292 text-transform: uppercase; 293 293 letter-spacing: 0.05em; 294 294 } ··· 319 319 320 320 .color-value { 321 321 font-family: monospace; 322 - font-size: 0.85rem; 322 + font-size: var(--text-sm); 323 323 color: var(--text-secondary); 324 324 } 325 325 ··· 354 354 align-items: center; 355 355 gap: 0.65rem; 356 356 color: var(--text-primary); 357 - font-size: 0.9rem; 357 + font-size: var(--text-base); 358 358 } 359 359 360 360 .toggle input { ··· 403 403 .toggle-hint { 404 404 margin: 0; 405 405 color: var(--text-tertiary); 406 - font-size: 0.8rem; 406 + font-size: var(--text-sm); 407 407 line-height: 1.3; 408 408 } 409 409 ··· 415 415 border-top: 1px solid var(--border-subtle); 416 416 color: var(--text-secondary); 417 417 text-decoration: none; 418 - font-size: 0.85rem; 418 + font-size: var(--text-sm); 419 419 transition: color 0.15s; 420 420 } 421 421
+1 -1
frontend/src/lib/components/ShareButton.svelte
··· 68 68 color: var(--accent); 69 69 padding: 0.25rem 0.75rem; 70 70 border-radius: var(--radius-sm); 71 - font-size: 0.75rem; 71 + font-size: var(--text-xs); 72 72 white-space: nowrap; 73 73 pointer-events: none; 74 74 animation: fadeIn 0.2s ease-in;
+1 -1
frontend/src/lib/components/SupporterBadge.svelte
··· 24 24 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 25 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;
+5 -5
frontend/src/lib/components/TagInput.svelte
··· 196 196 border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); 197 197 color: var(--accent-hover); 198 198 border-radius: var(--radius-xl); 199 - font-size: 0.9rem; 199 + font-size: var(--text-base); 200 200 font-weight: 500; 201 201 } 202 202 ··· 233 233 background: transparent; 234 234 border: none; 235 235 color: var(--text-primary); 236 - font-size: 1rem; 236 + font-size: var(--text-lg); 237 237 font-family: inherit; 238 238 outline: none; 239 239 } ··· 249 249 250 250 .spinner { 251 251 color: var(--text-muted); 252 - font-size: 0.85rem; 252 + font-size: var(--text-sm); 253 253 margin-left: auto; 254 254 } 255 255 ··· 317 317 } 318 318 319 319 .tag-count { 320 - font-size: 0.85rem; 320 + font-size: var(--text-sm); 321 321 color: var(--text-tertiary); 322 322 } 323 323 ··· 332 332 333 333 .tag-chip { 334 334 padding: 0.3rem 0.5rem; 335 - font-size: 0.85rem; 335 + font-size: var(--text-sm); 336 336 } 337 337 } 338 338 </style>
+4 -4
frontend/src/lib/components/Toast.svelte
··· 63 63 border: 1px solid rgba(255, 255, 255, 0.06); 64 64 border-radius: var(--radius-md); 65 65 pointer-events: none; 66 - font-size: 0.85rem; 66 + font-size: var(--text-sm); 67 67 max-width: 450px; 68 68 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 69 69 } 70 70 71 71 .toast-icon { 72 - font-size: 0.8rem; 72 + font-size: var(--text-sm); 73 73 flex-shrink: 0; 74 74 opacity: 0.7; 75 75 margin-top: 0.1rem; ··· 135 135 136 136 .toast { 137 137 padding: 0.35rem 0.7rem; 138 - font-size: 0.8rem; 138 + font-size: var(--text-sm); 139 139 max-width: 90vw; 140 140 } 141 141 142 142 .toast-icon { 143 - font-size: 0.75rem; 143 + font-size: var(--text-xs); 144 144 } 145 145 146 146 .toast-message {
+9 -9
frontend/src/lib/components/TrackActionsMenu.svelte
··· 474 474 } 475 475 476 476 .menu-item span { 477 - font-size: 1rem; 477 + font-size: var(--text-lg); 478 478 font-weight: 400; 479 479 flex: 1; 480 480 } ··· 508 508 border: none; 509 509 border-bottom: 1px solid var(--border-default); 510 510 color: var(--text-secondary); 511 - font-size: 0.9rem; 511 + font-size: var(--text-base); 512 512 font-family: inherit; 513 513 cursor: pointer; 514 514 transition: background 0.15s; ··· 534 534 border: none; 535 535 border-bottom: 1px solid var(--border-subtle); 536 536 color: var(--text-primary); 537 - font-size: 1rem; 537 + font-size: var(--text-lg); 538 538 font-family: inherit; 539 539 cursor: pointer; 540 540 transition: background 0.15s; ··· 590 590 gap: 0.5rem; 591 591 padding: 2rem 1rem; 592 592 color: var(--text-tertiary); 593 - font-size: 0.9rem; 593 + font-size: var(--text-base); 594 594 } 595 595 596 596 .create-playlist-btn { ··· 603 603 border: none; 604 604 border-top: 1px solid var(--border-subtle); 605 605 color: var(--accent); 606 - font-size: 1rem; 606 + font-size: var(--text-lg); 607 607 font-family: inherit; 608 608 cursor: pointer; 609 609 transition: background 0.15s; ··· 630 630 border-radius: var(--radius-md); 631 631 color: var(--text-primary); 632 632 font-family: inherit; 633 - font-size: 1rem; 633 + font-size: var(--text-lg); 634 634 } 635 635 636 636 .create-form input:focus { ··· 653 653 border-radius: var(--radius-md); 654 654 color: white; 655 655 font-family: inherit; 656 - font-size: 1rem; 656 + font-size: var(--text-lg); 657 657 font-weight: 500; 658 658 cursor: pointer; 659 659 transition: opacity 0.15s; ··· 723 723 } 724 724 725 725 .menu-item span { 726 - font-size: 0.9rem; 726 + font-size: var(--text-base); 727 727 } 728 728 729 729 .menu-item svg { ··· 737 737 738 738 .playlist-item { 739 739 padding: 0.625rem 1rem; 740 - font-size: 0.9rem; 740 + font-size: var(--text-base); 741 741 } 742 742 743 743 .playlist-thumb,
+12 -12
frontend/src/lib/components/TrackItem.svelte
··· 370 370 371 371 .track-index { 372 372 width: 24px; 373 - font-size: 0.85rem; 373 + font-size: var(--text-sm); 374 374 color: var(--text-muted); 375 375 text-align: center; 376 376 flex-shrink: 0; ··· 540 540 align-items: flex-start; 541 541 gap: 0.15rem; 542 542 color: var(--text-secondary); 543 - font-size: 0.9rem; 543 + font-size: var(--text-base); 544 544 font-family: inherit; 545 545 min-width: 0; 546 546 width: 100%; ··· 560 560 561 561 .metadata-separator { 562 562 display: none; 563 - font-size: 0.7rem; 563 + font-size: var(--text-xs); 564 564 } 565 565 566 566 .artist-link { ··· 660 660 background: color-mix(in srgb, var(--accent) 15%, transparent); 661 661 color: var(--accent-hover); 662 662 border-radius: var(--radius-sm); 663 - font-size: 0.75rem; 663 + font-size: var(--text-xs); 664 664 font-weight: 500; 665 665 text-decoration: none; 666 666 transition: all 0.15s; ··· 680 680 color: var(--text-muted); 681 681 border: none; 682 682 border-radius: var(--radius-sm); 683 - font-size: 0.75rem; 683 + font-size: var(--text-xs); 684 684 font-weight: 500; 685 685 font-family: inherit; 686 686 cursor: pointer; ··· 695 695 } 696 696 697 697 .track-meta { 698 - font-size: 0.8rem; 698 + font-size: var(--text-sm); 699 699 color: var(--text-tertiary); 700 700 display: flex; 701 701 align-items: center; ··· 709 709 710 710 .meta-separator { 711 711 color: var(--text-muted); 712 - font-size: 0.7rem; 712 + font-size: var(--text-xs); 713 713 } 714 714 715 715 .likes { ··· 822 822 } 823 823 824 824 .track-title { 825 - font-size: 0.9rem; 825 + font-size: var(--text-base); 826 826 } 827 827 828 828 .track-metadata { 829 - font-size: 0.8rem; 829 + font-size: var(--text-sm); 830 830 gap: 0.35rem; 831 831 } 832 832 833 833 .track-meta { 834 - font-size: 0.7rem; 834 + font-size: var(--text-xs); 835 835 } 836 836 837 837 .track-actions { ··· 875 875 } 876 876 877 877 .track-title { 878 - font-size: 0.85rem; 878 + font-size: var(--text-sm); 879 879 } 880 880 881 881 .track-metadata { 882 - font-size: 0.75rem; 882 + font-size: var(--text-xs); 883 883 } 884 884 885 885 .metadata-separator {
+1 -1
frontend/src/lib/components/WaveLoading.svelte
··· 67 67 .message { 68 68 margin: 0; 69 69 color: var(--text-secondary); 70 - font-size: 0.9rem; 70 + font-size: var(--text-base); 71 71 text-align: center; 72 72 } 73 73 </style>
+2 -2
frontend/src/lib/components/player/PlaybackControls.svelte
··· 310 310 } 311 311 312 312 .time { 313 - font-size: 0.85rem; 313 + font-size: var(--text-sm); 314 314 color: var(--text-tertiary); 315 315 min-width: 45px; 316 316 font-variant-numeric: tabular-nums; ··· 493 493 } 494 494 495 495 .time { 496 - font-size: 0.75rem; 496 + font-size: var(--text-xs); 497 497 min-width: 38px; 498 498 } 499 499
+3 -3
frontend/src/lib/components/player/TrackInfo.svelte
··· 197 197 198 198 .player-title, 199 199 .player-title-link { 200 - font-size: 0.95rem; 200 + font-size: var(--text-base); 201 201 font-weight: 600; 202 202 color: var(--text-primary); 203 203 margin-bottom: 0; ··· 384 384 385 385 .player-title, 386 386 .player-title-link { 387 - font-size: 0.9rem; 387 + font-size: var(--text-base); 388 388 margin-bottom: 0; 389 389 } 390 390 391 391 .player-metadata { 392 - font-size: 0.8rem; 392 + font-size: var(--text-sm); 393 393 } 394 394 395 395 .player-title.scrolling,
+4 -4
frontend/src/routes/+error.svelte
··· 62 62 } 63 63 64 64 .error-message { 65 - font-size: 1.25rem; 65 + font-size: var(--text-2xl); 66 66 color: var(--text-secondary); 67 67 margin: 0 0 0.5rem 0; 68 68 } 69 69 70 70 .error-detail { 71 - font-size: 1rem; 71 + font-size: var(--text-lg); 72 72 color: var(--text-tertiary); 73 73 margin: 0 0 2rem 0; 74 74 } ··· 76 76 .home-link { 77 77 color: var(--accent); 78 78 text-decoration: none; 79 - font-size: 1.1rem; 79 + font-size: var(--text-xl); 80 80 padding: 0.75rem 1.5rem; 81 81 border: 1px solid var(--accent); 82 82 border-radius: var(--radius-base); ··· 98 98 } 99 99 100 100 .error-message { 101 - font-size: 1.1rem; 101 + font-size: var(--text-xl); 102 102 } 103 103 } 104 104 </style>
+22 -3
frontend/src/routes/+layout.svelte
··· 450 450 --text-muted: #666666; 451 451 452 452 /* typography scale */ 453 - --text-page-heading: 1.5rem; 453 + --text-xs: 0.75rem; 454 + --text-sm: 0.85rem; 455 + --text-base: 0.9rem; 456 + --text-lg: 1rem; 457 + --text-xl: 1.1rem; 458 + --text-2xl: 1.25rem; 459 + --text-3xl: 1.5rem; 460 + 461 + /* semantic typography (aliases) */ 462 + --text-page-heading: var(--text-3xl); 454 463 --text-section-heading: 1.2rem; 455 - --text-body: 1rem; 456 - --text-small: 0.9rem; 464 + --text-body: var(--text-lg); 465 + --text-small: var(--text-base); 457 466 458 467 /* border radius scale */ 459 468 --radius-sm: 4px; ··· 523 532 :global(:root.theme-light) :global(.tag-badge) { 524 533 background: color-mix(in srgb, var(--accent) 12%, white); 525 534 color: var(--accent-muted); 535 + } 536 + 537 + /* shared animation for active play buttons */ 538 + @keyframes -global-ethereal-glow { 539 + 0%, 100% { 540 + box-shadow: 0 0 8px 1px color-mix(in srgb, var(--accent) 25%, transparent); 541 + } 542 + 50% { 543 + box-shadow: 0 0 14px 3px color-mix(in srgb, var(--accent) 45%, transparent); 544 + } 526 545 } 527 546 528 547 :global(body) {
+1 -1
frontend/src/routes/+page.svelte
··· 227 227 } 228 228 229 229 .section-header h2 { 230 - font-size: 1.25rem; 230 + font-size: var(--text-2xl); 231 231 } 232 232 } 233 233 </style>
+16 -37
frontend/src/routes/costs/+page.svelte
··· 35 35 breakdown: CostBreakdown; 36 36 note: string; 37 37 }; 38 - upstash?: { 39 - amount: number; 40 - note: string; 41 - }; 42 38 audd: { 43 39 amount: number; 44 40 base_cost: number; ··· 97 93 data.costs.fly_io.amount, 98 94 data.costs.neon.amount, 99 95 data.costs.cloudflare.amount, 100 - data.costs.upstash?.amount ?? 0, 101 96 data.costs.audd.amount 102 97 ) 103 98 : 1 ··· 227 222 <span class="cost-note">{data.costs.cloudflare.note}</span> 228 223 </div> 229 224 230 - {#if data.costs.upstash} 231 - <div class="cost-item"> 232 - <div class="cost-header"> 233 - <span class="cost-name">upstash</span> 234 - <span class="cost-amount">{formatCurrency(data.costs.upstash.amount)}</span> 235 - </div> 236 - <div class="cost-bar-bg"> 237 - <div 238 - class="cost-bar" 239 - style="width: {barWidth(data.costs.upstash.amount, maxCost)}%" 240 - ></div> 241 - </div> 242 - <span class="cost-note">{data.costs.upstash.note}</span> 243 - </div> 244 - {/if} 245 - 246 225 <div class="cost-item"> 247 226 <div class="cost-header"> 248 227 <span class="cost-name">audd</span> ··· 373 352 374 353 .subtitle { 375 354 color: var(--text-tertiary); 376 - font-size: 0.9rem; 355 + font-size: var(--text-base); 377 356 margin: 0; 378 357 } 379 358 ··· 391 370 392 371 .error-state .hint { 393 372 color: var(--text-tertiary); 394 - font-size: 0.85rem; 373 + font-size: var(--text-sm); 395 374 margin-top: 0.5rem; 396 375 } 397 376 ··· 411 390 } 412 391 413 392 .total-label { 414 - font-size: 0.8rem; 393 + font-size: var(--text-sm); 415 394 text-transform: uppercase; 416 395 letter-spacing: 0.08em; 417 396 color: var(--text-tertiary); ··· 426 405 427 406 .updated { 428 407 text-align: center; 429 - font-size: 0.75rem; 408 + font-size: var(--text-xs); 430 409 color: var(--text-tertiary); 431 410 margin-top: 0.75rem; 432 411 } ··· 438 417 439 418 .breakdown-section h2, 440 419 .audd-section h2 { 441 - font-size: 0.8rem; 420 + font-size: var(--text-sm); 442 421 text-transform: uppercase; 443 422 letter-spacing: 0.08em; 444 423 color: var(--text-tertiary); ··· 496 475 } 497 476 498 477 .cost-note { 499 - font-size: 0.75rem; 478 + font-size: var(--text-xs); 500 479 color: var(--text-tertiary); 501 480 } 502 481 ··· 529 508 .time-range-toggle button { 530 509 padding: 0.35rem 0.75rem; 531 510 font-family: inherit; 532 - font-size: 0.75rem; 511 + font-size: var(--text-xs); 533 512 font-weight: 500; 534 513 background: transparent; 535 514 border: none; ··· 551 530 .no-data { 552 531 text-align: center; 553 532 color: var(--text-tertiary); 554 - font-size: 0.85rem; 533 + font-size: var(--text-sm); 555 534 padding: 2rem; 556 535 background: var(--bg-tertiary); 557 536 border: 1px solid var(--border-subtle); ··· 566 545 } 567 546 568 547 .audd-explainer { 569 - font-size: 0.8rem; 548 + font-size: var(--text-sm); 570 549 color: var(--text-secondary); 571 550 margin-bottom: 1.5rem; 572 551 line-height: 1.5; ··· 587 566 } 588 567 589 568 .stat-value { 590 - font-size: 1.25rem; 569 + font-size: var(--text-2xl); 591 570 font-weight: 700; 592 571 color: var(--text-primary); 593 572 font-variant-numeric: tabular-nums; 594 573 } 595 574 596 575 .stat-label { 597 - font-size: 0.7rem; 576 + font-size: var(--text-xs); 598 577 color: var(--text-tertiary); 599 578 text-align: center; 600 579 margin-top: 0.25rem; ··· 610 589 } 611 590 612 591 .daily-chart h3 { 613 - font-size: 0.75rem; 592 + font-size: var(--text-xs); 614 593 text-transform: uppercase; 615 594 letter-spacing: 0.05em; 616 595 color: var(--text-tertiary); ··· 683 662 684 663 .support-text h3 { 685 664 margin: 0 0 0.5rem; 686 - font-size: 1.1rem; 665 + font-size: var(--text-xl); 687 666 color: var(--text-primary); 688 667 } 689 668 690 669 .support-text p { 691 670 margin: 0 0 1.5rem; 692 671 color: var(--text-secondary); 693 - font-size: 0.9rem; 672 + font-size: var(--text-base); 694 673 } 695 674 696 675 .support-button { ··· 703 682 border-radius: var(--radius-md); 704 683 text-decoration: none; 705 684 font-weight: 600; 706 - font-size: 0.9rem; 685 + font-size: var(--text-base); 707 686 transition: transform 0.15s, box-shadow 0.15s; 708 687 } 709 688 ··· 715 694 /* footer */ 716 695 .footer-note { 717 696 text-align: center; 718 - font-size: 0.8rem; 697 + font-size: var(--text-sm); 719 698 color: var(--text-tertiary); 720 699 padding-bottom: 1rem; 721 700 }
+15 -15
frontend/src/routes/library/+page.svelte
··· 264 264 } 265 265 266 266 .page-header p { 267 - font-size: 0.9rem; 267 + font-size: var(--text-base); 268 268 color: var(--text-tertiary); 269 269 margin: 0; 270 270 } ··· 331 331 } 332 332 333 333 .collection-info h3 { 334 - font-size: 1rem; 334 + font-size: var(--text-lg); 335 335 font-weight: 600; 336 336 color: var(--text-primary); 337 337 margin: 0 0 0.15rem 0; ··· 341 341 } 342 342 343 343 .collection-info p { 344 - font-size: 0.85rem; 344 + font-size: var(--text-sm); 345 345 color: var(--text-tertiary); 346 346 margin: 0; 347 347 } ··· 370 370 } 371 371 372 372 .section-header h2 { 373 - font-size: 1.1rem; 373 + font-size: var(--text-xl); 374 374 font-weight: 600; 375 375 color: var(--text-primary); 376 376 margin: 0; ··· 432 432 } 433 433 434 434 .empty-state p { 435 - font-size: 1rem; 435 + font-size: var(--text-lg); 436 436 font-weight: 500; 437 437 color: var(--text-secondary); 438 438 margin: 0 0 0.25rem 0; 439 439 } 440 440 441 441 .empty-state span { 442 - font-size: 0.85rem; 442 + font-size: var(--text-sm); 443 443 color: var(--text-muted); 444 444 } 445 445 ··· 476 476 } 477 477 478 478 .modal-header h3 { 479 - font-size: 1.1rem; 479 + font-size: var(--text-xl); 480 480 font-weight: 600; 481 481 color: var(--text-primary); 482 482 margin: 0; ··· 507 507 508 508 .modal-body label { 509 509 display: block; 510 - font-size: 0.85rem; 510 + font-size: var(--text-sm); 511 511 font-weight: 500; 512 512 color: var(--text-secondary); 513 513 margin-bottom: 0.5rem; ··· 520 520 border: 1px solid var(--border-default); 521 521 border-radius: var(--radius-md); 522 522 font-family: inherit; 523 - font-size: 1rem; 523 + font-size: var(--text-lg); 524 524 color: var(--text-primary); 525 525 transition: border-color 0.15s; 526 526 } ··· 536 536 537 537 .modal-body .error { 538 538 margin: 0.5rem 0 0 0; 539 - font-size: 0.85rem; 539 + font-size: var(--text-sm); 540 540 color: #ef4444; 541 541 } 542 542 ··· 552 552 padding: 0.625rem 1.25rem; 553 553 border-radius: var(--radius-md); 554 554 font-family: inherit; 555 - font-size: 0.9rem; 555 + font-size: var(--text-base); 556 556 font-weight: 500; 557 557 cursor: pointer; 558 558 transition: all 0.15s; ··· 596 596 } 597 597 598 598 .page-header h1 { 599 - font-size: 1.5rem; 599 + font-size: var(--text-3xl); 600 600 } 601 601 602 602 .collection-card { ··· 609 609 } 610 610 611 611 .collection-info h3 { 612 - font-size: 0.95rem; 612 + font-size: var(--text-base); 613 613 } 614 614 615 615 .section-header h2 { 616 - font-size: 1rem; 616 + font-size: var(--text-lg); 617 617 } 618 618 619 619 .create-btn { 620 620 padding: 0.5rem 0.875rem; 621 - font-size: 0.85rem; 621 + font-size: var(--text-sm); 622 622 } 623 623 624 624 .empty-state {
+8 -8
frontend/src/routes/liked/+page.svelte
··· 345 345 } 346 346 347 347 .count { 348 - font-size: 0.85rem; 348 + font-size: var(--text-sm); 349 349 font-weight: 500; 350 350 color: var(--text-tertiary); 351 351 background: var(--bg-tertiary); ··· 364 364 padding: 0.75rem 1.5rem; 365 365 border-radius: var(--radius-2xl); 366 366 font-weight: 600; 367 - font-size: 0.95rem; 367 + font-size: var(--text-base); 368 368 font-family: inherit; 369 369 cursor: pointer; 370 370 transition: all 0.2s; ··· 419 419 } 420 420 421 421 .empty-state h2 { 422 - font-size: 1.5rem; 422 + font-size: var(--text-3xl); 423 423 font-weight: 600; 424 424 color: var(--text-secondary); 425 425 margin: 0 0 0.5rem 0; 426 426 } 427 427 428 428 .empty-state p { 429 - font-size: 0.95rem; 429 + font-size: var(--text-base); 430 430 margin: 0; 431 431 } 432 432 ··· 505 505 } 506 506 507 507 .section-header h2 { 508 - font-size: 1.25rem; 508 + font-size: var(--text-2xl); 509 509 } 510 510 511 511 .count { 512 - font-size: 0.8rem; 512 + font-size: var(--text-sm); 513 513 padding: 0.15rem 0.45rem; 514 514 } 515 515 ··· 518 518 } 519 519 520 520 .empty-state h2 { 521 - font-size: 1.25rem; 521 + font-size: var(--text-2xl); 522 522 } 523 523 524 524 .header-actions { ··· 528 528 .queue-button, 529 529 .reorder-button { 530 530 padding: 0.6rem 1rem; 531 - font-size: 0.85rem; 531 + font-size: var(--text-sm); 532 532 } 533 533 534 534 .queue-button svg,
+14 -14
frontend/src/routes/liked/[handle]/+page.svelte
··· 137 137 justify-content: center; 138 138 background: var(--bg-tertiary); 139 139 color: var(--text-secondary); 140 - font-size: 1.5rem; 140 + font-size: var(--text-3xl); 141 141 font-weight: 600; 142 142 } 143 143 ··· 149 149 } 150 150 151 151 .user-info h1 { 152 - font-size: 1.5rem; 152 + font-size: var(--text-3xl); 153 153 font-weight: 700; 154 154 color: var(--text-primary); 155 155 margin: 0; ··· 159 159 } 160 160 161 161 .handle { 162 - font-size: 0.9rem; 162 + font-size: var(--text-base); 163 163 color: var(--text-tertiary); 164 164 text-decoration: none; 165 165 transition: color 0.15s; ··· 189 189 } 190 190 191 191 .count { 192 - font-size: 0.95rem; 192 + font-size: var(--text-base); 193 193 font-weight: 500; 194 194 color: var(--text-secondary); 195 195 } ··· 209 209 border: 1px solid var(--border-default); 210 210 color: var(--text-secondary); 211 211 border-radius: var(--radius-base); 212 - font-size: 0.85rem; 212 + font-size: var(--text-sm); 213 213 font-family: inherit; 214 214 cursor: pointer; 215 215 transition: all 0.15s; ··· 241 241 } 242 242 243 243 .empty-state h2 { 244 - font-size: 1.5rem; 244 + font-size: var(--text-3xl); 245 245 font-weight: 600; 246 246 color: var(--text-secondary); 247 247 margin: 0 0 0.5rem 0; 248 248 } 249 249 250 250 .empty-state p { 251 - font-size: 0.95rem; 251 + font-size: var(--text-base); 252 252 margin: 0; 253 253 } 254 254 ··· 275 275 } 276 276 277 277 .avatar-placeholder { 278 - font-size: 1.25rem; 278 + font-size: var(--text-2xl); 279 279 } 280 280 281 281 .user-info h1 { 282 - font-size: 1.25rem; 282 + font-size: var(--text-2xl); 283 283 } 284 284 285 285 .handle { 286 - font-size: 0.85rem; 286 + font-size: var(--text-sm); 287 287 } 288 288 289 289 .section-header h2 { 290 - font-size: 1.25rem; 290 + font-size: var(--text-2xl); 291 291 } 292 292 293 293 .count { 294 - font-size: 0.85rem; 294 + font-size: var(--text-sm); 295 295 } 296 296 297 297 .empty-state { ··· 299 299 } 300 300 301 301 .empty-state h2 { 302 - font-size: 1.25rem; 302 + font-size: var(--text-2xl); 303 303 } 304 304 305 305 .btn-action { 306 306 padding: 0.45rem 0.7rem; 307 - font-size: 0.8rem; 307 + font-size: var(--text-sm); 308 308 } 309 309 310 310 .btn-action svg {
+5 -5
frontend/src/routes/login/+page.svelte
··· 171 171 172 172 label { 173 173 color: var(--text-secondary); 174 - font-size: 0.9rem; 174 + font-size: var(--text-base); 175 175 } 176 176 177 177 button.primary { ··· 181 181 color: white; 182 182 border: none; 183 183 border-radius: var(--radius-md); 184 - font-size: 0.95rem; 184 + font-size: var(--text-base); 185 185 font-weight: 500; 186 186 font-family: inherit; 187 187 cursor: pointer; ··· 213 213 border: none; 214 214 color: var(--text-secondary); 215 215 font-family: inherit; 216 - font-size: 0.9rem; 216 + font-size: var(--text-base); 217 217 cursor: pointer; 218 218 text-align: left; 219 219 } ··· 234 234 .faq-content { 235 235 padding: 0 0 1rem 0; 236 236 color: var(--text-tertiary); 237 - font-size: 0.85rem; 237 + font-size: var(--text-sm); 238 238 line-height: 1.6; 239 239 } 240 240 ··· 269 269 } 270 270 271 271 h1 { 272 - font-size: 1.5rem; 272 + font-size: var(--text-3xl); 273 273 } 274 274 } 275 275 </style>
+49 -30
frontend/src/routes/playlist/[id]/+page.svelte
··· 607 607 608 608 // check if user owns this playlist 609 609 const isOwner = $derived(auth.user?.did === playlist.owner_did); 610 + 611 + // check if current track is from this playlist (active, regardless of paused state) 612 + const isPlaylistActive = $derived( 613 + player.currentTrack !== null && 614 + tracks.some(t => t.id === player.currentTrack?.id) 615 + ); 616 + 617 + // check if actively playing (not paused) 618 + const isPlaylistPlaying = $derived(isPlaylistActive && !player.paused); 610 619 </script> 611 620 612 621 <svelte:window on:keydown={handleKeydown} /> ··· 865 874 </div> 866 875 867 876 <div class="playlist-actions"> 868 - <button class="play-button" onclick={playNow}> 869 - <svg 870 - width="20" 871 - height="20" 872 - viewBox="0 0 24 24" 873 - fill="currentColor" 874 - > 875 - <path d="M8 5v14l11-7z" /> 876 - </svg> 877 - play now 877 + <button 878 + class="play-button" 879 + class:is-playing={isPlaylistPlaying} 880 + onclick={() => isPlaylistActive ? player.togglePlayPause() : playNow()} 881 + > 882 + {#if isPlaylistPlaying} 883 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 884 + <path d="M6 4h4v16H6zM14 4h4v16h-4z"/> 885 + </svg> 886 + pause 887 + {:else} 888 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 889 + <path d="M8 5v14l11-7z" /> 890 + </svg> 891 + play 892 + {/if} 878 893 </button> 879 894 <button class="queue-button" onclick={addToQueue}> 880 895 <svg ··· 1400 1415 1401 1416 .art-edit-overlay span { 1402 1417 font-family: inherit; 1403 - font-size: 0.85rem; 1418 + font-size: var(--text-sm); 1404 1419 font-weight: 500; 1405 1420 } 1406 1421 ··· 1432 1447 1433 1448 .playlist-type { 1434 1449 text-transform: uppercase; 1435 - font-size: 0.75rem; 1450 + font-size: var(--text-xs); 1436 1451 font-weight: 600; 1437 1452 letter-spacing: 0.1em; 1438 1453 color: var(--text-tertiary); ··· 1473 1488 display: flex; 1474 1489 align-items: center; 1475 1490 gap: 0.75rem; 1476 - font-size: 0.95rem; 1491 + font-size: var(--text-base); 1477 1492 color: var(--text-secondary); 1478 1493 } 1479 1494 ··· 1490 1505 1491 1506 .meta-separator { 1492 1507 color: var(--text-muted); 1493 - font-size: 0.7rem; 1508 + font-size: var(--text-xs); 1494 1509 } 1495 1510 1496 1511 .show-on-profile-toggle { ··· 1499 1514 gap: 0.5rem; 1500 1515 margin-top: 0.75rem; 1501 1516 cursor: pointer; 1502 - font-size: 0.85rem; 1517 + font-size: var(--text-sm); 1503 1518 color: var(--text-secondary); 1504 1519 } 1505 1520 ··· 1562 1577 padding: 0.75rem 1.5rem; 1563 1578 border-radius: var(--radius-2xl); 1564 1579 font-weight: 600; 1565 - font-size: 0.95rem; 1580 + font-size: var(--text-base); 1566 1581 font-family: inherit; 1567 1582 cursor: pointer; 1568 1583 transition: all 0.2s; ··· 1581 1596 transform: scale(1.05); 1582 1597 } 1583 1598 1599 + .play-button.is-playing { 1600 + animation: ethereal-glow 3s ease-in-out infinite; 1601 + } 1602 + 1584 1603 .queue-button { 1585 1604 background: var(--glass-btn-bg, transparent); 1586 1605 color: var(--text-primary); ··· 1615 1634 } 1616 1635 1617 1636 .section-heading { 1618 - font-size: 1.25rem; 1637 + font-size: var(--text-2xl); 1619 1638 font-weight: 600; 1620 1639 color: var(--text-primary); 1621 1640 margin-bottom: 1rem; ··· 1735 1754 border-radius: var(--radius-md); 1736 1755 color: var(--text-tertiary); 1737 1756 font-family: inherit; 1738 - font-size: 0.9rem; 1757 + font-size: var(--text-base); 1739 1758 cursor: pointer; 1740 1759 transition: all 0.2s; 1741 1760 } ··· 1769 1788 } 1770 1789 1771 1790 .empty-state p { 1772 - font-size: 1rem; 1791 + font-size: var(--text-lg); 1773 1792 font-weight: 500; 1774 1793 color: var(--text-secondary); 1775 1794 margin: 0 0 0.25rem 0; 1776 1795 } 1777 1796 1778 1797 .empty-state span { 1779 - font-size: 0.85rem; 1798 + font-size: var(--text-sm); 1780 1799 color: var(--text-muted); 1781 1800 margin-bottom: 1.5rem; 1782 1801 } ··· 1788 1807 border: none; 1789 1808 border-radius: var(--radius-md); 1790 1809 font-family: inherit; 1791 - font-size: 0.9rem; 1810 + font-size: var(--text-base); 1792 1811 font-weight: 500; 1793 1812 cursor: pointer; 1794 1813 transition: all 0.15s; ··· 1838 1857 } 1839 1858 1840 1859 .modal-header h3 { 1841 - font-size: 1.1rem; 1860 + font-size: var(--text-xl); 1842 1861 font-weight: 600; 1843 1862 color: var(--text-primary); 1844 1863 margin: 0; ··· 1877 1896 background: transparent; 1878 1897 border: none; 1879 1898 font-family: inherit; 1880 - font-size: 1rem; 1899 + font-size: var(--text-lg); 1881 1900 color: var(--text-primary); 1882 1901 outline: none; 1883 1902 } ··· 1898 1917 padding: 2rem 1.5rem; 1899 1918 text-align: center; 1900 1919 color: var(--text-muted); 1901 - font-size: 0.9rem; 1920 + font-size: var(--text-base); 1902 1921 margin: 0; 1903 1922 } 1904 1923 ··· 1947 1966 } 1948 1967 1949 1968 .result-title { 1950 - font-size: 0.9rem; 1969 + font-size: var(--text-base); 1951 1970 font-weight: 500; 1952 1971 color: var(--text-primary); 1953 1972 white-space: nowrap; ··· 1956 1975 } 1957 1976 1958 1977 .result-artist { 1959 - font-size: 0.8rem; 1978 + font-size: var(--text-sm); 1960 1979 color: var(--text-tertiary); 1961 1980 white-space: nowrap; 1962 1981 overflow: hidden; ··· 1994 2013 .modal-body p { 1995 2014 margin: 0; 1996 2015 color: var(--text-secondary); 1997 - font-size: 0.95rem; 2016 + font-size: var(--text-base); 1998 2017 line-height: 1.5; 1999 2018 } 2000 2019 ··· 2010 2029 padding: 0.625rem 1.25rem; 2011 2030 border-radius: var(--radius-md); 2012 2031 font-family: inherit; 2013 - font-size: 0.9rem; 2032 + font-size: var(--text-base); 2014 2033 font-weight: 500; 2015 2034 cursor: pointer; 2016 2035 transition: all 0.15s; ··· 2099 2118 } 2100 2119 2101 2120 .playlist-meta { 2102 - font-size: 0.85rem; 2121 + font-size: var(--text-sm); 2103 2122 } 2104 2123 2105 2124 .playlist-actions { ··· 2142 2161 } 2143 2162 2144 2163 .playlist-meta { 2145 - font-size: 0.8rem; 2164 + font-size: var(--text-sm); 2146 2165 flex-wrap: wrap; 2147 2166 } 2148 2167 }
+78 -89
frontend/src/routes/portal/+page.svelte
··· 106 106 } 107 107 108 108 try { 109 - await loadMyTracks(); 110 - await loadArtistProfile(); 111 - await loadMyAlbums(); 112 - await loadMyPlaylists(); 109 + await Promise.all([ 110 + loadMyTracks(), 111 + loadArtistProfile(), 112 + loadMyAlbums(), 113 + loadMyPlaylists() 114 + ]); 113 115 } catch (_e) { 114 116 console.error('error loading portal data:', _e); 115 117 error = 'failed to load portal data'; ··· 1142 1144 .view-profile-link { 1143 1145 color: var(--text-secondary); 1144 1146 text-decoration: none; 1145 - font-size: 0.8rem; 1147 + font-size: var(--text-sm); 1146 1148 padding: 0.35rem 0.6rem; 1147 1149 background: var(--bg-tertiary); 1148 1150 border-radius: var(--radius-sm); ··· 1160 1162 .settings-link { 1161 1163 color: var(--text-secondary); 1162 1164 text-decoration: none; 1163 - font-size: 0.8rem; 1165 + font-size: var(--text-sm); 1164 1166 padding: 0.35rem 0.6rem; 1165 1167 background: var(--bg-tertiary); 1166 1168 border-radius: var(--radius-sm); ··· 1219 1221 .upload-card-title { 1220 1222 display: block; 1221 1223 font-weight: 600; 1222 - font-size: 0.95rem; 1224 + font-size: var(--text-base); 1223 1225 color: var(--text-primary); 1224 1226 } 1225 1227 1226 1228 .upload-card-subtitle { 1227 1229 display: block; 1228 - font-size: 0.8rem; 1230 + font-size: var(--text-sm); 1229 1231 color: var(--text-tertiary); 1230 1232 } 1231 1233 ··· 1259 1261 display: block; 1260 1262 color: var(--text-secondary); 1261 1263 margin-bottom: 0.4rem; 1262 - font-size: 0.85rem; 1264 + font-size: var(--text-sm); 1263 1265 } 1264 1266 1265 - input[type='text'] { 1267 + input[type='text'], 1268 + input[type='url'], 1269 + textarea { 1266 1270 width: 100%; 1267 1271 padding: 0.6rem 0.75rem; 1268 1272 background: var(--bg-primary); 1269 1273 border: 1px solid var(--border-default); 1270 1274 border-radius: var(--radius-sm); 1271 1275 color: var(--text-primary); 1272 - font-size: 0.95rem; 1276 + font-size: var(--text-base); 1273 1277 font-family: inherit; 1274 1278 transition: all 0.15s; 1275 1279 } 1276 1280 1277 - input[type='text']:focus { 1281 + input[type='text']:focus, 1282 + input[type='url']:focus, 1283 + textarea:focus { 1278 1284 outline: none; 1279 1285 border-color: var(--accent); 1280 1286 } 1281 1287 1282 - input[type='text']:disabled { 1288 + input[type='text']:disabled, 1289 + input[type='url']:disabled, 1290 + textarea:disabled { 1283 1291 opacity: 0.5; 1284 1292 cursor: not-allowed; 1285 1293 } 1286 1294 1287 1295 textarea { 1288 - width: 100%; 1289 - padding: 0.6rem 0.75rem; 1290 - background: var(--bg-primary); 1291 - border: 1px solid var(--border-default); 1292 - border-radius: var(--radius-sm); 1293 - color: var(--text-primary); 1294 - font-size: 0.95rem; 1295 - font-family: inherit; 1296 - transition: all 0.15s; 1297 1296 resize: vertical; 1298 1297 min-height: 80px; 1299 1298 } 1300 1299 1301 - textarea:focus { 1302 - outline: none; 1303 - border-color: var(--accent); 1304 - } 1305 - 1306 - textarea:disabled { 1307 - opacity: 0.5; 1308 - cursor: not-allowed; 1309 - } 1310 - 1311 1300 .hint { 1312 1301 margin-top: 0.35rem; 1313 - font-size: 0.75rem; 1302 + font-size: var(--text-xs); 1314 1303 color: var(--text-muted); 1315 1304 } 1316 1305 ··· 1328 1317 display: block; 1329 1318 color: var(--text-secondary); 1330 1319 margin-bottom: 0.6rem; 1331 - font-size: 0.85rem; 1320 + font-size: var(--text-sm); 1332 1321 } 1333 1322 1334 1323 .support-options { ··· 1368 1357 } 1369 1358 1370 1359 .support-option span { 1371 - font-size: 0.9rem; 1360 + font-size: var(--text-base); 1372 1361 color: var(--text-primary); 1373 1362 } 1374 1363 1375 1364 .support-status { 1376 1365 margin-left: auto; 1377 - font-size: 0.75rem; 1366 + font-size: var(--text-xs); 1378 1367 color: var(--text-tertiary); 1379 1368 } 1380 1369 1381 1370 .support-setup-link, 1382 1371 .support-status-link { 1383 1372 margin-left: auto; 1384 - font-size: 0.75rem; 1373 + font-size: var(--text-xs); 1385 1374 text-decoration: none; 1386 1375 } 1387 1376 ··· 1414 1403 border: 1px solid var(--border-default); 1415 1404 border-radius: var(--radius-sm); 1416 1405 color: var(--text-primary); 1417 - font-size: 0.95rem; 1406 + font-size: var(--text-base); 1418 1407 font-family: inherit; 1419 1408 transition: all 0.15s; 1420 1409 margin-bottom: 0.5rem; ··· 1452 1441 border: 1px solid var(--border-default); 1453 1442 border-radius: var(--radius-sm); 1454 1443 color: var(--text-primary); 1455 - font-size: 0.9rem; 1444 + font-size: var(--text-base); 1456 1445 font-family: inherit; 1457 1446 cursor: pointer; 1458 1447 } ··· 1464 1453 1465 1454 .file-info { 1466 1455 margin-top: 0.5rem; 1467 - font-size: 0.85rem; 1456 + font-size: var(--text-sm); 1468 1457 color: var(--text-muted); 1469 1458 } 1470 1459 ··· 1475 1464 color: var(--text-primary); 1476 1465 border: none; 1477 1466 border-radius: var(--radius-sm); 1478 - font-size: 1rem; 1467 + font-size: var(--text-lg); 1479 1468 font-weight: 600; 1480 1469 font-family: inherit; 1481 1470 cursor: pointer; ··· 1586 1575 } 1587 1576 1588 1577 .track-view-link { 1589 - font-size: 0.7rem; 1578 + font-size: var(--text-xs); 1590 1579 color: var(--text-muted); 1591 1580 text-decoration: none; 1592 1581 transition: color 0.15s; ··· 1633 1622 } 1634 1623 1635 1624 .edit-label { 1636 - font-size: 0.85rem; 1625 + font-size: var(--text-sm); 1637 1626 color: var(--text-secondary); 1638 1627 } 1639 1628 ··· 1642 1631 align-items: center; 1643 1632 gap: 0.5rem; 1644 1633 cursor: pointer; 1645 - font-size: 0.9rem; 1634 + font-size: var(--text-base); 1646 1635 color: var(--text-primary); 1647 1636 } 1648 1637 ··· 1653 1642 } 1654 1643 1655 1644 .field-hint { 1656 - font-size: 0.8rem; 1645 + font-size: var(--text-sm); 1657 1646 color: var(--text-tertiary); 1658 1647 margin-top: 0.25rem; 1659 1648 } ··· 1669 1658 1670 1659 .track-title { 1671 1660 font-weight: 600; 1672 - font-size: 1rem; 1661 + font-size: var(--text-lg); 1673 1662 margin-bottom: 0.25rem; 1674 1663 color: var(--text-primary); 1675 1664 display: flex; ··· 1705 1694 } 1706 1695 1707 1696 .track-meta { 1708 - font-size: 0.9rem; 1697 + font-size: var(--text-base); 1709 1698 color: var(--text-secondary); 1710 1699 margin-bottom: 0.25rem; 1711 1700 display: flex; ··· 1774 1763 background: color-mix(in srgb, var(--accent) 15%, transparent); 1775 1764 color: var(--accent-hover); 1776 1765 border-radius: var(--radius-sm); 1777 - font-size: 0.8rem; 1766 + font-size: var(--text-sm); 1778 1767 font-weight: 500; 1779 1768 text-decoration: none; 1780 1769 transition: all 0.15s; ··· 1786 1775 } 1787 1776 1788 1777 .track-date { 1789 - font-size: 0.85rem; 1778 + font-size: var(--text-sm); 1790 1779 color: var(--text-muted); 1791 1780 } 1792 1781 ··· 1854 1843 border: 1px solid var(--border-default); 1855 1844 border-radius: var(--radius-sm); 1856 1845 color: var(--text-primary); 1857 - font-size: 0.9rem; 1846 + font-size: var(--text-base); 1858 1847 font-family: inherit; 1859 1848 } 1860 1849 ··· 1878 1867 1879 1868 .current-image-label { 1880 1869 color: var(--text-tertiary); 1881 - font-size: 0.85rem; 1870 + font-size: var(--text-sm); 1882 1871 } 1883 1872 1884 1873 .edit-input:focus { ··· 1949 1938 } 1950 1939 1951 1940 .album-title { 1952 - font-size: 1rem; 1941 + font-size: var(--text-lg); 1953 1942 font-weight: 600; 1954 1943 color: var(--text-primary); 1955 1944 margin: 0 0 0.25rem 0; ··· 1959 1948 } 1960 1949 1961 1950 .album-stats { 1962 - font-size: 0.85rem; 1951 + font-size: var(--text-sm); 1963 1952 color: var(--text-tertiary); 1964 1953 margin: 0; 1965 1954 } ··· 1977 1966 .view-playlists-link { 1978 1967 color: var(--text-secondary); 1979 1968 text-decoration: none; 1980 - font-size: 0.8rem; 1969 + font-size: var(--text-sm); 1981 1970 padding: 0.35rem 0.6rem; 1982 1971 background: var(--bg-tertiary); 1983 1972 border-radius: var(--radius-sm); ··· 2040 2029 } 2041 2030 2042 2031 .playlist-title { 2043 - font-size: 1rem; 2032 + font-size: var(--text-lg); 2044 2033 font-weight: 600; 2045 2034 color: var(--text-primary); 2046 2035 margin: 0 0 0.25rem 0; ··· 2050 2039 } 2051 2040 2052 2041 .playlist-stats { 2053 - font-size: 0.85rem; 2042 + font-size: var(--text-sm); 2054 2043 color: var(--text-tertiary); 2055 2044 margin: 0; 2056 2045 } ··· 2087 2076 } 2088 2077 2089 2078 .control-info h3 { 2090 - font-size: 0.9rem; 2079 + font-size: var(--text-base); 2091 2080 font-weight: 600; 2092 2081 margin: 0 0 0.15rem 0; 2093 2082 color: var(--text-primary); 2094 2083 } 2095 2084 2096 2085 .control-description { 2097 - font-size: 0.75rem; 2086 + font-size: var(--text-xs); 2098 2087 color: var(--text-tertiary); 2099 2088 margin: 0; 2100 2089 line-height: 1.4; ··· 2106 2095 color: var(--text-primary); 2107 2096 border: none; 2108 2097 border-radius: var(--radius-base); 2109 - font-size: 0.9rem; 2098 + font-size: var(--text-base); 2110 2099 font-weight: 600; 2111 2100 cursor: pointer; 2112 2101 transition: all 0.2s; ··· 2152 2141 border: 1px solid var(--error); 2153 2142 border-radius: var(--radius-base); 2154 2143 font-family: inherit; 2155 - font-size: 0.9rem; 2144 + font-size: var(--text-base); 2156 2145 font-weight: 600; 2157 2146 cursor: pointer; 2158 2147 transition: all 0.2s; ··· 2174 2163 .delete-warning { 2175 2164 margin: 0 0 1rem; 2176 2165 color: var(--error); 2177 - font-size: 0.9rem; 2166 + font-size: var(--text-base); 2178 2167 line-height: 1.5; 2179 2168 } 2180 2169 ··· 2189 2178 display: flex; 2190 2179 align-items: center; 2191 2180 gap: 0.5rem; 2192 - font-size: 0.9rem; 2181 + font-size: var(--text-base); 2193 2182 color: var(--text-primary); 2194 2183 cursor: pointer; 2195 2184 } ··· 2202 2191 2203 2192 .atproto-note { 2204 2193 margin: 0.5rem 0 0; 2205 - font-size: 0.8rem; 2194 + font-size: var(--text-sm); 2206 2195 color: var(--text-tertiary); 2207 2196 } 2208 2197 ··· 2220 2209 padding: 0.5rem; 2221 2210 background: color-mix(in srgb, var(--warning) 10%, transparent); 2222 2211 border-radius: var(--radius-sm); 2223 - font-size: 0.8rem; 2212 + font-size: var(--text-sm); 2224 2213 color: var(--warning); 2225 2214 } 2226 2215 2227 2216 .confirm-prompt { 2228 2217 margin: 0 0 0.5rem; 2229 - font-size: 0.9rem; 2218 + font-size: var(--text-base); 2230 2219 color: var(--text-secondary); 2231 2220 } 2232 2221 ··· 2237 2226 border: 1px solid var(--border-default); 2238 2227 border-radius: var(--radius-base); 2239 2228 color: var(--text-primary); 2240 - font-size: 0.9rem; 2229 + font-size: var(--text-base); 2241 2230 font-family: inherit; 2242 2231 margin-bottom: 1rem; 2243 2232 } ··· 2260 2249 border-radius: var(--radius-base); 2261 2250 color: var(--text-secondary); 2262 2251 font-family: inherit; 2263 - font-size: 0.9rem; 2252 + font-size: var(--text-base); 2264 2253 cursor: pointer; 2265 2254 transition: all 0.15s; 2266 2255 } ··· 2282 2271 border-radius: var(--radius-base); 2283 2272 color: white; 2284 2273 font-family: inherit; 2285 - font-size: 0.9rem; 2274 + font-size: var(--text-base); 2286 2275 font-weight: 600; 2287 2276 cursor: pointer; 2288 2277 transition: all 0.15s; ··· 2308 2297 } 2309 2298 2310 2299 .portal-header h2 { 2311 - font-size: 1.25rem; 2300 + font-size: var(--text-2xl); 2312 2301 } 2313 2302 2314 2303 .profile-section h2, ··· 2316 2305 .albums-section h2, 2317 2306 .playlists-section h2, 2318 2307 .data-section h2 { 2319 - font-size: 1.1rem; 2308 + font-size: var(--text-xl); 2320 2309 } 2321 2310 2322 2311 .section-header { ··· 2324 2313 } 2325 2314 2326 2315 .view-profile-link { 2327 - font-size: 0.75rem; 2316 + font-size: var(--text-xs); 2328 2317 padding: 0.3rem 0.5rem; 2329 2318 } 2330 2319 ··· 2337 2326 } 2338 2327 2339 2328 label { 2340 - font-size: 0.8rem; 2329 + font-size: var(--text-sm); 2341 2330 margin-bottom: 0.3rem; 2342 2331 } 2343 2332 ··· 2345 2334 input[type='url'], 2346 2335 textarea { 2347 2336 padding: 0.5rem 0.6rem; 2348 - font-size: 0.9rem; 2337 + font-size: var(--text-base); 2349 2338 } 2350 2339 2351 2340 textarea { ··· 2353 2342 } 2354 2343 2355 2344 .hint { 2356 - font-size: 0.7rem; 2345 + font-size: var(--text-xs); 2357 2346 } 2358 2347 2359 2348 .avatar-preview img { ··· 2363 2352 2364 2353 button[type="submit"] { 2365 2354 padding: 0.6rem; 2366 - font-size: 0.9rem; 2355 + font-size: var(--text-base); 2367 2356 } 2368 2357 2369 2358 /* upload card mobile */ ··· 2383 2372 } 2384 2373 2385 2374 .upload-card-title { 2386 - font-size: 0.9rem; 2375 + font-size: var(--text-base); 2387 2376 } 2388 2377 2389 2378 .upload-card-subtitle { 2390 - font-size: 0.75rem; 2379 + font-size: var(--text-xs); 2391 2380 } 2392 2381 2393 2382 /* tracks mobile */ ··· 2421 2410 } 2422 2411 2423 2412 .track-title { 2424 - font-size: 0.9rem; 2413 + font-size: var(--text-base); 2425 2414 } 2426 2415 2427 2416 .track-meta { 2428 - font-size: 0.8rem; 2417 + font-size: var(--text-sm); 2429 2418 } 2430 2419 2431 2420 .track-date { 2432 - font-size: 0.75rem; 2421 + font-size: var(--text-xs); 2433 2422 } 2434 2423 2435 2424 .track-actions { ··· 2457 2446 } 2458 2447 2459 2448 .edit-label { 2460 - font-size: 0.8rem; 2449 + font-size: var(--text-sm); 2461 2450 } 2462 2451 2463 2452 .edit-input { 2464 2453 padding: 0.45rem 0.5rem; 2465 - font-size: 0.85rem; 2454 + font-size: var(--text-sm); 2466 2455 } 2467 2456 2468 2457 .edit-actions { ··· 2476 2465 } 2477 2466 2478 2467 .control-info h3 { 2479 - font-size: 0.85rem; 2468 + font-size: var(--text-sm); 2480 2469 } 2481 2470 2482 2471 .control-description { 2483 - font-size: 0.7rem; 2472 + font-size: var(--text-xs); 2484 2473 } 2485 2474 2486 2475 .export-btn { 2487 2476 padding: 0.5rem 0.85rem; 2488 - font-size: 0.8rem; 2477 + font-size: var(--text-sm); 2489 2478 } 2490 2479 2491 2480 /* albums mobile */ ··· 2500 2489 } 2501 2490 2502 2491 .album-title { 2503 - font-size: 0.85rem; 2492 + font-size: var(--text-sm); 2504 2493 } 2505 2494 2506 2495 /* playlists mobile */ ··· 2515 2504 } 2516 2505 2517 2506 .playlist-title { 2518 - font-size: 0.85rem; 2507 + font-size: var(--text-sm); 2519 2508 } 2520 2509 2521 2510 .playlist-stats { 2522 - font-size: 0.75rem; 2511 + font-size: var(--text-xs); 2523 2512 } 2524 2513 2525 2514 .view-playlists-link { 2526 - font-size: 0.75rem; 2515 + font-size: var(--text-xs); 2527 2516 padding: 0.3rem 0.5rem; 2528 2517 } 2529 2518 }
+4 -4
frontend/src/routes/profile/setup/+page.svelte
··· 248 248 display: block; 249 249 color: var(--text-secondary); 250 250 margin-bottom: 0.5rem; 251 - font-size: 0.9rem; 251 + font-size: var(--text-base); 252 252 font-weight: 500; 253 253 } 254 254 ··· 261 261 border: 1px solid var(--border-default); 262 262 border-radius: var(--radius-sm); 263 263 color: var(--text-primary); 264 - font-size: 1rem; 264 + font-size: var(--text-lg); 265 265 font-family: inherit; 266 266 transition: all 0.2s; 267 267 } ··· 287 287 288 288 .hint { 289 289 margin-top: 0.5rem; 290 - font-size: 0.85rem; 290 + font-size: var(--text-sm); 291 291 color: var(--text-muted); 292 292 } 293 293 ··· 298 298 color: white; 299 299 border: none; 300 300 border-radius: var(--radius-sm); 301 - font-size: 1rem; 301 + font-size: var(--text-lg); 302 302 font-weight: 600; 303 303 cursor: pointer; 304 304 transition: all 0.2s;
+25 -25
frontend/src/routes/settings/+page.svelte
··· 788 788 789 789 .token-overlay-content h2 { 790 790 margin: 0 0 0.75rem; 791 - font-size: 1.5rem; 791 + font-size: var(--text-3xl); 792 792 color: var(--text-primary); 793 793 } 794 794 795 795 .token-overlay-warning { 796 796 color: var(--warning); 797 - font-size: 0.9rem; 797 + font-size: var(--text-base); 798 798 margin: 0 0 1.5rem; 799 799 line-height: 1.5; 800 800 } ··· 811 811 812 812 .token-overlay-display code { 813 813 flex: 1; 814 - font-size: 0.85rem; 814 + font-size: var(--text-sm); 815 815 word-break: break-all; 816 816 color: var(--accent); 817 817 text-align: left; ··· 825 825 border-radius: var(--radius-base); 826 826 color: var(--text-primary); 827 827 font-family: inherit; 828 - font-size: 0.85rem; 828 + font-size: var(--text-sm); 829 829 font-weight: 600; 830 830 cursor: pointer; 831 831 white-space: nowrap; ··· 837 837 } 838 838 839 839 .token-overlay-hint { 840 - font-size: 0.8rem; 840 + font-size: var(--text-sm); 841 841 color: var(--text-tertiary); 842 842 margin: 0 0 1.5rem; 843 843 } ··· 858 858 border-radius: var(--radius-md); 859 859 color: var(--text-secondary); 860 860 font-family: inherit; 861 - font-size: 0.9rem; 861 + font-size: var(--text-base); 862 862 cursor: pointer; 863 863 transition: all 0.15s; 864 864 } ··· 901 901 .portal-link { 902 902 color: var(--text-secondary); 903 903 text-decoration: none; 904 - font-size: 0.85rem; 904 + font-size: var(--text-sm); 905 905 padding: 0.4rem 0.75rem; 906 906 background: var(--bg-tertiary); 907 907 border-radius: var(--radius-base); ··· 919 919 } 920 920 921 921 .settings-section h2 { 922 - font-size: 0.8rem; 922 + font-size: var(--text-sm); 923 923 text-transform: uppercase; 924 924 letter-spacing: 0.08em; 925 925 color: var(--text-tertiary); ··· 957 957 958 958 .setting-info h3 { 959 959 margin: 0 0 0.25rem; 960 - font-size: 0.95rem; 960 + font-size: var(--text-base); 961 961 font-weight: 600; 962 962 color: var(--text-primary); 963 963 } 964 964 965 965 .setting-info p { 966 966 margin: 0; 967 - font-size: 0.8rem; 967 + font-size: var(--text-sm); 968 968 color: var(--text-tertiary); 969 969 line-height: 1.4; 970 970 } ··· 1087 1087 border: 1px solid var(--border-default); 1088 1088 border-radius: var(--radius-base); 1089 1089 color: var(--text-primary); 1090 - font-size: 0.85rem; 1090 + font-size: var(--text-sm); 1091 1091 font-family: inherit; 1092 1092 } 1093 1093 ··· 1104 1104 display: flex; 1105 1105 align-items: center; 1106 1106 gap: 0.4rem; 1107 - font-size: 0.8rem; 1107 + font-size: var(--text-sm); 1108 1108 color: var(--text-secondary); 1109 1109 cursor: pointer; 1110 1110 } ··· 1169 1169 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 1170 1170 border-radius: var(--radius-base); 1171 1171 margin-top: 0.75rem; 1172 - font-size: 0.8rem; 1172 + font-size: var(--text-sm); 1173 1173 color: var(--warning); 1174 1174 } 1175 1175 ··· 1191 1191 /* developer tokens */ 1192 1192 .loading-tokens { 1193 1193 color: var(--text-tertiary); 1194 - font-size: 0.85rem; 1194 + font-size: var(--text-sm); 1195 1195 } 1196 1196 1197 1197 .existing-tokens { ··· 1199 1199 } 1200 1200 1201 1201 .tokens-header { 1202 - font-size: 0.75rem; 1202 + font-size: var(--text-xs); 1203 1203 text-transform: uppercase; 1204 1204 letter-spacing: 0.05em; 1205 1205 color: var(--text-tertiary); ··· 1233 1233 .token-name { 1234 1234 font-weight: 500; 1235 1235 color: var(--text-primary); 1236 - font-size: 0.9rem; 1236 + font-size: var(--text-base); 1237 1237 } 1238 1238 1239 1239 .token-meta { 1240 - font-size: 0.75rem; 1240 + font-size: var(--text-xs); 1241 1241 color: var(--text-tertiary); 1242 1242 } 1243 1243 ··· 1248 1248 border-radius: var(--radius-sm); 1249 1249 color: var(--text-secondary); 1250 1250 font-family: inherit; 1251 - font-size: 0.8rem; 1251 + font-size: var(--text-sm); 1252 1252 cursor: pointer; 1253 1253 transition: all 0.15s; 1254 1254 white-space: nowrap; ··· 1277 1277 1278 1278 .token-value { 1279 1279 flex: 1; 1280 - font-size: 0.8rem; 1280 + font-size: var(--text-sm); 1281 1281 word-break: break-all; 1282 1282 color: var(--accent); 1283 1283 } ··· 1290 1290 border-radius: var(--radius-sm); 1291 1291 color: var(--text-secondary); 1292 1292 font-family: inherit; 1293 - font-size: 0.8rem; 1293 + font-size: var(--text-sm); 1294 1294 cursor: pointer; 1295 1295 transition: all 0.15s; 1296 1296 } ··· 1303 1303 1304 1304 .token-warning { 1305 1305 margin-top: 0.5rem; 1306 - font-size: 0.8rem; 1306 + font-size: var(--text-sm); 1307 1307 color: var(--warning); 1308 1308 } 1309 1309 ··· 1322 1322 border: 1px solid var(--border-default); 1323 1323 border-radius: var(--radius-base); 1324 1324 color: var(--text-primary); 1325 - font-size: 0.9rem; 1325 + font-size: var(--text-base); 1326 1326 font-family: inherit; 1327 1327 } 1328 1328 ··· 1335 1335 display: flex; 1336 1336 align-items: center; 1337 1337 gap: 0.5rem; 1338 - font-size: 0.85rem; 1338 + font-size: var(--text-sm); 1339 1339 color: var(--text-secondary); 1340 1340 } 1341 1341 ··· 1345 1345 border: 1px solid var(--border-default); 1346 1346 border-radius: var(--radius-base); 1347 1347 color: var(--text-primary); 1348 - font-size: 0.85rem; 1348 + font-size: var(--text-sm); 1349 1349 font-family: inherit; 1350 1350 cursor: pointer; 1351 1351 } ··· 1362 1362 border-radius: var(--radius-base); 1363 1363 color: var(--text-primary); 1364 1364 font-family: inherit; 1365 - font-size: 0.9rem; 1365 + font-size: var(--text-base); 1366 1366 font-weight: 600; 1367 1367 cursor: pointer; 1368 1368 transition: all 0.15s;
+9 -9
frontend/src/routes/tag/[name]/+page.svelte
··· 197 197 } 198 198 199 199 .subtitle { 200 - font-size: 0.95rem; 200 + font-size: var(--text-base); 201 201 color: var(--text-tertiary); 202 202 margin: 0; 203 203 text-shadow: var(--text-shadow, none); ··· 212 212 border: 1px solid var(--glass-btn-border, var(--accent)); 213 213 color: var(--accent); 214 214 border-radius: var(--radius-base); 215 - font-size: 0.9rem; 215 + font-size: var(--text-base); 216 216 font-family: inherit; 217 217 cursor: pointer; 218 218 transition: all 0.2s; ··· 240 240 } 241 241 242 242 .empty-state h2 { 243 - font-size: 1.5rem; 243 + font-size: var(--text-3xl); 244 244 font-weight: 600; 245 245 color: var(--text-secondary); 246 246 margin: 0 0 0.5rem 0; 247 247 } 248 248 249 249 .empty-state p { 250 - font-size: 0.95rem; 250 + font-size: var(--text-base); 251 251 margin: 0; 252 252 } 253 253 ··· 264 264 text-align: center; 265 265 padding: 4rem 1rem; 266 266 color: var(--text-tertiary); 267 - font-size: 0.95rem; 267 + font-size: var(--text-base); 268 268 } 269 269 270 270 .tracks-list { ··· 292 292 } 293 293 294 294 .empty-state h2 { 295 - font-size: 1.25rem; 295 + font-size: var(--text-2xl); 296 296 } 297 297 298 298 .btn-queue-all { 299 299 padding: 0.5rem 0.75rem; 300 - font-size: 0.85rem; 300 + font-size: var(--text-sm); 301 301 } 302 302 303 303 .btn-queue-all svg { ··· 330 330 } 331 331 332 332 .subtitle { 333 - font-size: 0.85rem; 333 + font-size: var(--text-sm); 334 334 } 335 335 336 336 .btn-queue-all { 337 337 padding: 0.45rem 0.65rem; 338 - font-size: 0.8rem; 338 + font-size: var(--text-sm); 339 339 } 340 340 341 341 .btn-queue-all svg {
+26 -26
frontend/src/routes/track/[id]/+page.svelte
··· 769 769 gap: 0.75rem; 770 770 flex-wrap: wrap; 771 771 color: var(--text-secondary); 772 - font-size: 1.1rem; 772 + font-size: var(--text-xl); 773 773 } 774 774 775 775 .separator { 776 776 color: var(--text-muted); 777 - font-size: 0.8rem; 777 + font-size: var(--text-sm); 778 778 } 779 779 780 780 .artist-link { ··· 855 855 856 856 .track-stats { 857 857 color: var(--text-tertiary); 858 - font-size: 0.95rem; 858 + font-size: var(--text-base); 859 859 display: flex; 860 860 align-items: center; 861 861 gap: 0.5rem; ··· 863 863 } 864 864 865 865 .track-stats .separator { 866 - font-size: 0.7rem; 866 + font-size: var(--text-xs); 867 867 } 868 868 869 869 .track-tags { ··· 879 879 background: color-mix(in srgb, var(--accent) 15%, transparent); 880 880 color: var(--accent-hover); 881 881 border-radius: var(--radius-sm); 882 - font-size: 0.85rem; 882 + font-size: var(--text-sm); 883 883 font-weight: 500; 884 884 text-decoration: none; 885 885 transition: all 0.15s; ··· 915 915 color: var(--bg-primary); 916 916 border: none; 917 917 border-radius: var(--radius-2xl); 918 - font-size: 0.95rem; 918 + font-size: var(--text-base); 919 919 font-weight: 600; 920 920 font-family: inherit; 921 921 cursor: pointer; ··· 928 928 } 929 929 930 930 .btn-play.playing { 931 - opacity: 0.8; 931 + animation: ethereal-glow 3s ease-in-out infinite; 932 932 } 933 933 934 934 .btn-queue { ··· 940 940 color: var(--text-primary); 941 941 border: 1px solid var(--border-emphasis); 942 942 border-radius: var(--radius-2xl); 943 - font-size: 0.95rem; 943 + font-size: var(--text-base); 944 944 font-weight: 500; 945 945 font-family: inherit; 946 946 cursor: pointer; ··· 984 984 } 985 985 986 986 .track-title { 987 - font-size: 1.5rem; 987 + font-size: var(--text-3xl); 988 988 } 989 989 990 990 .track-metadata { 991 - font-size: 0.9rem; 991 + font-size: var(--text-base); 992 992 gap: 0.5rem; 993 993 } 994 994 995 995 .track-stats { 996 - font-size: 0.85rem; 996 + font-size: var(--text-sm); 997 997 } 998 998 999 999 .track-actions { ··· 1010 1010 min-width: calc(50% - 0.25rem); 1011 1011 justify-content: center; 1012 1012 padding: 0.6rem 1rem; 1013 - font-size: 0.9rem; 1013 + font-size: var(--text-base); 1014 1014 } 1015 1015 1016 1016 .btn-play svg { ··· 1023 1023 min-width: calc(50% - 0.25rem); 1024 1024 justify-content: center; 1025 1025 padding: 0.6rem 1rem; 1026 - font-size: 0.9rem; 1026 + font-size: var(--text-base); 1027 1027 } 1028 1028 1029 1029 .btn-queue svg { ··· 1042 1042 } 1043 1043 1044 1044 .comments-title { 1045 - font-size: 1rem; 1045 + font-size: var(--text-lg); 1046 1046 font-weight: 600; 1047 1047 color: var(--text-primary); 1048 1048 margin: 0 0 0.75rem 0; ··· 1069 1069 border: 1px solid var(--border-default); 1070 1070 border-radius: var(--radius-base); 1071 1071 color: var(--text-primary); 1072 - font-size: 0.9rem; 1072 + font-size: var(--text-base); 1073 1073 font-family: inherit; 1074 1074 } 1075 1075 ··· 1088 1088 color: var(--bg-primary); 1089 1089 border: none; 1090 1090 border-radius: var(--radius-base); 1091 - font-size: 0.9rem; 1091 + font-size: var(--text-base); 1092 1092 font-weight: 600; 1093 1093 font-family: inherit; 1094 1094 cursor: pointer; ··· 1106 1106 1107 1107 .login-prompt { 1108 1108 color: var(--text-tertiary); 1109 - font-size: 0.9rem; 1109 + font-size: var(--text-base); 1110 1110 margin-bottom: 1rem; 1111 1111 } 1112 1112 ··· 1121 1121 1122 1122 .no-comments { 1123 1123 color: var(--text-muted); 1124 - font-size: 0.9rem; 1124 + font-size: var(--text-base); 1125 1125 text-align: center; 1126 1126 padding: 1rem; 1127 1127 } ··· 1169 1169 } 1170 1170 1171 1171 .comment-timestamp { 1172 - font-size: 0.8rem; 1172 + font-size: var(--text-sm); 1173 1173 font-weight: 600; 1174 1174 color: var(--accent); 1175 1175 background: color-mix(in srgb, var(--accent) 10%, transparent); ··· 1207 1207 } 1208 1208 1209 1209 .comment-time { 1210 - font-size: 0.75rem; 1210 + font-size: var(--text-xs); 1211 1211 color: var(--text-muted); 1212 1212 } 1213 1213 ··· 1226 1226 } 1227 1227 1228 1228 .comment-author { 1229 - font-size: 0.85rem; 1229 + font-size: var(--text-sm); 1230 1230 font-weight: 500; 1231 1231 color: var(--text-secondary); 1232 1232 text-decoration: none; ··· 1237 1237 } 1238 1238 1239 1239 .comment-text { 1240 - font-size: 0.9rem; 1240 + font-size: var(--text-base); 1241 1241 color: var(--text-primary); 1242 1242 margin: 0; 1243 1243 line-height: 1.4; ··· 1277 1277 border: none; 1278 1278 padding: 0; 1279 1279 color: var(--text-muted); 1280 - font-size: 0.8rem; 1280 + font-size: var(--text-sm); 1281 1281 cursor: pointer; 1282 1282 transition: color 0.15s; 1283 1283 font-family: inherit; ··· 1312 1312 border: 1px solid var(--border-default); 1313 1313 border-radius: var(--radius-sm); 1314 1314 color: var(--text-primary); 1315 - font-size: 0.9rem; 1315 + font-size: var(--text-base); 1316 1316 font-family: inherit; 1317 1317 } 1318 1318 ··· 1329 1329 1330 1330 .edit-form-btn { 1331 1331 padding: 0.25rem 0.6rem; 1332 - font-size: 0.8rem; 1332 + font-size: var(--text-sm); 1333 1333 font-family: inherit; 1334 1334 border-radius: var(--radius-sm); 1335 1335 cursor: pointer; ··· 1443 1443 } 1444 1444 1445 1445 .comment-timestamp { 1446 - font-size: 0.75rem; 1446 + font-size: var(--text-xs); 1447 1447 padding: 0.15rem 0.4rem; 1448 1448 } 1449 1449 }
+4 -4
frontend/src/routes/u/[handle]/+error.svelte
··· 96 96 } 97 97 98 98 .error-message { 99 - font-size: 1.25rem; 99 + font-size: var(--text-2xl); 100 100 color: var(--text-secondary); 101 101 margin: 0 0 0.5rem 0; 102 102 } 103 103 104 104 .error-detail { 105 - font-size: 1rem; 105 + font-size: var(--text-lg); 106 106 color: var(--text-tertiary); 107 107 margin: 0 0 2rem 0; 108 108 } ··· 118 118 .bsky-link { 119 119 color: var(--accent); 120 120 text-decoration: none; 121 - font-size: 1.1rem; 121 + font-size: var(--text-xl); 122 122 padding: 0.75rem 1.5rem; 123 123 border: 1px solid var(--accent); 124 124 border-radius: var(--radius-base); ··· 151 151 } 152 152 153 153 .error-message { 154 - font-size: 1.1rem; 154 + font-size: var(--text-xl); 155 155 } 156 156 157 157 .actions {
+16 -16
frontend/src/routes/u/[handle]/+page.svelte
··· 653 653 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 654 654 border-radius: var(--radius-sm); 655 655 color: var(--accent); 656 - font-size: 0.85rem; 656 + font-size: var(--text-sm); 657 657 text-decoration: none; 658 658 transition: all 0.2s ease; 659 659 } ··· 695 695 696 696 .handle { 697 697 color: var(--text-tertiary); 698 - font-size: 1.1rem; 698 + font-size: var(--text-xl); 699 699 text-decoration: none; 700 700 transition: color 0.2s; 701 701 } ··· 738 738 739 739 .section-header span { 740 740 color: var(--text-tertiary); 741 - font-size: 0.9rem; 741 + font-size: var(--text-base); 742 742 text-transform: uppercase; 743 743 letter-spacing: 0.1em; 744 744 } ··· 820 820 .album-card-meta p { 821 821 margin: 0; 822 822 color: var(--text-tertiary); 823 - font-size: 0.9rem; 823 + font-size: var(--text-base); 824 824 display: flex; 825 825 align-items: center; 826 826 gap: 0.4rem; ··· 853 853 854 854 .stat-label { 855 855 color: var(--text-tertiary); 856 - font-size: 0.9rem; 856 + font-size: var(--text-base); 857 857 text-transform: lowercase; 858 858 line-height: 1; 859 859 } 860 860 861 861 .stat-duration { 862 862 margin-top: 0.5rem; 863 - font-size: 0.85rem; 863 + font-size: var(--text-sm); 864 864 color: var(--text-secondary); 865 865 font-variant-numeric: tabular-nums; 866 866 } ··· 888 888 889 889 .top-item-plays { 890 890 color: var(--accent); 891 - font-size: 1rem; 891 + font-size: var(--text-lg); 892 892 line-height: 1; 893 893 } 894 894 ··· 963 963 border-radius: var(--radius-md); 964 964 color: var(--text-secondary); 965 965 font-family: inherit; 966 - font-size: 0.95rem; 966 + font-size: var(--text-base); 967 967 cursor: pointer; 968 968 transition: all 0.2s ease; 969 969 } ··· 981 981 982 982 .tracks-loading { 983 983 margin-left: 0.75rem; 984 - font-size: 0.95rem; 984 + font-size: var(--text-base); 985 985 color: var(--text-secondary); 986 986 font-weight: 400; 987 987 text-transform: lowercase; ··· 1003 1003 1004 1004 .empty-message { 1005 1005 color: var(--text-secondary); 1006 - font-size: 1.25rem; 1006 + font-size: var(--text-2xl); 1007 1007 margin: 0 0 0.5rem 0; 1008 1008 } 1009 1009 ··· 1015 1015 .bsky-link { 1016 1016 color: var(--accent); 1017 1017 text-decoration: none; 1018 - font-size: 1rem; 1018 + font-size: var(--text-lg); 1019 1019 padding: 0.75rem 1.5rem; 1020 1020 border: 1px solid var(--accent); 1021 1021 border-radius: var(--radius-base); ··· 1072 1072 1073 1073 .support-btn { 1074 1074 height: 28px; 1075 - font-size: 0.8rem; 1075 + font-size: var(--text-sm); 1076 1076 padding: 0 0.6rem; 1077 1077 } 1078 1078 ··· 1116 1116 } 1117 1117 1118 1118 .album-card-meta h3 { 1119 - font-size: 0.95rem; 1119 + font-size: var(--text-base); 1120 1120 margin-bottom: 0.25rem; 1121 1121 } 1122 1122 1123 1123 .album-card-meta p { 1124 - font-size: 0.8rem; 1124 + font-size: var(--text-sm); 1125 1125 } 1126 1126 } 1127 1127 ··· 1193 1193 1194 1194 .collection-info h3 { 1195 1195 margin: 0 0 0.25rem 0; 1196 - font-size: 1.1rem; 1196 + font-size: var(--text-xl); 1197 1197 color: var(--text-primary); 1198 1198 } 1199 1199 1200 1200 .collection-info p { 1201 1201 margin: 0; 1202 - font-size: 0.9rem; 1202 + font-size: var(--text-base); 1203 1203 color: var(--text-tertiary); 1204 1204 } 1205 1205
+41 -17
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 27 27 tracks = [...data.album.tracks]; 28 28 }); 29 29 30 + // local mutable copy of tracks for reordering 31 + let tracks = $state<Track[]>([...data.album.tracks]); 32 + 30 33 // check if current user owns this album 31 34 const isOwner = $derived(auth.user?.did === albumMetadata.artist_did); 32 35 // can only reorder if owner and album has an ATProto list 33 36 const canReorder = $derived(isOwner && !!albumMetadata.list_uri); 34 37 35 - // local mutable copy of tracks for reordering 36 - let tracks = $state<Track[]>([...data.album.tracks]); 38 + // check if current track is from this album (active, regardless of paused state) 39 + const isAlbumActive = $derived( 40 + player.currentTrack !== null && 41 + tracks.some(t => t.id === player.currentTrack?.id) 42 + ); 43 + 44 + // check if actively playing (not paused) 45 + const isAlbumPlaying = $derived(isAlbumActive && !player.paused); 37 46 38 47 // edit mode state 39 48 let isEditMode = $state(false); ··· 545 554 </div> 546 555 547 556 <div class="album-actions"> 548 - <button class="play-button" onclick={playNow}> 549 - <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 550 - <path d="M8 5v14l11-7z"/> 551 - </svg> 552 - play now 557 + <button 558 + class="play-button" 559 + class:is-playing={isAlbumPlaying} 560 + onclick={() => isAlbumActive ? player.togglePlayPause() : playNow()} 561 + > 562 + {#if isAlbumPlaying} 563 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 564 + <path d="M6 4h4v16H6zM14 4h4v16h-4z"/> 565 + </svg> 566 + pause 567 + {:else} 568 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 569 + <path d="M8 5v14l11-7z"/> 570 + </svg> 571 + play 572 + {/if} 553 573 </button> 554 574 <button class="queue-button" onclick={addToQueue}> 555 575 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> ··· 865 885 justify-content: center; 866 886 gap: 0.5rem; 867 887 color: white; 868 - font-size: 0.85rem; 888 + font-size: var(--text-sm); 869 889 opacity: 0; 870 890 transition: opacity 0.2s; 871 891 } ··· 893 913 894 914 .album-type { 895 915 text-transform: uppercase; 896 - font-size: 0.75rem; 916 + font-size: var(--text-xs); 897 917 font-weight: 600; 898 918 letter-spacing: 0.1em; 899 919 color: var(--text-tertiary); ··· 917 937 display: flex; 918 938 align-items: center; 919 939 gap: 0.75rem; 920 - font-size: 0.95rem; 940 + font-size: var(--text-base); 921 941 color: var(--text-secondary); 922 942 text-shadow: var(--text-shadow, none); 923 943 } ··· 936 956 937 957 .meta-separator { 938 958 color: var(--text-muted); 939 - font-size: 0.7rem; 959 + font-size: var(--text-xs); 940 960 } 941 961 942 962 .album-actions { ··· 950 970 padding: 0.75rem 1.5rem; 951 971 border-radius: var(--radius-2xl); 952 972 font-weight: 600; 953 - font-size: 0.95rem; 973 + font-size: var(--text-base); 954 974 font-family: inherit; 955 975 cursor: pointer; 956 976 transition: all 0.2s; ··· 969 989 transform: scale(1.05); 970 990 } 971 991 992 + .play-button.is-playing { 993 + animation: ethereal-glow 3s ease-in-out infinite; 994 + } 995 + 972 996 .queue-button { 973 997 background: var(--glass-btn-bg, transparent); 974 998 color: var(--text-primary); ··· 1000 1024 } 1001 1025 1002 1026 .section-heading { 1003 - font-size: 1.25rem; 1027 + font-size: var(--text-2xl); 1004 1028 font-weight: 600; 1005 1029 color: var(--text-primary); 1006 1030 margin-bottom: 1rem; ··· 1111 1135 } 1112 1136 1113 1137 .album-meta { 1114 - font-size: 0.85rem; 1138 + font-size: var(--text-sm); 1115 1139 } 1116 1140 1117 1141 .album-actions { ··· 1143 1167 } 1144 1168 1145 1169 .album-meta { 1146 - font-size: 0.8rem; 1170 + font-size: var(--text-sm); 1147 1171 flex-wrap: wrap; 1148 1172 } 1149 1173 } ··· 1202 1226 1203 1227 .modal-header h3 { 1204 1228 margin: 0; 1205 - font-size: 1.25rem; 1229 + font-size: var(--text-2xl); 1206 1230 font-weight: 600; 1207 1231 color: var(--text-primary); 1208 1232 } ··· 1228 1252 padding: 0.625rem 1.25rem; 1229 1253 border-radius: var(--radius-md); 1230 1254 font-weight: 500; 1231 - font-size: 0.9rem; 1255 + font-size: var(--text-base); 1232 1256 font-family: inherit; 1233 1257 cursor: pointer; 1234 1258 transition: all 0.2s;
+11 -11
frontend/src/routes/upload/+page.svelte
··· 435 435 display: block; 436 436 color: var(--text-secondary); 437 437 margin-bottom: 0.5rem; 438 - font-size: 0.9rem; 438 + font-size: var(--text-base); 439 439 } 440 440 441 441 input[type="text"] { ··· 445 445 border: 1px solid var(--border-default); 446 446 border-radius: var(--radius-sm); 447 447 color: var(--text-primary); 448 - font-size: 1rem; 448 + font-size: var(--text-lg); 449 449 font-family: inherit; 450 450 transition: all 0.2s; 451 451 } ··· 462 462 border: 1px solid var(--border-default); 463 463 border-radius: var(--radius-sm); 464 464 color: var(--text-primary); 465 - font-size: 0.9rem; 465 + font-size: var(--text-base); 466 466 font-family: inherit; 467 467 cursor: pointer; 468 468 } 469 469 470 470 .format-hint { 471 471 margin-top: 0.25rem; 472 - font-size: 0.8rem; 472 + font-size: var(--text-sm); 473 473 color: var(--text-tertiary); 474 474 } 475 475 476 476 .file-info { 477 477 margin-top: 0.5rem; 478 - font-size: 0.85rem; 478 + font-size: var(--text-sm); 479 479 color: var(--text-muted); 480 480 } 481 481 ··· 486 486 color: var(--text-primary); 487 487 border: none; 488 488 border-radius: var(--radius-sm); 489 - font-size: 1rem; 489 + font-size: var(--text-lg); 490 490 font-weight: 600; 491 491 font-family: inherit; 492 492 cursor: pointer; ··· 542 542 } 543 543 544 544 .checkbox-text { 545 - font-size: 0.95rem; 545 + font-size: var(--text-base); 546 546 color: var(--text-primary); 547 547 line-height: 1.4; 548 548 } ··· 550 550 .attestation-note { 551 551 margin-top: 0.75rem; 552 552 margin-left: 2rem; 553 - font-size: 0.8rem; 553 + font-size: var(--text-sm); 554 554 color: var(--text-tertiary); 555 555 line-height: 1.4; 556 556 } ··· 584 584 .gating-note { 585 585 margin-top: 0.5rem; 586 586 margin-left: 2rem; 587 - font-size: 0.8rem; 587 + font-size: var(--text-sm); 588 588 color: var(--text-tertiary); 589 589 line-height: 1.4; 590 590 } ··· 611 611 } 612 612 613 613 .gating-disabled-text { 614 - font-size: 0.85rem; 614 + font-size: var(--text-sm); 615 615 line-height: 1.4; 616 616 } 617 617 ··· 638 638 } 639 639 640 640 .section-header h2 { 641 - font-size: 1.25rem; 641 + font-size: var(--text-2xl); 642 642 } 643 643 } 644 644 </style>
+1 -1
moderation/Cargo.toml
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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('&', "&amp;") 437 + .replace('<', "&lt;") 438 + .replace('>', "&gt;") 439 + .replace('"', "&quot;") 440 + .replace('\'', "&#039;") 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
··· 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
··· 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
··· 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
··· 1 + app = "plyr-redis" 2 + primary_region = "iad" 3 + 4 + [build] 5 + image = "redis:7-alpine" 6 + 7 + [mounts] 8 + source = "redis_data" 9 + destination = "/data" 10 + 11 + [env] 12 + # redis config via command line args in [processes] 13 + 14 + [processes] 15 + app = "--appendonly yes --maxmemory 200mb --maxmemory-policy allkeys-lru" 16 + 17 + [[services]] 18 + protocol = "tcp" 19 + internal_port = 6379 20 + processes = ["app"] 21 + 22 + # only accessible within private network 23 + [[services.ports]] 24 + port = 6379 25 + 26 + [[vm]] 27 + memory = "256mb" 28 + cpu_kind = "shared" 29 + cpus = 1
+1 -10
scripts/costs/export_costs.py
··· 39 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 40 # neon: fixed $5/month 41 41 # cloudflare: mostly free tier 42 - # upstash: free tier (256MB, 500K commands/month) 42 + # redis: self-hosted on fly (included in fly_io costs) 43 43 FIXED_COSTS = { 44 44 "fly_io": { 45 45 "breakdown": { ··· 60 60 "domain": 1.00, 61 61 "total": 1.16, 62 62 "note": "r2 egress is free, pages free tier", 63 - }, 64 - "upstash": { 65 - "total": 0.00, 66 - "note": "redis for docket + caching (free tier: 256MB, 500K commands/month)", 67 63 }, 68 64 } 69 65 ··· 206 202 plyr_fly 207 203 + FIXED_COSTS["neon"]["total"] 208 204 + FIXED_COSTS["cloudflare"]["total"] 209 - + FIXED_COSTS["upstash"]["total"] 210 205 + audd_stats["estimated_cost"] 211 206 ) 212 207 ··· 231 226 "domain": FIXED_COSTS["cloudflare"]["domain"], 232 227 }, 233 228 "note": FIXED_COSTS["cloudflare"]["note"], 234 - }, 235 - "upstash": { 236 - "amount": FIXED_COSTS["upstash"]["total"], 237 - "note": FIXED_COSTS["upstash"]["note"], 238 229 }, 239 230 "audd": { 240 231 "amount": audd_stats["estimated_cost"],
+4 -6
scripts/docket_runs.py
··· 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
··· 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()