docs initial

This commit is contained in:
Daniil
2026-04-06 01:44:58 +03:00
parent 2a344ad588
commit 694b8bc77c
84 changed files with 6922 additions and 298 deletions
@@ -0,0 +1,888 @@
# Docker Infrastructure Hardening — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Harden all Docker infrastructure across the monorepo — security, build optimization, service organization, health checks, and networking.
**Architecture:** 4-phase approach: quick config fixes first (no code changes), then Dockerfile improvements, then health endpoints + networking, then resource limits. Each phase produces a working stack.
**Tech Stack:** Docker, Docker Compose, FastAPI (Python), ElysiaJS (Bun/TypeScript), PostgreSQL, Redis, MinIO
---
### Task 1: Add .env to .gitignore files
**Files:**
- Modify: `cofee_backend/.gitignore`
- Modify: `cofee_frontend/.gitignore`
- [ ] **Step 1: Add .env exclusion to backend .gitignore**
Append to `cofee_backend/.gitignore`:
```
# Environment
.env
.env.*
```
- [ ] **Step 2: Add .env exclusion to frontend .gitignore**
The frontend `.gitignore` has `.env*.local` but not `.env` itself. Add before the `# local env files` section in `cofee_frontend/.gitignore`:
```
# Environment
.env
```
Note: Keep the existing `.env*.local` line too.
- [ ] **Step 3: Verify .env files are not tracked**
Run: `git ls-files | grep '\.env'`
Expected: no output. If any .env files are tracked, run `git rm --cached <file>` for each.
- [ ] **Step 4: Commit**
```bash
git add cofee_backend/.gitignore cofee_frontend/.gitignore
git commit -m "fix(infra): add .env to backend and frontend .gitignore"
```
---
### Task 2: Add .env to backend .dockerignore
**Files:**
- Modify: `cofee_backend/.dockerignore`
- [ ] **Step 1: Add .env exclusion**
Add to `cofee_backend/.dockerignore`:
```
.env
.env.*
```
- [ ] **Step 2: Commit**
```bash
git add cofee_backend/.dockerignore
git commit -m "fix(infra): exclude .env from backend Docker build context"
```
---
### Task 3: DRY up docker-compose env vars with YAML anchor
**Files:**
- Modify: `cofee_backend/docker-compose.yml`
The `api` and `worker` services share 14 identical env vars. Extract into an `x-backend-env` anchor. Also adds the missing `JWT_SECRET_KEY` to worker.
- [ ] **Step 1: Add x-backend-env anchor and refactor services**
Replace the entire `cofee_backend/docker-compose.yml` with:
```yaml
x-backend-image: &backend-image
image: cpv3-backend:dev
build:
context: .
dockerfile: Dockerfile
target: dev
x-backend-env: &backend-env
DEBUG: ${DEBUG:-1}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_HOST: db
POSTGRES_PORT: 5432
POSTGRES_DATABASE: ${POSTGRES_DATABASE:-coffee_project_db}
STORAGE_BACKEND: ${STORAGE_BACKEND:-S3}
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-coffee-bucket}
S3_ENDPOINT_URL_INTERNAL: http://minio:9000
S3_ENDPOINT_URL_PUBLIC: http://localhost:9000
REDIS_URL: redis://redis:6379/0
WEBHOOK_BASE_URL: http://api:8000
REMOTION_SERVICE_URL: ${REMOTION_SERVICE_URL:-http://remotion:3001}
services:
db:
container_name: cpv3_postgres
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DATABASE:-coffee_project_db}
ports:
- "127.0.0.1:5332:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-coffee_project_db}"]
interval: 5s
timeout: 3s
retries: 20
volumes:
- cpv3_db:/var/lib/postgresql/data
minio:
container_name: cpv3_minio
image: minio/minio:RELEASE.2024-11-07T00-52-20Z
restart: unless-stopped
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- cpv3_minio:/data
redis:
container_name: cpv3_redis
image: redis:7-alpine
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
- cpv3_redis:/data
api:
container_name: cpv3_api
<<: *backend-image
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
<<: *backend-env
ports:
- "127.0.0.1:8000:8000"
volumes:
- ./cpv3:/app/cpv3
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini
worker:
container_name: cpv3_worker
<<: *backend-image
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
<<: *backend-env
command: >
watchfiles --filter python 'dramatiq cpv3.modules.tasks.service --processes 1 --threads 2' /app/cpv3
volumes:
- ./cpv3:/app/cpv3
volumes:
cpv3_db:
cpv3_minio:
cpv3_redis:
```
Key changes in this file:
- `x-backend-env` anchor with all shared env vars (DRY)
- `JWT_SECRET_KEY` added to worker (was missing)
- `restart: unless-stopped` on all services
- All ports bound to `127.0.0.1` (not `0.0.0.0`)
- MinIO pinned to `RELEASE.2024-11-07T00-52-20Z`
- MinIO health check added (`curl` on `/minio/health/live`)
- Removed inline comments for cleanliness
- [ ] **Step 2: Validate compose syntax**
Run: `cd cofee_backend && docker compose config > /dev/null`
Expected: no errors.
- [ ] **Step 3: Test stack starts**
Run: `cd cofee_backend && docker compose up -d`
Wait 30s, then: `docker compose ps`
Expected: all services `Up` or `Up (healthy)`.
- [ ] **Step 4: Commit**
```bash
git add cofee_backend/docker-compose.yml
git commit -m "refactor(infra): DRY env vars, pin images, bind localhost, add restart policies"
```
---
### Task 4: Move build-essential out of base stage in backend Dockerfile
**Files:**
- Modify: `cofee_backend/Dockerfile`
`build-essential` is only needed during `uv sync` (compiling C extensions). Moving it from `base` to `deps` saves ~200MB in the prod image since the `prod` stage inherits from `deps` but the compiled artifacts are in `.venv`, not the system packages.
- [ ] **Step 1: Restructure Dockerfile stages**
Replace the entire `cofee_backend/Dockerfile` with:
```dockerfile
# syntax=docker/dockerfile:1.7
# ---------------------------------------------------------------------------
# Stage 1: base — minimal runtime dependencies (shared by dev and prod)
# ---------------------------------------------------------------------------
FROM python:3.11-slim AS base
COPY --from=ghcr.io/astral-sh/uv:0.8.15 /uv /uvx /bin/
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/app/.venv/bin:${PATH}"
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# ---------------------------------------------------------------------------
# Stage 2: deps — install Python dependencies (build-essential here only)
# ---------------------------------------------------------------------------
FROM base AS deps
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# ---------------------------------------------------------------------------
# Stage 3: dev — development target (used by docker-compose)
# ---------------------------------------------------------------------------
FROM deps AS dev
ENV PYTHONPATH=/app
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && uvicorn cpv3.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/cpv3"]
# ---------------------------------------------------------------------------
# Stage 4: prod — production target (no build-essential, non-root user)
# ---------------------------------------------------------------------------
FROM base AS prod
RUN groupadd --gid 1000 app && \
useradd --uid 1000 --gid app --create-home app
COPY --from=deps /app/.venv /app/.venv
COPY pyproject.toml uv.lock ./
ENV UV_LINK_MODE=copy
COPY cpv3 ./cpv3
COPY alembic ./alembic
COPY alembic.ini ./
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
RUN chown -R app:app /app
USER app
EXPOSE 8000
CMD ["sh", "-c", "alembic upgrade head && uvicorn cpv3.main:app --host 0.0.0.0 --port 8000"]
```
Key changes:
- `build-essential` moved from `base` to `deps` — prod image is ~200MB smaller
- `prod` stage inherits from `base` (not `deps`) — no compiler in production
- `prod` copies only `.venv` from `deps` stage — gets compiled packages without build tools
- Non-root `app` user (uid 1000) added to `prod` stage
- `dev` stage still inherits from `deps` (has build-essential for potential ad-hoc installs)
- [ ] **Step 2: Build and verify prod stage**
Run: `cd cofee_backend && docker build --target prod -t cpv3-backend:prod-test .`
Expected: builds successfully.
- [ ] **Step 3: Build and verify dev stage**
Run: `cd cofee_backend && docker build --target dev -t cpv3-backend:dev-test .`
Expected: builds successfully.
- [ ] **Step 4: Verify dev stack still works**
Run: `cd cofee_backend && docker compose up -d --build`
Wait 30s, then: `docker compose ps`
Expected: all services running.
- [ ] **Step 5: Commit**
```bash
git add cofee_backend/Dockerfile
git commit -m "perf(infra): move build-essential to deps stage, add non-root user to prod"
```
---
### Task 5: Add BuildKit cache mounts and non-root user to Remotion Dockerfile
**Files:**
- Modify: `remotion_service/Dockerfile`
- [ ] **Step 1: Update Remotion Dockerfile**
Replace the entire `remotion_service/Dockerfile` with:
```dockerfile
# syntax=docker/dockerfile:1.7-labs
FROM oven/bun:1.3.10 AS base
ENV APP_HOME=/app \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 \
REMOTION_PUPPETEER_NO_SANDBOX=1 \
NODE_ENV=production
WORKDIR ${APP_HOME}
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
ffmpeg \
chromium \
libglib2.0-0 \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libdrm2 \
libxkbcommon0 \
libgbm1 \
fonts-noto-color-emoji \
curl \
&& rm -rf /var/lib/apt/lists/*
FROM base AS deps
WORKDIR ${APP_HOME}
COPY package.json bun.lock ./
RUN NODE_ENV=development bun install --frozen-lockfile
FROM base AS runner
WORKDIR ${APP_HOME}
RUN groupadd --gid 1000 app && \
useradd --uid 1000 --gid app --create-home app
COPY --from=deps ${APP_HOME}/node_modules ./node_modules
COPY package.json bun.lock ./
COPY tsconfig.json remotion.config.ts ./
COPY public ./public
COPY src ./src
COPY server ./server
RUN mkdir -p out && chown -R app:app /app
USER app
EXPOSE 3001
CMD ["bun", "run", "server"]
```
Key changes:
- BuildKit apt cache mounts added (matches backend pattern)
- Non-root `app` user (uid 1000) in runner stage
- `chown` before `USER app` so the app owns all files including `out/`
- [ ] **Step 2: Build and verify**
Run: `cd remotion_service && docker build --target runner -t remotion:test .`
Expected: builds successfully.
- [ ] **Step 3: Commit**
```bash
git add remotion_service/Dockerfile
git commit -m "perf(infra): add BuildKit cache mounts and non-root user to Remotion Dockerfile"
```
---
### Task 6: Add resource limits and cap_drop to Remotion docker-compose
**Files:**
- Modify: `remotion_service/docker-compose.yml`
- [ ] **Step 1: Update Remotion docker-compose.yml**
Replace the entire `remotion_service/docker-compose.yml` with:
```yaml
services:
remotion:
build:
context: .
dockerfile: Dockerfile
target: runner
command: >
sh -lc "NODE_ENV=development bun install --frozen-lockfile && bun run server"
restart: unless-stopped
env_file: .env
environment:
S3_ENDPOINT_URL: http://minio:9000
REDIS_URL: redis://redis:6379/0
ports:
- "127.0.0.1:3001:3001"
deploy:
resources:
limits:
memory: 4g
cpus: "2"
reservations:
memory: 1g
cpus: "0.5"
cap_drop:
- ALL
cap_add:
- SYS_ADMIN
volumes:
- .:/app:cached
- remotion_node_modules:/app/node_modules
networks:
- backend
stdin_open: true
tty: true
volumes:
remotion_node_modules:
networks:
backend:
external: true
name: cofee_backend_default
```
Key changes:
- `restart: unless-stopped`
- Port bound to `127.0.0.1`
- Resource limits: 4GB memory / 2 CPUs (Chromium + FFmpeg need this)
- Resource reservations: 1GB / 0.5 CPU (scheduling guarantees)
- `cap_drop: ALL` + `cap_add: SYS_ADMIN` (SYS_ADMIN needed for Chromium sandbox)
- [ ] **Step 2: Validate compose syntax**
Run: `cd remotion_service && docker compose config > /dev/null`
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add remotion_service/docker-compose.yml
git commit -m "fix(infra): add resource limits, cap_drop, restart policy to Remotion compose"
```
---
### Task 7: Add resource limits and cap_drop to backend docker-compose
**Files:**
- Modify: `cofee_backend/docker-compose.yml`
- [ ] **Step 1: Add deploy and cap_drop sections to each service**
Add to the `db` service after `volumes`:
```yaml
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
```
Add to the `minio` service after `volumes`:
```yaml
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- FOWNER
- SETGID
- SETUID
```
Add to the `redis` service after `volumes`:
```yaml
cap_drop:
- ALL
```
Add to the `api` service after `volumes`:
```yaml
deploy:
resources:
limits:
memory: 512m
cpus: "1"
cap_drop:
- ALL
```
Add to the `worker` service after `volumes`:
```yaml
deploy:
resources:
limits:
memory: 1g
cpus: "1"
cap_drop:
- ALL
```
- [ ] **Step 2: Validate compose syntax**
Run: `cd cofee_backend && docker compose config > /dev/null`
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add cofee_backend/docker-compose.yml
git commit -m "fix(infra): add resource limits and capability dropping to backend compose"
```
---
### Task 8: Add health check endpoint to backend API
**Files:**
- Modify: `cofee_backend/cpv3/modules/system/router.py`
The existing `/api/ping/` only returns a static response. We need a `/api/health/` endpoint that checks DB and Redis connectivity for Docker health checks.
- [ ] **Step 1: Add health endpoint to system router**
Replace the contents of `cofee_backend/cpv3/modules/system/router.py` with:
```python
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from cpv3.db.session import get_db
from cpv3.infrastructure.settings import get_settings
router = APIRouter(prefix="/api", tags=["System"])
_settings = get_settings()
@router.get("/ping/")
async def ping() -> dict[str, str]:
return {"status": "ok"}
@router.get("/health/")
async def health(db: AsyncSession = Depends(get_db)) -> dict[str, str]:
"""Health check for Docker/K8s probes. Verifies DB connectivity."""
try:
await db.execute(text("SELECT 1"))
db_status = "connected"
except Exception:
db_status = "disconnected"
status = "ok" if db_status == "connected" else "degraded"
return {"status": status, "database": db_status}
```
- [ ] **Step 2: Run linter**
Run: `cd cofee_backend && uv run ruff check cpv3/modules/system/router.py`
Expected: no errors.
- [ ] **Step 3: Run existing tests**
Run: `cd cofee_backend && uv run pytest -x -q 2>&1 | tail -10`
Expected: all tests pass (health endpoint is additive, no breaking changes).
- [ ] **Step 4: Commit**
```bash
git add cofee_backend/cpv3/modules/system/router.py
git commit -m "feat(backend): add /api/health/ endpoint for Docker health checks"
```
---
### Task 9: Add health check endpoint to Remotion service
**Files:**
- Modify: `remotion_service/server/index.ts`
- [ ] **Step 1: Add /health endpoint before app.listen**
Add before the `app.listen(...)` line (around line 138) in `remotion_service/server/index.ts`:
```typescript
app.get("/health", async () => {
return { status: "ok" };
});
```
Note: This is outside the `/api` prefix since it's at the Elysia instance level. The endpoint will be available at `GET /api/health` because the Elysia instance has `prefix: "/api"`.
- [ ] **Step 2: Type check**
Run: `cd remotion_service && bunx tsc --noEmit`
Expected: no new errors.
- [ ] **Step 3: Commit**
```bash
git add remotion_service/server/index.ts
git commit -m "feat(remotion): add /api/health endpoint for Docker health checks"
```
---
### Task 10: Add health checks for api, worker, and remotion in compose files
**Files:**
- Modify: `cofee_backend/docker-compose.yml`
- Modify: `remotion_service/docker-compose.yml`
- [ ] **Step 1: Add healthcheck to api service**
Add to `api` service in `cofee_backend/docker-compose.yml` (after `depends_on`):
```yaml
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health/')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
```
- [ ] **Step 2: Add healthcheck to worker service**
The worker has no HTTP port. Use a process check. Add to `worker` service:
```yaml
healthcheck:
test: ["CMD-SHELL", "pgrep -f dramatiq || exit 1"]
interval: 15s
timeout: 5s
retries: 3
```
- [ ] **Step 3: Add healthcheck to remotion service**
Add to `remotion` service in `remotion_service/docker-compose.yml` (after `environment`):
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
```
- [ ] **Step 4: Validate both compose files**
Run: `cd cofee_backend && docker compose config > /dev/null && cd ../remotion_service && docker compose config > /dev/null`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add cofee_backend/docker-compose.yml remotion_service/docker-compose.yml
git commit -m "feat(infra): add health checks to api, worker, and remotion services"
```
---
### Task 11: Add network segmentation to backend compose
**Files:**
- Modify: `cofee_backend/docker-compose.yml`
Currently all services share one flat network. Separate into `db-net` (data stores) and `app-net` (application services). This prevents Remotion from reaching DB/Redis directly.
- [ ] **Step 1: Add networks to compose**
Add at the bottom of `cofee_backend/docker-compose.yml`, replacing the existing `volumes:` section:
```yaml
volumes:
cpv3_db:
cpv3_minio:
cpv3_redis:
networks:
db-net:
driver: bridge
app-net:
driver: bridge
```
- [ ] **Step 2: Add network assignments to each service**
Add to `db`:
```yaml
networks:
- db-net
```
Add to `redis`:
```yaml
networks:
- db-net
```
Add to `minio`:
```yaml
networks:
- db-net
- app-net
```
Add to `api`:
```yaml
networks:
- db-net
- app-net
```
Add to `worker`:
```yaml
networks:
- db-net
- app-net
```
- [ ] **Step 3: Update Remotion compose to use app-net**
In `remotion_service/docker-compose.yml`, change the networks section:
```yaml
networks:
backend:
external: true
name: cofee_backend_app-net
```
This ensures Remotion can reach MinIO and API (on `app-net`) but NOT PostgreSQL or Redis (on `db-net`).
- [ ] **Step 4: Validate both compose files**
Run: `cd cofee_backend && docker compose config > /dev/null && cd ../remotion_service && docker compose config > /dev/null`
Expected: no errors.
- [ ] **Step 5: Test full stack connectivity**
Run:
```bash
cd cofee_backend && docker compose down && docker compose up -d
# Wait for healthy
cd ../remotion_service && docker compose down && docker compose up -d
```
Verify API can reach DB, Redis, MinIO. Verify Remotion can reach MinIO but NOT DB.
- [ ] **Step 6: Commit**
```bash
git add cofee_backend/docker-compose.yml remotion_service/docker-compose.yml
git commit -m "feat(infra): add network segmentation — db-net and app-net isolation"
```
---
### Task 12: Final verification
- [ ] **Step 1: Bring down everything**
```bash
cd cofee_backend && docker compose down
cd ../remotion_service && docker compose down
```
- [ ] **Step 2: Clean build**
```bash
cd cofee_backend && docker compose build --no-cache
cd ../remotion_service && docker compose build --no-cache
```
- [ ] **Step 3: Start backend stack**
```bash
cd cofee_backend && docker compose up -d
```
Wait for: `docker compose ps` shows all services healthy.
- [ ] **Step 4: Start Remotion stack**
```bash
cd remotion_service && docker compose up -d
```
Wait for: `docker compose ps` shows remotion healthy.
- [ ] **Step 5: Test API health**
Run: `curl http://127.0.0.1:8000/api/health/`
Expected: `{"status":"ok","database":"connected"}`
- [ ] **Step 6: Test Remotion health**
Run: `curl http://127.0.0.1:3001/api/health`
Expected: `{"status":"ok"}`
- [ ] **Step 7: Verify port binding**
Run: `docker compose -f cofee_backend/docker-compose.yml ps --format '{{.Name}} {{.Ports}}'`
Expected: all ports show `127.0.0.1:XXXX->YYYY/tcp` (not `0.0.0.0`).
- [ ] **Step 8: Verify resource limits**
Run: `docker inspect cpv3_api --format '{{.HostConfig.Memory}}'`
Expected: `536870912` (512MB).
Run: `docker inspect remotion --format '{{.HostConfig.Memory}}'`
Expected: `4294967296` (4GB).
@@ -0,0 +1,478 @@
# Subtitle Revision Workspace Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Redesign the subtitle-revision screen into a more cohesive editorial workspace while staying inside the current frontend design system.
**Architecture:** Keep the existing component boundaries and logic intact, then improve hierarchy through coordinated shell styling and small presentation-only markup changes. The work is isolated to the shared stepper chrome, the subtitle-revision step layout, the transcription editor surface, and the timeline dock so the redesign remains low-risk and easy to verify.
**Tech Stack:** Next.js 16, React, TypeScript, SCSS Modules, Vidstack, Lucide, Chrome DevTools MCP
---
## File Structure
- Modify: `cofee_frontend/src/shared/ui/Stepper/Stepper.module.scss`
Purpose: Reduce stepper visual dominance and align it with the calmer workspace shell.
- Modify: `cofee_frontend/src/widgets/ProjectWizard/ProjectWizard.module.scss`
Purpose: Introduce softer page-level spacing/canvas treatment around the active step content.
- Modify: `cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx`
Purpose: Add minimal presentational structure for player/editor panel headers and shell grouping.
- Modify: `cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.module.scss`
Purpose: Build the unified editorial workspace shell and responsive balanced split behavior.
- Modify: `cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx`
Purpose: Add small semantic wrappers for a stronger editor header and cleaner segment metadata grouping.
- Modify: `cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss`
Purpose: Redesign the editor surface, segment cards, and add-segment action within current tokens.
- Modify: `cofee_frontend/src/widgets/TimelinePanel/TimelinePanel.module.scss`
Purpose: Make the timeline feel like a docked rail within the same workspace shell.
## Task 1: Soften the Stepper and Page Canvas
**Files:**
- Modify: `cofee_frontend/src/shared/ui/Stepper/Stepper.module.scss`
- Modify: `cofee_frontend/src/widgets/ProjectWizard/ProjectWizard.module.scss`
- Test: `cd cofee_frontend && bunx tsc --noEmit`
- [ ] **Step 1: Inspect the current stepper and wizard shell before editing**
Run:
```bash
sed -n '1,220p' cofee_frontend/src/shared/ui/Stepper/Stepper.module.scss
sed -n '1,220p' cofee_frontend/src/widgets/ProjectWizard/ProjectWizard.module.scss
```
Expected: confirm the current stepper uses a saturated active pill and the wizard root is mostly structural with minimal page-level styling.
- [ ] **Step 2: Update the stepper to feel quieter and more integrated**
Apply changes in `cofee_frontend/src/shared/ui/Stepper/Stepper.module.scss` so the active step is calmer and the bar reads as context instead of a hero element.
Use this shape for the key selectors:
```scss
.root {
position: relative;
background: linear-gradient(180deg, variables.$bg-default 0%, variables.$bg-surface 100%);
border-bottom: 1px solid variables.$border-subtle;
}
.scrollContainer {
gap: 10px;
padding: 18px 28px 14px;
}
.step {
padding: 8px 14px 8px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
border: 1px solid transparent;
}
.stepActive {
background: variables.$bg-surface;
border-color: rgba(139, 92, 246, 0.16);
box-shadow: 0 10px 24px rgba(24, 24, 27, 0.06);
}
.stepCompleted {
background: rgba(255, 255, 255, 0.28);
}
```
- [ ] **Step 3: Give the wizard a softer canvas around the active step**
Update `cofee_frontend/src/widgets/ProjectWizard/ProjectWizard.module.scss` so the content area gets breathing room without changing behavior.
Use this shape:
```scss
.root {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height));
overflow: hidden;
background: linear-gradient(180deg, variables.$bg-default 0%, rgba(255, 255, 255, 0.55) 100%);
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
padding: 18px 24px 24px;
}
```
- [ ] **Step 4: Run the frontend type-check after the shell changes**
Run:
```bash
cd cofee_frontend && bunx tsc --noEmit
```
Expected: exit code `0`.
- [ ] **Step 5: Commit the shell changes**
```bash
git add cofee_frontend/src/shared/ui/Stepper/Stepper.module.scss cofee_frontend/src/widgets/ProjectWizard/ProjectWizard.module.scss
git commit -m "feat: refine project wizard shell"
```
## Task 2: Build the Subtitle Revision Workspace Shell
**Files:**
- Modify: `cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx`
- Modify: `cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.module.scss`
- Test: `cd cofee_frontend && bunx tsc --noEmit`
- [ ] **Step 1: Inspect the current subtitle-revision markup and styles**
Run:
```bash
sed -n '1,260p' cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx
sed -n '1,260p' cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.module.scss
```
Expected: confirm the current main grid, timeline, and footer are separate blocks with minimal shared shell styling.
- [ ] **Step 2: Add panel headers and a single workspace shell in the TSX**
Update `cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx` so the player and editor live inside named panels.
Use this structure inside the `MediaPlayer` content:
```tsx
<div className={styles.workspaceShell}>
<div className={styles.mainGrid}>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<div>
<p className={styles.eyebrow}>Просмотр</p>
<h3 className={styles.panelTitle}>Видео проекта</h3>
</div>
</div>
<div className={styles.playerColumn}>...</div>
</section>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<div>
<p className={styles.eyebrow}>Редактор</p>
<h3 className={styles.panelTitle}>Транскрипция</h3>
</div>
</div>
<div className={styles.editorColumn}>...</div>
</section>
</div>
<div className={styles.timelineWrapper}>...</div>
<div className={styles.footer}>...</div>
</div>
```
- [ ] **Step 3: Style the workspace shell, balanced split, and responsive stack**
Update `cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.module.scss` with a single rounded shell, two equal panels, and a docked lower rail.
Use this shape for the key selectors:
```scss
.workspaceShell {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid rgba(24, 24, 27, 0.08);
border-radius: variables.$radius-lg;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.72) 0%, variables.$bg-surface 100%);
box-shadow: 0 18px 48px rgba(24, 24, 27, 0.08);
overflow: hidden;
}
.mainGrid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 18px;
padding: 20px;
flex: 1;
min-height: 0;
}
.panel {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid rgba(24, 24, 27, 0.08);
border-radius: variables.$radius-lg;
background: rgba(255, 255, 255, 0.58);
overflow: hidden;
}
```
Add responsive collapse:
```scss
@media (max-width: 1024px) {
.mainGrid {
grid-template-columns: 1fr;
}
}
```
- [ ] **Step 4: Verify the layout still type-checks**
Run:
```bash
cd cofee_frontend && bunx tsc --noEmit
```
Expected: exit code `0`.
- [ ] **Step 5: Commit the workspace shell changes**
```bash
git add cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx cofee_frontend/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.module.scss
git commit -m "feat: redesign subtitle revision workspace shell"
```
## Task 3: Redesign the Transcription Editor Surface
**Files:**
- Modify: `cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx`
- Modify: `cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss`
- Test: `cd cofee_frontend && bunx tsc --noEmit`
- [ ] **Step 1: Inspect the current transcription editor structure**
Run:
```bash
sed -n '1,320p' cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx
sed -n '1,320p' cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss
```
Expected: confirm the current editor has a plain header, dense segment rows, and a dashed add button.
- [ ] **Step 2: Add semantic wrappers for a stronger header and cleaner metadata row**
Update `cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx` with small presentation-only wrappers.
Use this shape:
```tsx
<div className={styles.headerMeta}>
<p className={styles.kicker}>Редактура</p>
<h3 className={styles.title}>Редактор транскрипции</h3>
</div>
<div className={styles.segmentMetaRow}>
<div className={styles.timesGroup}>...</div>
<div className={styles.actionsGroup}>...</div>
</div>
```
For each timing field, wrap the label and input in a chip-like container:
```tsx
<label className={styles.timeChip}>
<span className={styles.timeLabelText}>Начало</span>
<input className={styles.timeInput} ... />
</label>
```
- [ ] **Step 3: Rework the editor styling into a calmer editorial surface**
Update `cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss` so the editor looks less like a raw form and more like a reading/editing workspace.
Use this shape for the key selectors:
```scss
.root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: transparent;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 14px;
border-bottom: 1px solid rgba(24, 24, 27, 0.08);
background: rgba(255, 255, 255, 0.68);
}
.segment {
border: 1px solid rgba(24, 24, 27, 0.08);
border-radius: variables.$radius-lg;
padding: 14px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 8px 24px rgba(24, 24, 27, 0.04);
}
.timeChip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: variables.$bg-hover;
border: 1px solid transparent;
}
.textArea {
padding: 14px 16px;
border-radius: variables.$radius-md;
line-height: 1.65;
background: rgba(244, 244, 245, 0.92);
}
```
Replace the dashed add button treatment with a quieter inset surface:
```scss
.addButton {
margin: 0 20px 18px;
padding: 12px 14px;
border: 1px solid rgba(24, 24, 27, 0.08);
border-radius: variables.$radius-md;
background: rgba(255, 255, 255, 0.6);
}
```
- [ ] **Step 4: Run the frontend type-check after the editor redesign**
Run:
```bash
cd cofee_frontend && bunx tsc --noEmit
```
Expected: exit code `0`.
- [ ] **Step 5: Commit the editor redesign**
```bash
git add cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.tsx cofee_frontend/src/features/project/TranscriptionEditor/TranscriptionEditor.module.scss
git commit -m "feat: refine transcription editor presentation"
```
## Task 4: Dock the Timeline and Verify in Chrome
**Files:**
- Modify: `cofee_frontend/src/widgets/TimelinePanel/TimelinePanel.module.scss`
- Test: `cd cofee_frontend && bunx tsc --noEmit`
- Verify: Chrome at `http://localhost:3000/projects/83eb1396-8217-4ceb-ae32-b3b63cd01982`
- [ ] **Step 1: Inspect the current timeline chrome**
Run:
```bash
sed -n '1,260p' cofee_frontend/src/widgets/TimelinePanel/TimelinePanel.module.scss
```
Expected: confirm the toolbar and label column are functional but visually flatter and less integrated with the workspace shell.
- [ ] **Step 2: Update the timeline dock styling to match the workspace**
Modify `cofee_frontend/src/widgets/TimelinePanel/TimelinePanel.module.scss` so the toolbar, labels column, and scroll area feel like a lower editing rail.
Use this shape:
```scss
.root {
display: flex;
flex-direction: column;
align-self: stretch;
height: 100%;
min-width: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.56);
}
.toolbar {
height: 40px;
padding: 0 14px;
border-bottom: 1px solid rgba(24, 24, 27, 0.08);
background: rgba(255, 255, 255, 0.72);
}
.labelsColumn {
width: 68px;
background: rgba(255, 255, 255, 0.48);
}
.zoomBtn {
width: 28px;
height: 28px;
border-radius: 999px;
}
```
- [ ] **Step 3: Run the frontend type-check before browser verification**
Run:
```bash
cd cofee_frontend && bunx tsc --noEmit
```
Expected: exit code `0`.
- [ ] **Step 4: Verify the redesigned screen in Chrome**
Check the route:
```text
http://localhost:3000/projects/83eb1396-8217-4ceb-ae32-b3b63cd01982
```
Verify all of the following:
- the stepper is still readable but less dominant
- the player and editor read as one shell
- the desktop split still feels balanced
- the transcription cards are calmer and easier to scan
- the timeline feels docked to the workspace
- the footer stays visually anchored
- the layout still holds together at a narrower viewport
- [ ] **Step 5: Commit the timeline and verification-backed finish**
```bash
git add cofee_frontend/src/widgets/TimelinePanel/TimelinePanel.module.scss
git commit -m "feat: align timeline dock with subtitle workspace"
```
## Self-Review
### Spec Coverage
- Quieter stepper: covered by Task 1
- Single workspace shell: covered by Task 2
- Stronger transcription editor hierarchy: covered by Task 3
- Docked timeline integration: covered by Task 4
- Responsive balanced split: covered by Task 2 and Task 4 browser verification
- Design-system constraint: enforced in every task by reusing existing tokens and limiting scope to SCSS/module presentation
### Placeholder Scan
- No `TODO`, `TBD`, or deferred implementation notes remain
- Each task lists exact files and commands
- Each styling task includes concrete selector/code shapes instead of abstract guidance
### Type Consistency
- `workspaceShell`, `panel`, `panelHeader`, `eyebrow`, and `panelTitle` are introduced in the step component only
- `headerMeta`, `kicker`, `segmentMetaRow`, and `timeChip` are introduced in the editor only
- No new logic APIs or renamed behavioral props are required
@@ -0,0 +1,687 @@
# Subtitle Preset Grid Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Redesign preset preview cards to match uploaded video aspect ratio with modern visual refresh and style characteristics display
**Architecture:** Fetch video metadata to calculate aspect ratio, pass to preset cards via props. Update StylePreview for dynamic sizing. Add loading skeleton state. Use Catppuccin Mocha color palette matching the project theme.
**Tech Stack:** React, TypeScript, SCSS Modules, TanStack Query, openapi-react-query
**Design Spec:** `docs/superpowers/specs/2026-04-06-subtitle-preset-grid-redesign.md`
---
## File Structure
| File | Purpose |
|------|---------|
| `src/features/project/CaptionSettingsStep/useVideoMetadata.ts` | New hook to fetch video metadata and calculate aspect ratio |
| `src/features/project/CaptionSettingsStep/PresetGrid.tsx` | Modified - adds aspect ratio fetching, loading state, passes ratio to cards |
| `src/features/project/CaptionSettingsStep/PresetGrid.module.scss` | Modified - grid layout, responsive styles |
| `src/features/project/CaptionSettingsStep/PresetCard.tsx` | Modified - adds style characteristics display, checkmark indicator, updated styling |
| `src/features/project/CaptionSettingsStep/PresetCard.module.scss` | Modified - new card design with Catppuccin Mocha colors |
| `src/features/project/CaptionSettingsStep/StylePreview.tsx` | Modified - accepts aspectRatio prop for dynamic sizing |
| `src/features/project/CaptionSettingsStep/StylePreview.module.scss` | Modified - dynamic aspect-ratio container |
| `src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx` | New - skeleton loading component for preset cards |
| `src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss` | New - skeleton styles with shimmer animation |
---
## Task 1: Create useVideoMetadata Hook
**Files:**
- Create: `src/features/project/CaptionSettingsStep/useVideoMetadata.ts`
**Context:** This hook fetches video metadata from the API and calculates the aspect ratio. It uses the existing `api` from `@shared/api` which is openapi-react-query.
- [ ] **Step 1: Write the hook implementation**
```typescript
import { useMemo } from "react"
import api from "@shared/api"
interface UseVideoMetadataResult {
aspectRatio: number
isLoading: boolean
isError: boolean
}
const DEFAULT_ASPECT_RATIO = 16 / 9
export function useVideoMetadata(fileId: string | null): UseVideoMetadataResult {
const { data: mediaFile, isLoading, isError } = api.useQuery(
"get",
"/api/media/mediafiles/{media_file_id}/",
{
params: {
path: {
media_file_id: fileId ?? "",
},
},
},
{
enabled: !!fileId,
}
)
const aspectRatio = useMemo(() => {
if (!mediaFile?.width || !mediaFile?.height) {
return DEFAULT_ASPECT_RATIO
}
return mediaFile.width / mediaFile.height
}, [mediaFile])
return {
aspectRatio,
isLoading,
isError,
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/features/project/CaptionSettingsStep/useVideoMetadata.ts
git commit -m "feat: add useVideoMetadata hook for aspect ratio calculation"
```
---
## Task 2: Create PresetCardSkeleton Component
**Files:**
- Create: `src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx`
- Create: `src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss`
- [ ] **Step 1: Write the SCSS module**
```scss
// PresetCardSkeleton.module.scss
.skeletonCard {
border-radius: 12px;
overflow: hidden;
background: var(--bg-default);
border: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
}
.skeletonPreview {
aspect-ratio: 16 / 9;
background: var(--bg-surface);
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(203, 166, 247, 0.08) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.skeletonFooter {
padding: 14px 16px;
background: linear-gradient(to top, var(--bg-surface), var(--bg-default));
border-top: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 10px;
}
.skeletonLine {
height: 14px;
background: var(--bg-hover);
border-radius: 4px;
width: 60%;
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(203, 166, 247, 0.06) 50%,
transparent 100%
);
animation: shimmer 1.5s infinite;
}
}
.skeletonLineShort {
composes: skeletonLine;
width: 40%;
height: 10px;
}
```
- [ ] **Step 2: Write the component**
```typescript
// PresetCardSkeleton.tsx
import type { FunctionComponent } from "react"
import type { JSX } from "react"
import styles from "./PresetCardSkeleton.module.scss"
interface IPresetCardSkeletonProps {
aspectRatio?: number
}
export const PresetCardSkeleton: FunctionComponent<IPresetCardSkeletonProps> = ({
aspectRatio = 16 / 9,
}): JSX.Element => {
return (
<div className={styles.skeletonCard}>
<div
className={styles.skeletonPreview}
style={{ aspectRatio }}
/>
<div className={styles.skeletonFooter}>
<div className={styles.skeletonLine} />
<div className={styles.skeletonLineShort} />
</div>
</div>
)
}
```
- [ ] **Step 3: Add barrel export**
Add to `src/features/project/CaptionSettingsStep/index.ts`:
```typescript
export { PresetCardSkeleton } from "./PresetCardSkeleton"
```
- [ ] **Step 4: Commit**
```bash
git add src/features/project/CaptionSettingsStep/PresetCardSkeleton.tsx
git add src/features/project/CaptionSettingsStep/PresetCardSkeleton.module.scss
git add src/features/project/CaptionSettingsStep/index.ts
git commit -m "feat: add PresetCardSkeleton component with shimmer animation"
```
---
## Task 3: Update StylePreview for Dynamic Aspect Ratio
**Files:**
- Modify: `src/features/project/CaptionSettingsStep/StylePreview.tsx`
- Modify: `src/features/project/CaptionSettingsStep/StylePreview.module.scss`
- [ ] **Step 1: Read existing StylePreview files**
```bash
cat src/features/project/CaptionSettingsStep/StylePreview.tsx
cat src/features/project/CaptionSettingsStep/StylePreview.module.scss
```
- [ ] **Step 2: Update StylePreview.module.scss**
Add or modify the preview container to accept dynamic aspect-ratio:
```scss
// Add to existing StylePreview.module.scss
.previewContainer {
position: relative;
width: 100%;
overflow: hidden;
background: #0c0a1a;
display: flex;
align-items: center;
justify-content: center;
}
```
- [ ] **Step 3: Update StylePreview.tsx**
Add `aspectRatio` prop and apply it to the container:
```typescript
// Add to existing imports
import type { CSSProperties } from "react"
// Update interface to include aspectRatio
interface IStylePreviewProps {
preset: CaptionPresetRead
aspectRatio?: number
className?: string
}
// In component, apply aspect ratio
export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
preset,
aspectRatio = 9 / 16, // Default to vertical (original behavior)
className,
}): JSX.Element => {
// ... existing logic ...
const containerStyle: CSSProperties = {
aspectRatio: String(aspectRatio),
}
return (
<div
className={cs(styles.previewContainer, className)}
style={containerStyle}
>
{/* ... existing preview content ... */}
</div>
)
}
```
- [ ] **Step 4: Commit**
```bash
git add src/features/project/CaptionSettingsStep/StylePreview.tsx
git add src/features/project/CaptionSettingsStep/StylePreview.module.scss
git commit -m "feat: add aspectRatio prop to StylePreview for dynamic sizing"
```
---
## Task 4: Update PresetCard with New Design
**Files:**
- Modify: `src/features/project/CaptionSettingsStep/PresetCard.tsx`
- Modify: `src/features/project/CaptionSettingsStep/PresetCard.module.scss`
- [ ] **Step 1: Read existing PresetCard files**
```bash
cat src/features/project/CaptionSettingsStep/PresetCard.tsx
cat src/features/project/CaptionSettingsStep/PresetCard.module.scss
```
- [ ] **Step 2: Rewrite PresetCard.module.scss with new design**
```scss
// PresetCard.module.scss
.presetCard {
position: relative;
border-radius: 12px;
overflow: hidden;
background: var(--bg-default);
border: 1px solid var(--border-subtle);
cursor: pointer;
transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
display: flex;
flex-direction: column;
&:hover {
border-color: var(--purple-400);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
}
.selected {
border-color: var(--purple-400);
box-shadow:
0 0 0 1px var(--purple-400),
0 0 20px rgba(203, 166, 247, 0.25),
var(--shadow-lg);
&::before {
content: "";
position: absolute;
inset: -1px;
border-radius: 12px;
padding: 1px;
background: linear-gradient(135deg, var(--purple-400), var(--purple-600));
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
}
.previewArea {
position: relative;
overflow: hidden;
}
.selectedIndicator {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background: var(--purple-400);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(203, 166, 247, 0.4);
svg {
width: 12px;
height: 12px;
color: var(--bg-canvas);
}
}
.cardFooter {
padding: 14px 16px;
background: linear-gradient(to top, var(--bg-surface), var(--bg-default));
border-top: 1px solid var(--border-subtle);
}
.presetName {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.systemBadge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 8px;
background: var(--purple-100);
color: var(--purple-400);
border-radius: 4px;
}
.styleChars {
font-size: 12px;
color: var(--text-tertiary);
display: flex;
align-items: center;
gap: 8px;
}
.colorDot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.divider {
color: var(--border-default);
}
```
- [ ] **Step 3: Update PresetCard.tsx with style characteristics**
```typescript
// Add helper to extract style characteristics
function getStyleCharacteristics(preset: CaptionPresetRead): {
fontFamily: string
accentColor: string | null
accentName: string | null
} {
const style = preset.style_config
const fontFamily = style?.text?.font_family ?? "Inter"
// Extract accent color from highlight or text color
const highlightColor = style?.highlight?.color
const textColor = style?.text?.color
// Simple color name mapping (expand as needed)
const colorMap: Record<string, string> = {
"#FFD700": "Золотой",
"#00ffff": "Неоновый",
"#ffffff": "Белый",
"#ff006e": "Розовый",
"#cba6f7": "Пурпурный",
"#f38ba8": "Розовый",
"#a6e3a1": "Зеленый",
"#f9e2af": "Желтый",
"#89dceb": "Голубой",
}
const accentColor = highlightColor || textColor
const accentName = accentColor ? (colorMap[accentColor] ?? null) : null
return {
fontFamily,
accentColor,
accentName,
}
}
// Update component to render characteristics
export const PresetCard: FunctionComponent<IPresetCardProps> = ({
preset,
isSelected,
aspectRatio,
onSelect,
onEdit,
onDelete,
}): JSX.Element => {
const { fontFamily, accentColor, accentName } = getStyleCharacteristics(preset)
return (
<div
className={cs(styles.presetCard, { [styles.selected]: isSelected })}
onClick={onSelect}
>
<div className={styles.previewArea}>
<StylePreview preset={preset} aspectRatio={aspectRatio} />
{isSelected && (
<div className={styles.selectedIndicator}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
)}
</div>
<div className={styles.cardFooter}>
<div className={styles.presetName}>
{preset.name}
{preset.is_system && <span className={styles.systemBadge}>Системный</span>}
</div>
<div className={styles.styleChars}>
{fontFamily}
{accentColor && accentName && (
<>
<span className={styles.divider}>·</span>
<span
className={styles.colorDot}
style={{ background: accentColor }}
/>
<span style={{ color: accentColor }}>{accentName}</span>
</>
)}
</div>
</div>
{/* Context menu for edit/delete - preserve existing */}
</div>
)
}
```
- [ ] **Step 4: Commit**
```bash
git add src/features/project/CaptionSettingsStep/PresetCard.tsx
git add src/features/project/CaptionSettingsStep/PresetCard.module.scss
git commit -m "feat: redesign PresetCard with style characteristics and checkmark indicator"
```
---
## Task 5: Update PresetGrid with Aspect Ratio and Loading State
**Files:**
- Modify: `src/features/project/CaptionSettingsStep/PresetGrid.tsx`
- Modify: `src/features/project/CaptionSettingsStep/PresetGrid.module.scss`
- [ ] **Step 1: Read existing PresetGrid files**
```bash
cat src/features/project/CaptionSettingsStep/PresetGrid.tsx
cat src/features/project/CaptionSettingsStep/PresetGrid.module.scss
```
- [ ] **Step 2: Update PresetGrid.module.scss**
```scss
// PresetGrid.module.scss
.presetGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
// Optional: Add fade-in animation for cards
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cardWrapper {
animation: fadeInUp 0.3s ease forwards;
// Staggered animation delay
@for $i from 1 through 10 {
&:nth-child(#{$i}) {
animation-delay: #{$i * 50}ms;
}
}
}
```
- [ ] **Step 3: Update PresetGrid.tsx**
```typescript
// Add imports
import { useVideoMetadata } from "./useVideoMetadata"
import { PresetCardSkeleton } from "./PresetCardSkeleton"
import { useWizard } from "../WizardContext"
// In component
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
presets,
selectedPresetId,
onSelect,
onEdit,
onDelete,
onCreate,
}): JSX.Element => {
const { primaryFileId } = useWizard()
const { aspectRatio, isLoading: isLoadingMetadata } = useVideoMetadata(primaryFileId)
if (isLoadingMetadata) {
return (
<div className={styles.presetGrid}>
{Array.from({ length: 4 }).map((_, i) => (
<PresetCardSkeleton key={i} aspectRatio={aspectRatio} />
))}
</div>
)
}
return (
<div className={styles.presetGrid}>
{presets.map((preset, index) => (
<div
key={preset.id}
className={styles.cardWrapper}
style={{ animationDelay: `${index * 50}ms` }}
>
<PresetCard
preset={preset}
isSelected={preset.id === selectedPresetId}
aspectRatio={aspectRatio}
onSelect={() => onSelect(preset.id)}
onEdit={() => onEdit(preset.id)}
onDelete={() => onDelete(preset.id)}
/>
</div>
))}
{/* Create new card - preserve existing */}
</div>
)
}
```
- [ ] **Step 4: Commit**
```bash
git add src/features/project/CaptionSettingsStep/PresetGrid.tsx
git add src/features/project/CaptionSettingsStep/PresetGrid.module.scss
git commit -m "feat: add aspect ratio and loading state to PresetGrid"
```
---
## Task 6: Type Check and Verify
- [ ] **Step 1: Run type check**
```bash
cd cofee_frontend && bunx tsc --noEmit
```
Expected: No TypeScript errors
- [ ] **Step 2: Run lint check**
```bash
cd cofee_frontend && bun run lint 2>/dev/null || echo "Lint not configured, using type check only"
```
- [ ] **Step 3: Final commit**
```bash
git add .
git commit -m "feat: complete subtitle preset grid redesign with dynamic aspect ratio"
```
---
## Verification Checklist
- [ ] Preset cards display with correct aspect ratio based on uploaded video
- [ ] Loading state shows skeleton cards with shimmer animation
- [ ] Style characteristics (font, color) visible on card footers
- [ ] Selected card shows checkmark indicator and purple glow border
- [ ] Grid is responsive (2 columns on mobile, more on desktop)
- [ ] Hover effects work smoothly
- [ ] Falls back to 16:9 when no video is available
- [ ] All existing functionality preserved (select, edit, delete, create)