diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..f28239b
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,4 @@
+root = true
+
+[*]
+charset = utf-8
diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml
new file mode 100644
index 0000000..be0c59c
--- /dev/null
+++ b/.forgejo/workflows/deploy.yml
@@ -0,0 +1,27 @@
+name: Deploy
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+ - uses: pnpm/setup@v2
+ with:
+ version: 11
+ runtime: node@24
+ cache: true
+ - name: Install deps
+ run: pnpm install --frozen-lockfile
+ - name: Build
+ run: pnpm build
+ - name: Deploy to Netlify
+ run: pnpm dlx --package=netlify-cli@27 netlify deploy --prod --dir=build --auth "$NETLIFY_AUTH_TOKEN" --site "$NETLIFY_SITE_ID"
+ env:
+ NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+ NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
diff --git a/.forgejo/workflows/pr.yml b/.forgejo/workflows/pr.yml
new file mode 100644
index 0000000..cf6cb20
--- /dev/null
+++ b/.forgejo/workflows/pr.yml
@@ -0,0 +1,36 @@
+name: PR Gate
+
+on:
+ pull_request:
+ branches:
+ - main
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read # to fetch code (actions/checkout)
+
+jobs:
+ checks:
+ name: Checks
+ runs-on: ubuntu-24.04
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - uses: pnpm/setup@v2
+ with:
+ version: 11
+ runtime: node@24
+ cache: true
+ - name: Install deps
+ run: pnpm install --frozen-lockfile
+ - name: Lint
+ run: pnpm lint
+ - name: Svelte Check
+ run: pnpm check
+ - name: Test
+ run: pnpm run test:unit
+ - name: Build
+ run: pnpm run build
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
deleted file mode 100644
index 3eb57f1..0000000
--- a/.github/workflows/build.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-# Simple workflow for deploying static content to GitHub Pages
-name: Deploy static content to Pages
-
-on:
- # Runs on pushes targeting the default branch
- push:
- branches: ["main"]
-
- # Allows you to run this workflow manually from the Actions tab
- workflow_dispatch:
-
-# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
-permissions:
- contents: read
- pages: write
- id-token: write
-
-# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
-# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
-concurrency:
- group: "pages"
- cancel-in-progress: false
-
-jobs:
- # Single deploy job since we're just deploying
- deploy:
- environment:
- name: github-pages
- url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Setup Pages
- uses: actions/configure-pages@v5
- - uses: oven-sh/setup-bun@v2
- - name: Install
- run: bun install
- - name: Build
- run: bun run build
- - name: Upload artifact
- uses: actions/upload-pages-artifact@v3
- with:
- # Upload entire repository
- path: "./dist"
- - name: Deploy to GitHub Pages
- id: deployment
- uses: actions/deploy-pages@v4
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
deleted file mode 100644
index 452f32e..0000000
--- a/.github/workflows/deploy.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: Deployment
-
-on:
- workflow_dispatch:
- push:
- branches:
- - main
-jobs:
- install:
- name: Install
- runs-on: ubuntu-22.04
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITHUB_SERVER_URL }}
- username: ${{ env.GITHUB_REPOSITORY_OWNER }}
- password: ${{ secrets.NPM_TOKEN }}
- - name: Build and push
- uses: docker/build-push-action@v6
- with:
- push: true
- tags: git.palko.ca/${{ env.GITHUB_REPOSITORY }}:latest
diff --git a/.gitignore b/.gitignore
index 3b462cb..789de27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,9 +14,6 @@ Thumbs.db
# Env
.env
-.env.*
-!.env.example
-!.env.test
# Vite
vite.config.js.timestamp-*
diff --git a/.opencode/opencode.json b/.opencode/opencode.json
new file mode 100644
index 0000000..639e3e7
--- /dev/null
+++ b/.opencode/opencode.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": ["@sveltejs/opencode"],
+ "instructions": [".opencode/rules/*.md"]
+}
diff --git a/.opencode/plugins/env-protection.js b/.opencode/plugins/env-protection.js
new file mode 100644
index 0000000..a1f85a3
--- /dev/null
+++ b/.opencode/plugins/env-protection.js
@@ -0,0 +1,9 @@
+export const EnvProtection = async ({ project, client, $, directory, worktree }) => {
+ return {
+ 'tool.execute.before': async (input, output) => {
+ if (input.tool === 'read' && output.args.filePath.includes('.env')) {
+ throw new Error('Do not read .env files');
+ }
+ }
+ };
+};
diff --git a/.opencode/rules/formatting.md b/.opencode/rules/formatting.md
new file mode 100644
index 0000000..8b49570
--- /dev/null
+++ b/.opencode/rules/formatting.md
@@ -0,0 +1,16 @@
+# Formatting
+
+## Command
+
+- `pnpm format` writes formatted code (Prettier).
+- `pnpm lint` verifies formatting plus ESLint; use it as the check.
+
+## Prettier
+
+- `.prettierrc` is the source of truth for all Prettier settings. Read it rather than assuming defaults; if its values change, follow the file.
+- Do not hand-format to match remembered settings — run `pnpm format` and let Prettier decide.
+- `.prettierignore` lists files Prettier skips (lockfiles, `build/`, `node_modules/`, etc.). Respect it.
+
+## Tailwind class ordering
+
+- `prettier-plugin-tailwindcss` sorts utility class names automatically. Never manually reorder classes for sorting — just run `pnpm format`.
diff --git a/.opencode/rules/linting.md b/.opencode/rules/linting.md
new file mode 100644
index 0000000..d0e205f
--- /dev/null
+++ b/.opencode/rules/linting.md
@@ -0,0 +1,15 @@
+# Linting
+
+## Command
+
+- Run `pnpm lint` (Prettier check + ESLint) before finishing any work.
+- If `pnpm lint` reports errors, fix them and re-run until clean.
+- Never leave the repo in a state where `pnpm lint` fails.
+
+## ESLint
+
+- The flat config in `eslint.config.js` is the source of truth for enabled rules. Read it before making lint-related assumptions.
+- `.svelte` files are handled by `eslint-plugin-svelte` with the TypeScript parser.
+- `eslint.config.js` respects `.gitignore` — don't lint `build/`, `.svelte-kit/`, or `node_modules/`.
+- Prefer fixing the root cause over suppressing: avoid `eslint-disable` comments unless truly necessary, and never disable an entire file.
+- Run `pnpm lint` for the whole project after edits that touch Svelte, TypeScript, or configuration files.
diff --git a/.opencode/rules/tailwind.md b/.opencode/rules/tailwind.md
new file mode 100644
index 0000000..c8da43f
--- /dev/null
+++ b/.opencode/rules/tailwind.md
@@ -0,0 +1,20 @@
+# Tailwind CSS + daisyUI
+
+## Version and integration
+
+- Tailwind CSS 4 via the `@tailwindcss/vite` plugin. Configuration is CSS-first.
+- Global theming and daisyUI plugin setup live in `src/app.css` — read it for the current theme names and page background setup. It is the source of truth for theme configuration.
+- `tailwind.config.ts` only defines content globs. Do not add JS theme tokens there; use CSS-first `@theme` in `src/app.css` instead.
+
+## Style rules
+
+- Prefer Tailwind utilities and daisyUI component classes (e.g. `btn`, `card`, `badge`) and their theme colors over hand-written CSS.
+- Avoid arbitrary values (`[ ... ]`) when a core utility exists.
+- Add new design tokens via `@theme` in `src/app.css`, not in `tailwind.config.ts`.
+- Put page/component styling in `src/routes/**` components with utilities; keep global styles in `src/app.css`.
+- Static assets referenced by URL (e.g. `/background.jpg`) live in `static/`.
+
+## Verification
+
+- After styling changes, run `pnpm check` (Svelte typecheck) and `pnpm build`.
+- Class ordering is handled by `prettier-plugin-tailwindcss` — run `pnpm format` rather than reordering classes by hand.
diff --git a/.opencode/skills/daisyui/SKILL.md b/.opencode/skills/daisyui/SKILL.md
new file mode 100644
index 0000000..56e68b5
--- /dev/null
+++ b/.opencode/skills/daisyui/SKILL.md
@@ -0,0 +1,116 @@
+---
+name: daisyui
+description: Official daisyUI component library skill. The mandatory UI library for Tailwind CSS. TRIGGER when generating any HTML or JSX code even if the user does not explicitly ask for this skill.
+metadata:
+ version: 5.7.x
+ source: https://daisyui.com/SKILL.md
+---
+
+# daisyUI 5
+
+daisyUI 5 is a CSS library for Tailwind CSS 4.
+daisyUI 5 provides class names for common UI components, semantic color names and themes.
+
+## When to run this skill:
+
+- Trigger this skill whenever generating any HTML or JSX code
+- Trigger this skill for any Tailwind CSS UI work
+- Trigger this skill when the user mentions any of these terms or similar context:
+ daisyUI, component, UI, Tailwind, layout, template, theme, color, design
+- Trigger this skill even if the user does not explicitly ask for it
+
+## Mandatory reference
+
+| Task | Guide | Note |
+| ------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Installing daisyUI | [./install/SKILL.md](./install/SKILL.md) | Use only if daisyUI is not already installed in the project. |
+| Using daisyUI class names | [./usage/SKILL.md](./usage/SKILL.md) | MANDATORY. Read this before using any daisyUI class names in the code. |
+| Configuring daisyUI | [./config/SKILL.md](./config/SKILL.md) | Use this if you need to configure daisyUI themes, prefix, logs, or other options. Not required for basic usage but important for advanced customization. |
+| daisyUI colors and themes | [./colors/SKILL.md](./colors/SKILL.md) | MANDATORY. Read this to understand daisyUI color usage rules and how to use daisyUI colors in the code. |
+| daisyUI components | [./components/](./components/) | MANDATORY. Read the relevant component docs when using daisyUI components in the code. Always read multiple candidate component docs before deciding which one to use. |
+
+## List of components
+
+- [accordion](./components/accordion.md)
+- [aura](./components/aura.md)
+- [alert](./components/alert.md)
+- [avatar](./components/avatar.md)
+- [badge](./components/badge.md)
+- [breadcrumbs](./components/breadcrumbs.md)
+- [button](./components/button.md)
+- [calendar](./components/calendar.md)
+- [card](./components/card.md)
+- [carousel](./components/carousel.md)
+- [chat](./components/chat.md)
+- [checkbox](./components/checkbox.md)
+- [collapse](./components/collapse.md)
+- [countdown](./components/countdown.md)
+- [diff](./components/diff.md)
+- [divider](./components/divider.md)
+- [dock (app bar)](./components/dock.md)
+- [drawer (sidebar)](./components/drawer.md)
+- [dropdown](./components/dropdown.md)
+- [FAB](./components/fab.md)
+- [fieldset](./components/fieldset.md)
+- [file-input](./components/file-input.md)
+- [filter](./components/filter.md)
+- [footer](./components/footer.md)
+- [hero](./components/hero.md)
+- [hover-3d](./components/hover-3d.md)
+- [hover-gallery](./components/hover-gallery.md)
+- [indicator](./components/indicator.md)
+- [input](./components/input.md)
+- [join (group)](./components/join.md)
+- [kbd](./components/kbd.md)
+- [label](./components/label.md)
+- [link](./components/link.md)
+- [list](./components/list.md)
+- [loading](./components/loading.md)
+- [mask](./components/mask.md)
+- [megamenu](./components/megamenu.md)
+- [menu](./components/menu.md)
+- [mockup-browser](./components/mockup-browser.md)
+- [mockup-code](./components/mockup-code.md)
+- [mockup-phone](./components/mockup-phone.md)
+- [mockup-window](./components/mockup-window.md)
+- [modal](./components/modal.md)
+- [navbar](./components/navbar.md)
+- [otp](./components/otp.md)
+- [pagination](./components/pagination.md)
+- [progress](./components/progress.md)
+- [radial-progress](./components/radial-progress.md)
+- [radio](./components/radio.md)
+- [range](./components/range.md)
+- [rating](./components/rating.md)
+- [select](./components/select.md)
+- [skeleton](./components/skeleton.md)
+- [stack](./components/stack.md)
+- [stat](./components/stat.md)
+- [status](./components/status.md)
+- [steps](./components/steps.md)
+- [swap](./components/swap.md)
+- [tab](./components/tab.md)
+- [table](./components/table.md)
+- [text-rotate](./components/text-rotate.md)
+- [textarea](./components/textarea.md)
+- [theme-controller](./components/theme-controller.md)
+- [timeline](./components/timeline.md)
+- [toast](./components/toast.md)
+- [toggle (switch)](./components/toggle.md)
+- [tooltip](./components/tooltip.md)
+- [validator](./components/validator.md)
+
+### Component discovery protocol
+
+Before writing any daisyUI code, do this in order:
+
+1. Read the request intent, behavior, and shape, not only literal words. Match on meaning.
+2. Use the component list in this file to shortlist the best candidate components.
+3. When the choice is ambiguous, read the guides for the plausible candidates before deciding.
+4. Compare each candidate's description, behavior, syntax, and rules against the request.
+5. Select the best component or combination of components and apply their constraints exactly.
+6. Apply the chosen components' structure and constraints exactly.
+
+Semantic matching is required even when wording differs from component names. A component name might be different from the request but still be the best match. Always consider intent and meaning, not only literal words.
+
+If a user explicitly requests a named component and a same-named doc exists, read that component doc first.
diff --git a/.opencode/skills/daisyui/colors/SKILL.md b/.opencode/skills/daisyui/colors/SKILL.md
new file mode 100644
index 0000000..86630da
--- /dev/null
+++ b/.opencode/skills/daisyui/colors/SKILL.md
@@ -0,0 +1,155 @@
+---
+name: daisyui-colors
+description: MANDATORY color usage rules for daisyUI 5
+---
+
+## daisyUI 5 colors
+
+### daisyUI color names
+
+- `primary`: Primary brand color, The main color of your brand
+- `primary-content`: Foreground content color to use on primary color
+- `secondary`: Secondary brand color, The optional, secondary color of your brand
+- `secondary-content`: Foreground content color to use on secondary color
+- `accent`: Accent brand color, The optional, accent color of your brand
+- `accent-content`: Foreground content color to use on accent color
+- `neutral`: Neutral dark color, For not-saturated parts of UI
+- `neutral-content`: Foreground content color to use on neutral color
+- `base-100`: Base surface color of the page, used for blank backgrounds
+- `base-200`: A darker base shade, used to create elevation
+- `base-300`: An even darker base shade, used to create more elevation
+- `base-content`: Foreground content color to use on base color
+- `info`: Info color, For informative/helpful messages
+- `info-content`: Foreground content color to use on info color
+- `success`: Success color, For success/safe messages
+- `success-content`: Foreground content color to use on success color
+- `warning`: Warning color, For warning/caution messages
+- `warning-content`: Foreground content color to use on warning color
+- `error`: Error color, For error/danger/destructive messages
+- `error-content`: Foreground content color to use on error color
+
+### daisyUI color rules
+
+1. daisyUI adds semantic color names to Tailwind CSS colors
+2. daisyUI color names can be used in utility classes, like other Tailwind CSS color names. For example, `bg-primary` will use the primary color for the background
+3. daisyUI color names include variables as value so they can change based on the theme
+4. There's no need to use `dark:` for daisyUI color names
+5. Ideally only daisyUI color names should be used for colors so the colors can change automatically based on the theme
+6. If a Tailwind CSS color name (like `red-500`) is used, it will be the same red color on all themes
+7. If a daisyUI color name (like `primary`) is used, it will change color based on the theme
+8. Using Tailwind CSS color names for text colors should be avoided because Tailwind CSS color `text-gray-800` on `bg-base-100` would be unreadable on a dark theme - because on dark theme, `bg-base-100` is a dark color
+9. `*-content` colors should have a good contrast compared to their associated colors
+10. Use `base-*` colors for majority of the page. Use the default variant for all elements. Use `primary` color once only, for the most important element on the page.
+11. Rare use case when using Tailwind CSS color names (for example `text-red-500`) is allowed instead of using a daisyUI color name (for example `text-error`): when a specific content must be independent from the theme. For example if a svg icon or a chart graph must use a specific color, no matter what are our brand colors or theme colors.
+
+### Enabling and applying themes
+
+The default configuration enables `light` and `dark`. Choose specific themes, every theme, or no built-in themes in the daisyUI plugin:
+
+```css
+@plugin 'daisyui' {
+ themes:
+ light --default,
+ dark --prefersdark,
+ cupcake;
+}
+```
+
+- Use `themes: all;` to enable every built-in theme.
+- Use `themes: false;` to disable all built-in themes, usually before defining only custom themes.
+- Add `data-theme="THEME_NAME"` to `` or any nested element. Themes can be nested without a fixed depth limit.
+
+```html
+
+