Browse, search, save and export colour palettes. Palette is a solo portfolio project built to a production bar: httpOnly-cookie auth with CSRF, an async FastAPI backend on PostgreSQL, Redis-backed rate limiting, encrypted secrets, and continuous delivery to a live server.
Frontend → Fetch API → FastAPI Backend → PostgreSQL Database
Live demo: palettes-app.com — deployed on an Oracle Cloud VM behind
Cloudflare + Caddy, auto-deployed on every green build to main. Full release history in
CHANGELOG.md.
| Home — the community feed | Admin — colour-row editor | Export — PNG preview |
|---|---|---|
![]() |
![]() |
![]() |
Full diagrams (ER, request path, auth flow) in docs/architecture.md.
erDiagram
USERS ||--o{ FAVORITES : "saves"
PALETTES ||--o{ FAVORITES : "saved in"
USERS ||--o{ REFRESH_TOKENS : "owns"
PALETTES }o..o{ TAGS : "referenced by name (JSONB)"
USERS {
int id PK
string username UK
string email UK
string password_hash "Argon2id"
bool is_admin
int token_version "revokes issued access tokens"
bool email_verified
}
PALETTES {
int id PK
string slug UK
string name
jsonb colors "list of HEX"
jsonb tags "GIN-indexed"
}
TAGS {
int id PK
string name UK
string kind "free | purpose"
}
FAVORITES {
int id PK
int user_id FK
int palette_id FK
}
REFRESH_TOKENS {
int id PK
int user_id FK
string token_hash UK
bool revoked
}
flowchart LR
U["Browser"] -->|HTTPS| CF["Cloudflare"]
CF --> CA["Caddy (TLS, reverse proxy)"]
CA -->|"/*"| FE["nginx — static frontend + CSP"]
CA -->|"/api/*"| BE["FastAPI backend (async)"]
BE --> DB[("PostgreSQL")]
BE --> RD[("Redis — rate limiting")]
BE -. "errors (if DSN)" .-> SN["Sentry"]
U -. "errors + Web Vitals (if DSN)" .-> SN
- Cookie-based auth, done properly — JWT access + rotating refresh tokens in httpOnly/Secure cookies, double-submit CSRF on mutations, Argon2id hashing, server-side token revocation, and a login path that costs the same whether or not the account exists. Login by username or email.
- Async request path — FastAPI with async SQLAlchemy 2.0 (asyncpg) on PostgreSQL 16;
colorsandtagsstored as JSONB with a GIN index for fast tag filtering. - Hardened by default — Redis-backed rate limiting on every mutating endpoint, strict
Content-Security-Policy and security headers, a CORS allowlist (never
*), RFC 7807 errors, and a versioned, paginated/api/v1. - Secrets encrypted in git — SOPS + age; the plaintext
.envis never committed and secrets are decrypted only on the deploy host. - Continuous delivery — a green CI run on
mainauto-deploys the exact commit CI validated, taking a database backup first because migrations apply on startup; isolated staging stack with its own secrets via a Compose override. - Operable in production — liveness/readiness probes, optional Sentry error tracking on both the backend and the browser frontend (client errors + Web Vitals), and a scripted database backup with retention.
- Tested and linted — an async pytest suite with ruff, mypy and an 80% coverage gate; on the frontend, Vitest with a coverage gate, Playwright end-to-end and axe accessibility specs, screenshot baselines compared at zero tolerance, a performance budget, and an integration suite driving the real stack. All enforced in CI, alongside CodeQL analysis of this repository's own code and dependency audits on both ecosystems.
| Layer | Stack |
|---|---|
| Backend | FastAPI · async SQLAlchemy 2.0 (asyncpg) · Pydantic v2 · Alembic · Argon2 · slowapi |
| Data | PostgreSQL 16 (JSONB + GIN) · Redis |
| Frontend | Vite · React 19 · TypeScript · React Router · TanStack Query |
| Infra | Docker Compose · Caddy · Cloudflare · Oracle Cloud VM · SOPS + age |
| Styling | vanilla-extract (typed, zero-runtime CSS-in-TS) |
| Quality / CI | GitHub Actions · CodeQL · ruff · mypy · pytest · Vitest · Playwright + axe · Lighthouse CI · Sentry |
- JWT access + rotating refresh tokens in httpOnly cookies with server-side revocation, plus double-submit CSRF on mutating requests.
- Argon2id password hashing with pinned cost parameters (legacy PBKDF2 upgraded on next login), run off the event loop; timing-safe secret comparison; and a constant-cost login path, so response time does not reveal which accounts exist.
- Login by username or email; email verification with a verify-link auto-login; password reset by email; self-service account deletion.
- Redis-backed rate limiting (shared across instances) on auth and every mutating endpoint.
- Security headers on both halves: nginx sets a strict CSP and the usual family for the
SPA, and the API sets
nosniff,DENY, a referrer policy and adefault-src 'none'CSP on every response, error responses included. HSTS is sent only over https, so it cannot pin a browser during local development. ALLOWED_HOSTSrejects requests for a Host the API does not answer for; explicit CORS allowlist; mandatorySECRET_KEY(fail-fast at startup).- Secrets encrypted in git via SOPS + age.
- Versioned API under
/api/v1; public palette API, auth API, favorites API, tag catalog. - Paginated palette list (
{ items, total, limit, offset }+X-Total-Count), with SQL-side search, tag filtering, exact color counts (1–8) and sorting;colors/tagsas JSONB with a GIN index ontags. - RFC 7807
application/problem+jsonerror responses; Pydantic v2 validation. - Alembic migrations (safe adoption of a pre-Alembic database on startup); typed configuration via
pydantic-settings; automatic seeding and first-admin creation from.env. - Swagger UI at
/api/docs(off by default; enable withENABLE_API_DOCS=true).
- Liveness (
/health) and readiness (/health/ready, checks database + Redis) probes; the Compose healthcheck uses readiness. - Continuous delivery: production auto-deploys the commit CI validated, after a database backup;
isolated staging stack with its own encrypted secrets via a Compose override (see
docs/deploy.md). - Optional Sentry error tracking — backend (off unless
SENTRY_DSNis set) and frontend (client errors + Web Vitals, off unlessVITE_SENTRY_DSNis set); scripted database backups with retention (seedocs/ops.md). - Structured logging (
LOG_LEVEL); automated tests with ruff, mypy and an 80% coverage gate in CI, pluspip-auditandnpm auditon every run and weekly Dependabot updates.
- React Router client-side navigation with a sliding tab indicator that measures and follows the active link.
- Search by name, description, slug and tags; combine tags, exact color counts (1–8) and sorting.
- Color-first palette cards with per-color copy, Copy all, linked tags and account-only Save.
- Guests who press Save return to their previous page after login; only that requested palette is saved. Registration preserves the request through email verification for up to 24 hours. Older on-device collections are no longer imported automatically.
- Mobile navigation and theme switching are available to guests and signed-in users.
- Save/remove favorites tied to the logged-in user; account page with password change; admin-only navigation hidden from guests.
- Admin palette manager with a dynamic HEX-row colour editor and chip-based tag editing.
- Export a selected palette as CSS, JSON or a standalone PNG palette card, with live preview. Detail-page export links preserve the owner and slug, including private palettes you can access.
- Accessible touches: skip-to-content link, visible focus states, ARIA-labelled controls, toggle
state exposed via
aria-pressed; audited by axe in CI. Works over the LAN via a dynamic API base. - Styling is vanilla-extract — every rule scoped to the component or page that owns it, with design tokens typed in TypeScript. No global stylesheet beyond the document layer.
Palette runs on PostgreSQL via Docker Compose — database, backend and static frontend together. This is the only supported way to run it (no SQLite / non-Docker mode).
docker compose up --build- Backend:
http://localhost:8000(Swagger at/api/docswhenENABLE_API_DOCS=true) - Frontend:
http://localhost:5500 - Database: PostgreSQL in the
dbservice, data persisted in thepgdatavolume.
backend/.env must exist (copy backend/.env.example and set a real SECRET_KEY). Compose sets
DATABASE_URL automatically. Stop with docker compose down (add -v to drop the database volume).
Backend, against a disposable PostgreSQL in its own Compose profile:
docker compose --profile test run --rm testsFrontend:
cd frontend-react
npm run test:coverage # Vitest + coverage gate
npm run test:e2e # Playwright flows, focus management + axe accessibility audit
npm run css:orphans # class names in markup that no stylesheet defines
./scripts/visual.sh # screenshot baselines, compared at zero tolerance
./scripts/integration.sh # the same browser against the real Compose stack
./scripts/lighthouse.sh # performance budget: asset sizes fail, timings warnThe stubbed E2E specs intercept every request, which is what makes them fast and what stops
them noticing when the front end and the API disagree. integration.sh brings the whole stack
up — nginx, FastAPI, PostgreSQL, Redis — runs against it, and tears it down with its volume;
it needs ports 5500 and 8000 free, so a dev stack has to come down first.
The screenshots and the performance budget run inside a pinned Playwright container because
rendering and timing are host-specific — a baseline recorded on Windows or macOS will never
match CI. See frontend-react/README.md.
Backend quality is enforced with ruff (lint + format), mypy and a pytest coverage gate — the same checks run in CI.
pip install -r backend/requirements-dev.txt
pre-commit install
ruff check backend/app backend/tests # lint
ruff format backend/app backend/tests # format
mypy backend/app # type-checkSchema changes use Alembic (backend/alembic/); the app runs migrations on startup and adopts an
existing pre-Alembic database safely. Add a migration with:
docker compose run --rm backend alembic revision -m "describe change"Palette/
├── frontend-react/
│ ├── src/ # api, auth, components, pages, lib, styles (*.css.ts)
│ ├── e2e/ # Playwright: flows, a11y, focus · visual/ · integration/ · screenshots/
│ ├── scripts/ # visual.sh, integration.sh, lighthouse.sh, screenshots.sh
│ └── Dockerfile # multi-stage build, served by nginx
├── backend/
│ ├── app/ # main, config, database, models, schemas, security, routers/, crud
│ ├── alembic/ # migrations
│ ├── tests/ # async pytest suite
│ └── requirements*.txt
├── docs/ # architecture, deploy, ops, secrets, api, auth, database, setup…
├── scripts/ # backup-db.sh, split-secrets.sh
├── .github/workflows/ # ci.yml, deploy.yml, codeql.yml
├── docker-compose.yml # + docker-compose.prod.yml, docker-compose.staging.yml
├── secrets/ # SOPS-encrypted prod + staging env (safe to commit)
├── CHANGELOG.md · ROADMAP.md
Copy backend/.env.example to backend/.env and fill it in:
SECRET_KEY=change-this-secret-key-before-sharing
ACCESS_TOKEN_EXPIRE_MINUTES=1440
LOG_LEVEL=INFO
ENABLE_API_DOCS=false
CORS_ORIGINS=http://localhost:5500,http://127.0.0.1:5500
# DATABASE_URL is mandatory (PostgreSQL). Docker Compose sets it from the values below.
POSTGRES_USER=palette
POSTGRES_PASSWORD=palette
POSTGRES_DB=palette
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_EMAIL=admin@palette.local
DEFAULT_ADMIN_PASSWORD=change-this-admin-password
# Email verification (Resend). Without RESEND_API_KEY the app logs the link instead.
RESEND_API_KEY=
EMAIL_FROM=Palette <noreply@palettes-app.com>
PUBLIC_BASE_URL=http://localhost:5500SECRET_KEY is mandatory — the backend refuses to start if it is missing or left as a
placeholder. Generate one with:
python -c "import secrets; print(secrets.token_urlsafe(48))"CORS_ORIGINS is a comma-separated allowlist of browser origins (never *). Do not commit
backend/.env — it is git-ignored; production and staging secrets live encrypted under
secrets/ (see docs/secrets.md).
GET /api/v1/palettes # paginated, ?search= ?tag= ?sort= ?limit= ?offset=
GET /api/v1/tags
POST /api/v1/auth/register
POST /api/v1/auth/login
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
DELETE /api/v1/auth/me
GET /api/v1/auth/verify?token=...
POST /api/v1/auth/forgot-password
POST /api/v1/auth/reset-password
PUT /api/v1/auth/password
GET /api/v1/favorites
POST /api/v1/favorites/{slug}
DELETE /api/v1/favorites/{slug}
Admin palette/tag mutations require an authenticated admin (is_admin = true). GET /api/v1/palettes
is paginated ({ items, total, limit, offset } + X-Total-Count). Errors are returned as
application/problem+json (RFC 7807).
Release history is in CHANGELOG.md; the scope of each release, including what
was deliberately deferred, is in ROADMAP.md.
Next up is v5.0 — the palette is the product. The 4.8.x line went almost entirely into infrastructure — security, CI, observability, accessibility — and the interface fell behind it: a filtered catalogue cannot be linked to, a single palette has no page of its own even though the API has served one since v3.0, and the grid stops at a hundred without saying so. v5.0 closes that gap: a shareable page per palette with a full contrast table, filters that live in the address bar, real pagination, a dark theme, and colour values in HEX, RGB, HSL or OKLCH.
The plan is twelve ordered steps, each with the files it touches, the interactions it has to
produce and the tests it owes: docs/v5.0-plan.md.
v5.2.8
Licensed under the PolyForm Noncommercial License 1.0.0 — you may use, modify and share this project for noncommercial purposes only. Commercial use is not permitted. This is a source-available license, not an OSI open-source license.
Copyright 2026 Ingwald.
Open Export palette from a palette to keep its exact owner/slug selection. The selected palette stays visible while Change palette opens search. Image groups PNG and SVG; Code groups CSS variables, CSS OKLCH, Tailwind configuration and JSON. Format/source changes live in the URL and support browser history. Image previews and downloads use the same generated content; code offers Copy code and Download file. Favorites exports use the authenticated account's complete favorites response. Guests can export public palettes, but cannot save favorites.
The shared editor starts with a live color preview. Use the arrow buttons to reorder colors, including by keyboard or touch; up to 8 six-digit HEX colors and 12 tags are supported. Invalid HEX remains in the field and prevents saving until corrected. Names require 2–160 characters; description is optional (up to 1000 characters). Failed saves retain the draft. Unsaved edits prompt before internal navigation or closing an admin editor, and use the browser's standard reload/close warning where supported. This is not autosave or crash recovery.
Admin Palettes opens with a compact list. New palette and Edit open a separate, nonmodal editor panel, which stacks above the list on narrow screens. Closing it returns focus to the initiating control. Search and pagination survive editing and tab switches. Tags and Reports retain their existing operations. Server authorization remains authoritative.
Local frontend checks: cd frontend-react, then npm ci, npm run build, npm run lint,
npm run test:coverage. Browser E2E/a11y and visual-baseline commands remain in package.json;
run them with the project's test backend and review screenshot differences before accepting.




