Adds 7 reusable primitives in src/lib/components/ui/ and migrates ~70 hand-rolled call sites across forms, admin panels, and routes. Tokens unchanged — same Cozy Home palette, just consistently applied. Primitives - Switch: pill toggle, role=switch, terracotta track, cubic-bezier knob - Button: 5 variants × 4 sizes, press-squash, loading spinner, buttonClass() helper for <a> link-as-CTA cases - Checkbox: rounded square with animated check-draw + indeterminate - Select: native <select> with Cozy chevron + matched radius - Slider: gradient track, terracotta-bordered knob, aria-valuetext - Input + Field: documented in CLAUDE.md for future use - 9 buttonClass unit tests Migrations - 23 <input type=checkbox> → <Switch> (boolean settings) - 5 multi-select checkboxes → <Checkbox> (DiscoveryPanel, sys-stats metrics) - ~28 <select> → <Select> - 17 <input type=range> → <Slider> (ThemeCustomizer's hue picker kept custom) - ~25 hand-rolled buttons → <Button> / buttonClass() Surface polish - Admin section wrappers: rounded-lg → rounded-[1.4rem] + shadow-soft (resolves the Phase-5 tradeoff from the Cozy migration memo) - BoardPropertiesPanel: live theme preview swatch showing computed hsl() on a sample button; hue/sat use Slider; bg/cardSize use Select - AppHealthBadge: role=status + aria-live=polite; .status-degraded (slow amber breathing) and .status-offline (single attention flash) now applied - AppForm collapse triggers: rotating chevron + aria-expanded - Empty states for /boards and /apps: inline SVGs using --room-* tokens (peach/sky/sage/butter) instead of generic Lucide icons - Login Remember Me: showcase Switch (first-impression surface) Motion (src/app.css) - New cozy-rise / cozy-rise-stagger for staggered grid reveals (/boards, /apps) - New cozy-expand for accordion sections (healthcheck, integration, wallpaper) - All motion respects prefers-reduced-motion CLAUDE.md - New project guide with a mandatory Frontend reuse table — every primitive documented with "never use raw <input type=checkbox>/<select>/<range>" and "do not repeat rounded-xl bg-primary px-4 py-2 ..." rules Verification - npm run check: 0 errors, 0 warnings, 5831 files - npm test: 301 passing - npm run lint: 0 errors (19 pre-existing warnings unchanged) - npm run build: ✔ done Branch is feat/cozy-polish, ready to PR against master.
Web App Launcher
A self-hosted dashboard for organizing, monitoring, and launching web applications. Built with SvelteKit, Prisma (SQLite), and Tailwind CSS.
Features
- App registry — add apps with icons, tags, and categories; automatic healthcheck monitoring with sparkline history
- Boards & widgets — customizable dashboards with drag-and-drop, resizable widget columns, and inline WYSIWYG editing
- Service integrations — connect to media services, Planka, and more to display live data in widgets
- Authentication — local accounts + OAuth/Authentik; per-board access control; API tokens
- Localization — English and Russian
- PWA — installable, multi-tab sync, auto-discovery bookmarklet
- SQLite backup/restore — full database backup from the admin panel
Quick Start
git clone https://git.dolgolyov-family.by/alexei.dolgolyov/web-app-launcher.git
cd web-app-launcher
# Generate two strong secrets
export JWT_SECRET=$(openssl rand -hex 32)
export INTEGRATION_ENCRYPTION_KEY=$(openssl rand -hex 32)
docker compose up -d
The app is available at http://localhost:3000. On first launch, create an admin account at the setup page.
The launcher refuses to start if JWT_SECRET or INTEGRATION_ENCRYPTION_KEY is missing, shorter than 32 characters, or set to a known placeholder. This is intentional — running with the old change-me-… defaults would let anyone mint admin tokens.
Configuration
Environment variables (set in docker-compose.yml or .env):
| Variable | Default | Description |
|---|---|---|
APP_PORT |
3000 |
Port to expose |
JWT_SECRET |
required | Strong secret for JWT signing. Generate with openssl rand -hex 32. |
INTEGRATION_ENCRYPTION_KEY |
required | Strong secret for encrypting stored integration credentials. Must differ from JWT_SECRET. Generate with openssl rand -hex 32. |
ORIGIN |
http://localhost:$APP_PORT |
Public URL users visit. When set to https://..., session cookies are issued with the Secure flag. Set this to your public https URL when behind a reverse proxy. |
GUEST_MODE |
true |
Allow unauthenticated access to guest-flagged boards |
HEALTHCHECK_CRON |
*/5 * * * * |
App healthcheck interval |
HEALTHCHECK_TIMEOUT_MS |
5000 |
Healthcheck request timeout |
ALLOW_PRIVATE_NETWORK_FETCH |
false (true in dev) |
Allow outbound fetches to RFC1918/loopback/link-local. Self-hosted users monitoring LAN services usually want true. Off by default in prod to mitigate SSRF. |
RUN_SCHEDULERS |
true |
Run background jobs (healthcheck, backup) in this process. Set false on extra horizontal replicas. |
OAUTH_CLIENT_ID |
— | OAuth provider client ID |
OAUTH_CLIENT_SECRET |
— | OAuth provider client secret |
OAUTH_DISCOVERY_URL |
— | OpenID Connect discovery URL |
METRICS_TOKEN |
— | Optional bearer token for /api/metrics. Unset = open (private-network setups) |
Production deployment
Reverse proxy (Traefik / Caddy / Nginx)
The launcher must know its public URL to issue secure cookies. Set ORIGIN=https://launcher.example.com and terminate TLS at the proxy. Example Traefik labels:
services:
web-app-launcher:
# remove `ports:` mapping
networks: [traefik, launcher-net]
labels:
- traefik.enable=true
- traefik.http.routers.launcher.rule=Host(`launcher.example.com`)
- traefik.http.routers.launcher.entrypoints=websecure
- traefik.http.routers.launcher.tls.certresolver=letsencrypt
- traefik.http.services.launcher.loadbalancer.server.port=3000
environment:
- ORIGIN=https://launcher.example.com
Volume backup
docker run --rm \
-v web-app-launcher_launcher-data:/data \
-v "$PWD":/backup \
alpine tar czf /backup/launcher-backup.tar.gz -C /data .
Upgrade
docker compose pull && docker compose up -d
Database migrations run automatically on container start via prisma migrate deploy. The previous db push fallback was removed because it can silently drop columns on schema drift.
Breaking changes when upgrading from versions ≤ 0.0.x
The 0.1.0 hardening release is a one-way upgrade with three breaking changes:
-
INTEGRATION_ENCRYPTION_KEYis required and must differ fromJWT_SECRET. The launcher will refuse to start without it. Previously the integration key was derived fromJWT_SECRET; all stored integration credentials (Planka, Authentik, Pi-hole, Portainer, Gitea, Immich, etc.) will be undecryptable after the upgrade and must be re-entered through the admin UI. -
All users will be logged out and all API tokens / invites will be revoked. The hardening migration drops the
Session,Invite, andApiTokentables to switch from bcrypt-hashed storage to sha256 (so token validation is O(1) instead of O(N) bcrypt comparisons). Users will need to log in once; admins need to reissue API tokens and pending invites. -
Uploaded icons / wallpapers move from
static/uploads/to/app/data/uploads/. This makes them persist across container rebuilds. On upgrade, copy any existing files from your previousstatic/uploads/mount into thelauncher-datavolume:# if you previously mounted `./static/uploads:/app/static/uploads` docker run --rm \ -v "$PWD/static/uploads:/src:ro" \ -v web-app-launcher_launcher-data:/dst \ alpine sh -c "mkdir -p /dst/uploads && cp -r /src/. /dst/uploads/"
Take a backup before upgrading.
Development
npm install
npx prisma generate
# strong dev secrets are already in .env (gitignored)
npm run dev
License
MIT