docs(copilot): add Copilot instructions for The Collective Hub

- Add comprehensive project overview and core philosophy
- Document file structure reference for the codebase
- Create key files reference table for task-specific guidance
- Include multi-tenant guidelines and site resolution flow
This commit is contained in:
KungRaseri
2026-06-05 23:46:15 -07:00
parent f4245a996a
commit b192cd53ba
33 changed files with 5710 additions and 120 deletions
+154
View File
@@ -0,0 +1,154 @@
# The Collective Hub — Copilot Instructions
## Project Overview
**The Collective Hub** is a reusable SvelteKit website template system for launching branded landing pages for online theater hosts, watch-party communities, and Discord communities. One codebase → multiple deployed sites → one shared database + CDN.
**Core philosophy**: One codebase, multiple sites, no data leaks, maintainable by one person.
---
## File Structure Reference
```
.github/
instructions/ ← Development guidelines for The Collective Hub
multi-tenant-architecture.instructions.md
database-schema.instructions.md
auth-and-roles.instructions.md
deployment-guide.instructions.md
admin-panel.instructions.md
cdn-and-assets.instructions.md
public-site-theming.instructions.md
api-route-patterns.instructions.md
testing-multi-tenant.instructions.md
bits-ui.instructions.md
components.instructions.md
icons.instructions.md
server-ts.instructions.md
svelte-ts.instructions.md
svelte5.instructions.md
tailwindcss.instructions.md
testing.instructions.md
prompts/ ← Scaffolding templates for routes, components, API, DB
new-route.prompt.md
generate-component-test.prompt.md
generate-api-endpoint.prompt.md
generate-admin-page.prompt.md
generate-db-migration.prompt.md
generate-svelte-component.prompt.md
generate-seed-data.prompt.md
test-coverage.prompt.md
workflows/ ← CI/CD and release automation
docs/ ← Architecture, database, roadmap, environment docs
src/ ← SvelteKit application
```
---
## Key Files to Read When Working on Specific Tasks
| When working on... | Read this first |
| ------------------ | --------------- |
| Understanding site resolution, data scoping, deployment model | [`multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md) |
| Database queries, schema changes, migrations | [`database-schema.instructions.md`](.github/instructions/database-schema.instructions.md) |
| Authentication, Discord OAuth, role-based access | [`auth-and-roles.instructions.md`](.github/instructions/auth-and-roles.instructions.md) |
| Deploying a new site, Docker, Coolify setup | [`deployment-guide.instructions.md`](.github/instructions/deployment-guide.instructions.md) |
| Admin panel pages, auth guards, form actions | [`admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md) |
| CDN storage, image upload, asset management | [`cdn-and-assets.instructions.md`](.github/instructions/cdn-and-assets.instructions.md) |
| Public landing page, theming, CSS custom properties | [`public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md) |
| API route conventions, validation, site scoping | [`api-route-patterns.instructions.md`](.github/instructions/api-route-patterns.instructions.md) |
| Writing tests with multi-tenant mocking patterns | [`testing-multi-tenant.instructions.md`](.github/instructions/testing-multi-tenant.instructions.md) |
| SvelteKit server-side code, Drizzle queries, form actions | [`server-ts.instructions.md`](.github/instructions/server-ts.instructions.md) |
| Svelte 5 runes, snippets, component patterns | [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md) |
| Tailwind CSS v4 styling | [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md) |
| Vitest + Playwright testing patterns | [`testing.instructions.md`](.github/instructions/testing.instructions.md) |
---
## Multi-Tenant Guidelines
### Site Resolution Flow
```
Request → hooks.server.ts (reads SITE_SLUG) → site-resolver.ts (queries DB by slug)
→ locals.site / locals.siteSettings → app renders with site context
```
### Data Scoping Rule
**Every table that owns site data must have a `siteId` column. Every query must filter by it.**
```ts
// ✅ Always filter by siteId
const items = await db
.select()
.from(events)
.where(and(eq(events.siteId, locals.site.id), eq(events.isPublished, true)));
```
### CDN Key Rule
**Store CDN keys (paths) in the database, never full URLs.**
```ts
// Database stores: "sites/bad-movies-theater/logo.webp"
// Use cdnUrl() to get the full URL:
const url = cdnUrl(settings.branding.logoCdnKey);
```
### Auth Flow
- `OWNER_DISCORD_ID` — bootstraps the site owner on first login
- `SUPER_ADMIN_DISCORD_IDS` — comma-separated, grants cross-site access
- Roles: `owner` > `admin` > `editor`
---
## Tech Stack Instructions
The web application is a **SvelteKit** project with TypeScript, PostgreSQL, Drizzle ORM, Better Auth, Tailwind CSS v4, Vitest, and Playwright. When generating or modifying code, read the relevant instruction file for detailed conventions.
| File | Purpose |
| ---- | ------- |
| [`multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md) | Site resolution, data scoping, deployment model, env vars |
| [`database-schema.instructions.md`](.github/instructions/database-schema.instructions.md) | All tables, relationships, indexes, migration strategy |
| [`auth-and-roles.instructions.md`](.github/instructions/auth-and-roles.instructions.md) | Better Auth, Discord OAuth, role system, super admin |
| [`deployment-guide.instructions.md`](.github/instructions/deployment-guide.instructions.md) | Coolify multi-deploy, Docker, migration runner, env setup |
| [`admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md) | Admin layout, auth guards, form patterns, admin pages |
| [`cdn-and-assets.instructions.md`](.github/instructions/cdn-and-assets.instructions.md) | CDN helpers, image upload, webp conversion, URL construction |
| [`public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md) | SSR-only landing page, CSS custom properties, theme presets |
| [`api-route-patterns.instructions.md`](.github/instructions/api-route-patterns.instructions.md) | API route conventions, asset upload, event CRUD, validation |
| [`testing-multi-tenant.instructions.md`](.github/instructions/testing-multi-tenant.instructions.md) | Multi-tenant test patterns, mocking site context, auth testing |
| [`bits-ui.instructions.md`](.github/instructions/bits-ui.instructions.md) | Bits UI headless component patterns |
| [`components.instructions.md`](.github/instructions/components.instructions.md) | Svelte 5 component architecture |
| [`icons.instructions.md`](.github/instructions/icons.instructions.md) | Lucide icon usage guidelines |
| [`server-ts.instructions.md`](.github/instructions/server-ts.instructions.md) | SvelteKit server-side TypeScript patterns |
| [`svelte-ts.instructions.md`](.github/instructions/svelte-ts.instructions.md) | Svelte 5 .svelte.ts reactive module patterns |
| [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md) | Svelte 5 runes, snippets, migrations |
| [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md) | Tailwind CSS v4 configuration |
| [`testing.instructions.md`](.github/instructions/testing.instructions.md) | Vitest + Playwright base testing patterns |
---
## Hard Rules
1. **Always filter by `siteId`.** Every Drizzle query on site-owned data must include a `siteId` filter. Missing this is the most common multi-tenant bug and causes data leaks between sites.
2. **Store CDN keys, not full URLs.** Database fields store paths like `sites/{slug}/logo.webp`. The `cdnUrl()` helper constructs full URLs using `CDN_BASE_URL`. Never store a full URL in the database.
3. **One deployment runs migrations.** Only the deployment with `RUN_MIGRATIONS=true` runs database migrations. Deploy this one first when schema changes are included in a release.
4. **No site-specific conditional logic.** Never write `if (site.slug === 'some-site')`. All per-site differences come from database settings. If a site genuinely needs a unique feature, build it as a configurable option for all sites.
5. **Never commit `.env` files.** Environment variables are configured per-deployment in Coolify. The `.env` file is in `.gitignore` and must never be committed.
6. **Use `$lib` aliases, not relative paths.** Always import from `$lib/server/db`, `$lib/components/`, etc. Never use relative imports like `../../lib/server/db`.
7. **Co-locate tests.** Test files go next to the source they test. Server tests use `.test.ts`, browser component tests use `.svelte.test.ts`.
8. **No `svelte-ignore` suppression comments.** All accessibility and type issues must be fixed properly. Never hide a warning behind a suppression comment.
9. **Prefer additive migrations.** In production, add new columns and tables. Avoid destructive operations (ALTER DROP, RENAME). JSON settings columns reduce migration frequency.
10. **Respect the phase roadmap.** Phase 1 must be fully working (public site + admin login + settings save) before Phase 2. A working simple site is more valuable than a half-built complex one.
@@ -0,0 +1,192 @@
---
description: 'Use when building or modifying admin panel pages for The Collective Hub. Covers admin route structure, auth guards, form action patterns, layout conventions, and admin Svelte component patterns.'
applyTo: 'src/routes/admin/**/*.svelte', 'src/routes/admin/**/*.server.ts'
---
# Admin Panel
## Route Structure
Admin routes live under `src/routes/admin/`:
```
src/routes/admin/
+layout.server.ts ← Auth guard (redirects to /login if unauthenticated)
+layout.svelte ← Admin shell with navigation sidebar
+page.svelte ← Admin dashboard (overview)
settings/
+page.server.ts ← Loads/saves site settings
+page.svelte
branding/
+page.server.ts ← Logo, colors, theme
+page.svelte
homepage/
+page.server.ts ← Hero text, about, CTA
+page.svelte
links/
+page.server.ts ← Nav links + social links
+page.svelte
events/
+page.server.ts ← Event CRUD
+page.svelte
assets/
+page.server.ts ← Asset library
+page.svelte
```
## Auth Guard Pattern
Every admin server load must check authentication. This is centralized in the admin layout:
```ts
// src/routes/admin/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
// Optional: check minimum role for admin access
// const roleLevel = { owner: 3, admin: 2, editor: 1 };
// if ((roleLevel[locals.membership?.role ?? ''] ?? 0) < 2) {
// redirect(302, '/');
// }
return {
user: locals.user,
membership: locals.membership,
settings: locals.siteSettings,
};
};
```
## Form Action Patterns
Admin pages use SvelteKit form actions for data mutations. API routes (`+server.ts`) are reserved for asset uploads and external integrations.
### Standard Form Action
```ts
// +page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { siteSettings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
return {
settings: locals.siteSettings,
};
};
export const actions: Actions = {
default: async ({ locals, request }) => {
if (!locals.user) return fail(401, { error: 'Unauthorized' });
const form = await request.formData();
const siteName = form.get('siteName') as string;
if (!siteName || siteName.trim().length === 0) {
return fail(400, { error: 'Site name is required' });
}
const updatedSettings = {
...locals.siteSettings,
branding: {
...locals.siteSettings?.branding,
siteName: siteName.trim(),
},
};
await db
.update(siteSettings)
.set({ settings: updatedSettings })
.where(eq(siteSettings.siteId, locals.site.id));
return { success: true, message: 'Settings saved' };
},
};
```
### Form Component
```svelte
<script lang="ts">
import type { PageData } from './$types';
let { data, form } = $props();
let { settings } = data;
let siteName = $state(settings?.branding?.siteName ?? '');
</script>
<form method="POST" class="space-y-4">
<div>
<label for="siteName" class="block text-sm font-medium">Site Name</label>
<input
id="siteName"
name="siteName"
type="text"
bind:value={siteName}
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
required
/>
</div>
{#if form?.error}
<p class="text-red-500 text-sm">{form.error}</p>
{/if}
{#if form?.success}
<p class="text-green-500 text-sm">{form.message}</p>
{/if}
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Save Changes
</button>
</form>
```
## Admin Layout Shell
The admin layout provides consistent navigation:
```svelte
<script lang="ts">
import type { LayoutData } from './$types';
let { data, children } = $props();
let { user } = data;
</script>
<div class="flex min-h-screen">
<!-- Sidebar -->
<nav class="w-64 bg-gray-900 text-white p-4">
<div class="text-lg font-bold mb-6">{user.discordUsername}</div>
<ul class="space-y-2">
<li><a href="/admin" class="block p-2 hover:bg-gray-700 rounded">Dashboard</a></li>
<li><a href="/admin/settings" class="block p-2 hover:bg-gray-700 rounded">Settings</a></li>
<li><a href="/admin/branding" class="block p-2 hover:bg-gray-700 rounded">Branding</a></li>
<li><a href="/admin/homepage" class="block p-2 hover:bg-gray-700 rounded">Homepage</a></li>
<li><a href="/admin/links" class="block p-2 hover:bg-gray-700 rounded">Links</a></li>
<li><a href="/admin/events" class="block p-2 hover:bg-gray-700 rounded">Events</a></li>
<li><a href="/admin/assets" class="block p-2 hover:bg-gray-700 rounded">Assets</a></li>
</ul>
</nav>
<!-- Main Content -->
<main class="flex-1 p-8">
{@render children()}
</main>
</div>
```
## Admin Page Conventions
1. **Server load fetches current data** — always load from DB, don't rely on cached `locals`
2. **Form actions validate and save** — never trust client-side data without server validation
3. **Success/error feedback** — return `{ success: true, message: '...' }` or `fail(statusCode, { error: '...' })`
4. **Role-aware UI** — conditionally show/hide controls based on `membership.role`
5. **Optimistic updates** — not required for V1; simple form submit + reload is fine
6. **No client-side state management** — rely on `form` and `data` from SvelteKit's form actions
@@ -0,0 +1,177 @@
---
description: 'Use when creating or modifying SvelteKit API endpoints (+server.ts) for The Collective Hub. Covers route conventions, authentication, validation, site scoping, and response formats.'
applyTo: 'src/routes/api/**/*.server.ts'
---
# API Route Patterns
## Route Structure
API routes live under `src/routes/api/`:
```
src/routes/api/
auth/
[...betterAuth]/ ← Better Auth handles this
assets/
+server.ts ← Upload endpoint (POST)
```
Additional API endpoints should follow this pattern.
## Conventions
### File Naming
- API handlers go in `+server.ts` files
- All HTTP methods are exported as named functions: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`
- Co-located test files drop the `+` prefix: `server.test.ts`
### Response Format
All API responses use SvelteKit's `json()` helper:
```ts
import { json } from '@sveltejs/kit';
// Success
return json({ data: result });
// Created
return json({ data: newResource }, { status: 201 });
// Error
return json({ error: 'Resource not found' }, { status: 404 });
```
## Authentication
All API routes (except public ones) must check authentication:
```ts
import { json } from '@sveltejs/kit';
export async function POST({ locals, request }) {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
// Site scoping: always use locals.site.id
const siteId = locals.site.id;
// ... handler logic
}
```
## Site Scoping
Every API route that touches site-owned data must filter by `locals.site.id`:
```ts
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { events } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
export async function GET({ locals, url }) {
const siteId = locals.site.id;
const items = await db
.select()
.from(events)
.where(
and(
eq(events.siteId, siteId),
eq(events.isPublished, true)
)
)
.orderBy(events.startTime);
return json({ data: items });
}
```
## Validation
Validate request data on the server. Never trust client-provided data:
```ts
import { json } from '@sveltejs/kit';
export async function POST({ locals, request }) {
if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 });
const body = await request.json();
const { title, startTime } = body;
// Manual validation
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return json({ error: 'Title is required' }, { status: 400 });
}
if (!startTime || isNaN(Date.parse(startTime))) {
return json({ error: 'Valid start time is required' }, { status: 400 });
}
// ... proceed with valid data
}
```
For complex validation, consider using [Zod](https://zod.dev/) (add when validation complexity warrants it):
```ts
import { z } from 'zod';
const eventSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
startTime: z.string().datetime(),
eventType: z.enum(['screening', 'watch_party', 'meetup', 'other']).optional(),
});
const result = eventSchema.safeParse(body);
if (!result.success) {
return json({ error: result.error.flatten() }, { status: 400 });
}
```
## Asset Upload Endpoint
The asset upload endpoint is the primary use case for API routes (most admin mutations use form actions instead). See [`cdn-and-assets.instructions.md`](cdn-and-assets.instructions.md) for the full upload pattern.
## Form Actions vs API Routes
| Concern | Use Form Actions | Use API Routes |
|---------|-----------------|----------------|
| Admin data mutations (settings, branding, content) | ✅ Form actions in `+page.server.ts` | ❌ |
| File uploads | ❌ | ✅ `+server.ts` (multipart) |
| External API proxies | ❌ | ✅ `+server.ts` |
| Third-party webhooks | ❌ | ✅ `+server.ts` |
**Rule of thumb:** If the request comes from an admin page form, use form actions. If it comes from an external system or involves file upload, use API routes.
## Error Handling
```ts
import { json } from '@sveltejs/kit';
// 400 — Bad Request (validation)
return json({ error: 'Invalid input' }, { status: 400 });
// 401 — Unauthorized (not logged in)
return json({ error: 'Authentication required' }, { status: 401 });
// 403 — Forbidden (wrong role)
return json({ error: 'Insufficient permissions' }, { status: 403 });
// 404 — Not Found
return json({ error: 'Resource not found' }, { status: 404 });
// 500 — Server Error
try {
// risky operation
} catch (err) {
console.error('Failed to process request:', err);
return json({ error: 'Internal server error' }, { status: 500 });
}
```
@@ -0,0 +1,168 @@
---
description: 'Use when implementing or modifying authentication, authorization, role-based access, or login flows for The Collective Hub. Covers Better Auth with Discord OAuth, owner bootstrap, super admin access, and role hierarchy.'
applyTo: 'src/lib/server/auth.ts', 'src/hooks.server.ts', 'src/routes/**/*.server.ts', 'src/routes/**/*.svelte'
---
# Authentication & Roles
## Auth Stack
- **Library**: [Better Auth](https://www.better-auth.com/) v1.6+
- **Provider**: Discord OAuth
- **Session**: Server-side sessions managed by Better Auth
## Auth Flow
```mermaid
sequenceDiagram
participant U as User
participant S as SvelteKit App
participant DA as Discord OAuth
participant DB as PostgreSQL
U->>S: Visit /login
S->>DA: Redirect to Discord OAuth
DA->>U: Authorize
U->>S: Callback with OAuth code
S->>DA: Exchange code for token + user info
DA-->>S: Discord user (id, username, avatar)
S->>DB: Upsert user record
S->>DB: Check if Discord ID matches OWNER_DISCORD_ID
S->>DB: Check if Discord ID matches SUPER_ADMIN_DISCORD_IDS
DB-->>S: Match found → assign appropriate role
S->>S: Create session
S-->>U: Redirect to /admin (or show access denied)
```
## Better Auth Setup
The auth instance is configured in [`src/lib/server/auth.ts`](src/lib/server/auth.ts):
```ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '$lib/server/db';
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
// Better Auth manages its own tables: user, session, account, verification
}),
socialProviders: {
discord: {
clientId: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
},
},
});
```
SvelteKit integration in [`src/hooks.server.ts`](src/hooks.server.ts) handles the `/api/auth/*` routes via `svelteKitHandler`.
## Owner Bootstrap
On first login, the app compares the user's Discord ID against the `OWNER_DISCORD_ID` environment variable:
1. A `membership` record is created (or confirmed) with role `owner` for the current site
2. The env var acts as a bootstrap — once the owner exists in the DB, the env var could be removed
3. Long-term, owners can add other admins/editors through the admin panel
```ts
// Pseudocode for owner bootstrap logic
if (user.discordId === env.OWNER_DISCORD_ID) {
// Upsert membership with role 'owner' for current site
await db.insert(memberships).values({
siteId: locals.site.id,
userId: user.id,
role: 'owner',
}).onConflictDoUpdate({ target: [memberships.siteId, memberships.userId], set: { role: 'owner' } });
}
```
## Super Admin Access
The app checks the user's Discord ID against `SUPER_ADMIN_DISCORD_IDS` (comma-separated):
1. Matched users get cross-site admin access — bypass site-scoped membership checks
2. Super admins can access any site's admin panel regardless of `OWNER_DISCORD_ID`
3. Intended for David (system maintainer) to manage all sites
4. Checked on every request, not just at login
```ts
const SUPER_ADMIN_IDS = (env.SUPER_ADMIN_DISCORD_IDS || '').split(',').map(id => id.trim());
const isSuperAdmin = SUPER_ADMIN_IDS.includes(user.discordId);
```
## Role Hierarchy
| Role | Permissions | Assigned By |
|------|-------------|-------------|
| `owner` | Full control. Can manage admins. One per site initially. | Bootstrap via `OWNER_DISCORD_ID` |
| `admin` | Can edit all site settings and content. Cannot delete site or manage owner. | Owner |
| `editor` | Can edit content (events, pages) but not site settings or branding. | Owner or Admin |
### Checking Roles in Server Code
```ts
import { redirect } from '@sveltejs/kit';
// Auth guard — redirect if not logged in
if (!locals.user) {
redirect(302, '/login');
}
// Role guard — check minimum role
const roleHierarchy = { owner: 3, admin: 2, editor: 1 };
const userRole = locals.membership?.role;
const minRole = 'admin';
if (!userRole || (roleHierarchy[userRole] ?? 0) < (roleHierarchy[minRole] ?? 0)) {
// Check super admin bypass
if (!locals.isSuperAdmin) {
redirect(302, '/admin'); // or show 403
}
}
```
### In Admin Layout Server Load
```ts
// src/routes/admin/+layout.server.ts
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
return {
user: locals.user,
membership: locals.membership,
isSuperAdmin: locals.isSuperAdmin,
};
};
```
### In Page Components
```svelte
<script lang="ts">
let { data } = $props();
const { user, membership } = data;
const canEdit = $derived(membership?.role === 'owner' || membership?.role === 'admin');
</script>
{#if canEdit}
<button onclick={handleSave}>Save Changes</button>
{/if}
```
## Session Handling
Better Auth manages sessions automatically. Key patterns:
- **Check login status**: `locals.user` is set by the auth handler in the hooks
- **Logout**: POST to `/api/auth/signout`
- **Session persistence**: Better Auth uses cookies; no client-side token storage needed
- **No client-side auth state**: Auth state comes from server load functions, never from `$page.data` derived values on the client (use server load functions exclusively)
@@ -0,0 +1,475 @@
---
description: 'Use when integrating Bits UI headless components into The Collective Hub. Covers installation, the child snippet pattern, component catalog, Tailwind v4 integration, and migration guidance for overlapping existing UI components.'
applyTo: 'src/**/*.svelte'
---
# Bits UI Integration
## Overview
[Bits UI](https://bits-ui.com/) is a **headless component library for Svelte 5**, built and maintained by Hunter Johnston ([@huntabyte](https://github.com/huntabyte)). It is the successor to Melt UI, rebuilt exclusively for Svelte 5 runes.
| Property | Description |
| ----------------------- | ---------------------------------------------------------------------------------------- |
| **Headless** | Ships unstyled — you bring your own CSS/Tailwind classes |
| **Accessibility-first** | WAI-ARIA compliant with keyboard navigation, focus management, and screen reader support |
| **TypeScript-native** | Full type coverage with exported type helpers |
| **Svelte 5 only** | Built exclusively for runes (`$state`, `$derived`, `$props`, `$bindable`, `Snippet`) |
## Installation
```bash
npm install bits-ui
```
For date/time components, also install the peer dependency:
```bash
npm install @internationalized/date
```
No additional setup or configuration required — import directly:
```svelte
<script lang="ts">
import { Accordion, Button, Dialog } from 'bits-ui';
</script>
```
## Component Catalog
### Interactive / Disclosure
| Component | Description |
| ---------------- | --------------------------------------- |
| `Accordion` | Expandable/collapsible content sections |
| `AlertDialog` | Modal dialog for urgent confirmations |
| `Collapsible` | Single collapsible panel |
| `Dialog` | Modal dialog overlay |
| `Tabs` | Tabbed content switcher |
| `DropdownMenu` | Menu anchored to a trigger button |
| `ContextMenu` | Right-click context menu |
| `Menubar` | Horizontal menu bar with nested menus |
| `NavigationMenu` | Responsive nav menu with submenus |
| `Popover` | Floating content anchored to a trigger |
| `Tooltip` | Hover tooltip anchored to an element |
| `LinkPreview` | Floating preview card for hyperlinks |
| `Command` | ⌘K-style command palette |
| `Combobox` | Autocomplete input with dropdown |
| `Select` | Single/multi-select dropdown |
### Form / Input
| Component | Description |
| ----------------- | -------------------------------- |
| `Button` | Unstyled button primitive |
| `Checkbox` | Checkbox input |
| `Label` | Accessible label |
| `RadioGroup` | Radio button group |
| `Switch` | Toggle switch |
| `Toggle` | Single toggle button |
| `ToggleGroup` | Grouped toggle buttons |
| `PinInput` | Split-digit code input |
| `Slider` | Range slider |
| `Meter` | Meter/guage display |
| `Progress` | Progress bar |
| `RatingGroup` | Star/score rating input |
| `Calendar` | Single date calendar |
| `RangeCalendar` | Date range calendar |
| `DateField` | Single date segmented input |
| `DateRangeField` | Date range segmented input |
| `DatePicker` | Calendar popover for single date |
| `DateRangePicker` | Calendar popover for date range |
| `TimeField` | Time segmented input |
| `TimeRangeField` | Time range segmented input |
### Layout / Display
| Component | Description |
| ------------- | ------------------------------- |
| `AspectRatio` | Fixed aspect ratio container |
| `Avatar` | Image placeholder with fallback |
| `Badge` | Inline status/label indicator |
| `Separator` | Horizontal/vertical divider |
| `Pagination` | Page navigation control |
| `ScrollArea` | Custom scrollbar container |
| `Toolbar` | Toolbar button group |
### Utilities & Type Helpers
| Export | Description |
| ------------------------ | ---------------------------------------------------------- |
| `mergeProps` | Merge multiple prop objects with handler chaining |
| `useId` | Generate unique IDs for accessibility wiring |
| `Portal` | Teleport content to a different DOM node |
| `isUsingKeyboard` | Track keyboard vs. mouse interaction |
| `BitsConfig` | Global Bits UI configuration provider |
| `computeCommandScore` | Score items for Command/Combobox filtering |
| `WithElementRef` | Type helper for element ref forwarding |
| `WithoutChild` | Type helper omitting the child snippet prop |
| `WithoutChildren` | Type helper omitting children snippet props |
| `WithoutChildrenOrChild` | Type helper omitting both child and children snippet props |
## Svelte 5 Runes Integration
Bits UI is built exclusively for Svelte 5 runes. It uses a **compound component pattern** (`Component.Root`, `Component.Item`, `Component.Trigger`, `Component.Content`, etc.) throughout.
### Bindable Props
Control component state with two-way bindable props:
```svelte
<script lang="ts">
import { Dialog } from 'bits-ui';
let open = $state(false);
</script>
<Dialog.Root bind:open>
<!-- ... -->
</Dialog.Root>
```
Common bindable props across components:
| Pattern | Example |
| ------------------ | ------------------------------------------ |
| `bind:open` | Dialog, Popover, DropdownMenu, Collapsible |
| `bind:value` | Select, Combobox, Tabs, RadioGroup |
| `bind:placeholder` | DateField, DatePicker |
| `bind:date` | Calendar, DatePicker |
### Callback Props
Bits UI uses an `on{EventName}Change` pattern for change callbacks:
```svelte
<Select.Root
bind:value
onValueChange={(val) => console.log('selected', val)}
>
```
## The `child` Snippet Pattern (Critical)
Bits UI does **not** use `<slot>`. Instead, it exposes a **`child` snippet** pattern for render delegation.
### Basic Usage
```svelte
<Accordion.Trigger>
{#snippet child({ props })}
<button {...props} class="my-custom-class">Trigger Label</button>
{/snippet}
</Accordion.Trigger>
```
The `child` snippet receives an object with `props` (and optionally `wrapperProps` for floating components). You must spread `{...props}` onto the root element of your rendered markup to wire up accessibility, events, and state attributes.
### Floating Components (Two-Level Wrapper)
For **floating components**`Popover`, `Tooltip`, `DropdownMenu`, `Select`, `Combobox`, `DatePicker`, `Command` — Bits UI requires a **two-level wrapper** inside the `child` snippet:
```svelte
<script lang="ts">
import { Popover, Button } from 'bits-ui';
let open = $state(false);
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button {...props} class="btn-cyan">Menu</button>
{/snippet}
</Popover.Trigger>
<Popover.Content>
{#snippet child({ wrapperProps, props })}
<div {...wrapperProps}>
<div {...props} class="shadow-card w-48 border border-slate-700 bg-slate-900 p-2">
<!-- Popover content -->
</div>
</div>
{/snippet}
</Popover.Content>
</Popover.Root>
```
The snippet receives:
- `wrapperProps` — spread onto the outermost positioning wrapper
- `props` — spread onto the actual UI element
- `open` — current open state (available but not needed for conditional rendering)
⚠️ **Do NOT wrap floating content in `{#if open}`** — bits-ui's `PresenceManager` handles visibility internally. Premature DOM removal via `{#if open}` breaks the animation lifecycle and causes `ScrollLock` to persist indefinitely (`document.body.style.pointerEvents = "none"` never gets cleared). See [Anti-Patterns](#anti-patterns--pitfalls).
## Tailwind CSS v4 Integration
Bits UI works naturally with the project's Tailwind v4 setup (see [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md)).
### Passing Classes
Pass `class` directly to any Bits UI component — they forward it to the root DOM element:
```svelte
<Dialog.Content class="fixed inset-0 z-50 flex items-center justify-center">
```
### Data-Attribute Selectors
Bits UI components expose `data-*` attributes on their root elements for state-driven styling:
| Attribute | Applies When |
| ------------------------- | ---------------------------------- |
| `[data-state="open"]` | Component is open/expanded |
| `[data-state="closed"]` | Component is closed/collapsed |
| `[data-state="active"]` | Tab or item is active |
| `[data-state="inactive"]` | Tab or item is inactive |
| `[data-disabled]` | Component or item is disabled |
| `[data-highlighted]` | Item is highlighted (keyboard nav) |
| `[data-selected]` | Item is selected |
Example usage in a CSS block:
```css
[data-state='open'] {
--tw-ring-color: var(--color-cyan-500);
}
```
### CSS Variables for Layout
Bits UI exposes CSS variables for measuring dynamic content:
| Variable | Component |
| --------------------------------- | ------------------- |
| `--bits-accordion-content-height` | `Accordion.Content` |
| `--bits-select-anchor-width` | `Select.Content` |
| `--bits-tooltip-trigger-width` | `Tooltip` |
| `--bits-popover-anchor-width` | `Popover.Content` |
### Animations
Use `data-starting-style` and `data-ending-style` transient attributes for CSS-based enter/exit animations. These attributes are present only during the animation frame and are automatically removed.
## Project-Specific Integration
This project's technology choices align well with Bits UI:
| Project Trait | Bits UI Compatibility |
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Tailwind CSS v4** (CSS-first config) | `class` prop pattern works directly — no `tailwind.config.js` required |
| **Svelte 5 runes only** (see [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md)) | Natively compatible — Bits UI is built on runes |
| **No `tailwind.config.js`** | Bits UI does not require one |
| **Colors follow brand palette** | Use existing design tokens (see [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md)) |
| **Lucide icons** (see [`icons.instructions.md`](.github/instructions/icons.instructions.md)) | Pass icon components as snippets to Bits UI components |
### Icon Pattern with Bits UI
Render Lucide SVG strings inside child snippets:
```svelte
<Accordion.Trigger>
{#snippet child({ props })}
<button {...props} class="flex items-center gap-2">
<!-- svelte-ignore no-at-html-tags -->
<!-- svelte-ignore a11y_no_svg_body -->
{@html ChevronDownIcon}
<span>Section Title</span>
</button>
{/snippet}
</Accordion.Trigger>
```
### `{@html}` for Inline SVGs — Last-Resort Pattern
The use of `{@html}` to render inline SVG strings is a **last-resort pattern** in this project. Follow the icon hierarchy below:
1. **`@lucide/svelte` components** — preferred for all UI icons (e.g., `<Filter size={16} />`, `<ChevronDown size={16} />`)
2. **`simple-icons`** — for brand icons (social media platforms, tech logos)
3. **Inline SVG via `{@html}`** — only for Bits UI child snippets where component pass-through isn't possible
> See [`icons.instructions.md`](.github/instructions/icons.instructions.md) for the full icon hierarchy and usage guidelines.
## Existing Component Overlap & Migration Guidance
The following custom components may have overlapping functionality with Bits UI. Migration should be **phased and deliberate** — never replace a tested, working component without a clear benefit.
| Component Type | Bits UI Equivalent | Priority | Rationale |
| ------------------- | ------------------------- | -------- | ------------------------------------------------------------------ |
| Custom modal/dialog | `Dialog` | Medium | Accessibility upgrade (focus trapping, ARIA, keyboard dismiss) |
| Tabs/accordion | `Tabs` / `Accordion` | Low | Existing implementation works; reconsider if a11y issues arise |
| Custom select | `Select` | Medium | Accessibility upgrade (keyboard nav, ARIA combobox pattern) |
| Custom button | `Button` | Low | Keep project-specific variants |
| (none) | `Popover` | **High** | New capability — useful for dropdown menus, filters, quick-actions |
| (none) | `Tooltip` | **High** | New capability — useful for icon-only buttons and truncated text |
| (none) | `Calendar` / `DatePicker` | Medium | New capability — date picking for scheduling |
| (none) | `DropdownMenu` | **High** | New capability — contextual menus |
### Migration Rules
1. **Never migrate a tested, working component** solely for library adoption — there must be a measurable benefit (accessibility, maintainability, feature gap)
2. When migrating, **keep the old component in place** during a transition period — do not break existing consumers
3. Wrap Bits UI components in project-specific wrappers if the same styling is used in many places (e.g., `AppPopover.svelte` that pre-applies project styling)
4. Run existing tests after any migration to confirm nothing is broken
## Usage Examples with Project Styling
### Dialog — Confirmation Modal
```svelte
<script lang="ts">
import { Dialog, Button } from 'bits-ui';
let open = $state(false);
</script>
<Button onmousedown={() => (open = true)} class="btn-cyan">Open Dialog</Button>
<Dialog.Root bind:open>
<Dialog.Content class="fixed inset-0 z-50 flex items-center justify-center">
{#snippet child({ wrapperProps, props })}
<div {...wrapperProps}>
<div
{...props}
class="shadow-card w-full max-w-md border-2 border-slate-700 bg-slate-900/95 p-6 backdrop-blur-md"
>
<Dialog.Header>
<Dialog.Title class="text-lg font-bold text-slate-100">Confirm Action</Dialog.Title>
<Dialog.Description class="mt-1 text-sm text-slate-400">
Are you sure you want to proceed? This action cannot be undone.
</Dialog.Description>
</Dialog.Header>
<div class="mt-4 flex justify-end gap-3">
<Button class="btn-ghost" onmousedown={() => (open = false)}>Cancel</Button>
<Button class="btn-cyan">Confirm</Button>
</div>
</div>
</div>
{/snippet}
</Dialog.Content>
</Dialog.Root>
```
### Popover — Filter Dropdown
```svelte
<script lang="ts">
import { Popover, Button } from 'bits-ui';
import { Filter } from '@lucide/svelte';
let open = $state(false);
let selectedFilter = $state('All');
</script>
<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<button {...props} class="btn-ghost flex items-center gap-2">
<Filter size={16} />
{selectedFilter}
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content>
{#snippet child({ wrapperProps, props })}
<div {...wrapperProps}>
<div {...props} class="shadow-card w-56 border-2 border-slate-700 bg-slate-900 p-2">
<p class="mb-2 px-2 text-xs font-semibold tracking-wider text-slate-400 uppercase">
Filter by Status
</p>
{#each ['All', 'Published', 'Draft', 'Archived'] as option}
<button
class="w-full px-2 py-1.5 text-left text-sm text-slate-200
hover:bg-slate-800 data-[highlighted]:bg-slate-800"
onmousedown={() => {
selectedFilter = option;
open = false;
}}
>
{option}
</button>
{/each}
</div>
</div>
{/snippet}
</Popover.Content>
</Popover.Root>
```
### Accordion — FAQ / Settings Sections
```svelte
<script lang="ts">
import { Accordion } from 'bits-ui';
import { ChevronDown } from '@lucide/svelte';
let value = $state<string | undefined>(undefined);
</script>
<Accordion.Root bind:value class="flex flex-col gap-2">
<Accordion.Item value="item-1" class="border-2 border-slate-700">
<Accordion.Trigger>
{#snippet child({ props, open })}
<button
{...props}
class="flex w-full items-center justify-between bg-slate-800 px-4 py-3
text-sm font-medium text-slate-200"
>
<span>Section One</span>
<span class:rotate-180={open} class="transition-transform duration-200">
<ChevronDown size={16} />
</span>
</button>
{/snippet}
</Accordion.Trigger>
<Accordion.Content>
{#snippet child({ props })}
<div {...props} class="border-t-2 border-slate-700 px-4 py-3 text-sm text-slate-400">
Content for section one.
</div>
{/snippet}
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2" class="border-2 border-slate-700">
<Accordion.Trigger>
{#snippet child({ props, open })}
<button
{...props}
class="flex w-full items-center justify-between bg-slate-800 px-4 py-3
text-sm font-medium text-slate-200"
>
<span>Section Two</span>
<span class:rotate-180={open} class="transition-transform duration-200">
<ChevronDown size={16} />
</span>
</button>
{/snippet}
</Accordion.Trigger>
<Accordion.Content>
{#snippet child({ props })}
<div {...props} class="border-t-2 border-slate-700 px-4 py-3 text-sm text-slate-400">
Content for section two.
</div>
{/snippet}
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
```
## Anti-Patterns / Pitfalls
-**Don't use Bits UI with Svelte 4** — it requires Svelte 5 runes and will not work
-**Don't use `<slot>` inside Bits UI components** — always use `{#snippet child(...)}` instead (see [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md))
-**Don't forget the two-level wrapper for floating components**`Popover`, `Tooltip`, `DropdownMenu`, `Select`, `Combobox`, `DatePicker`, and `Command` content snippets receive both `wrapperProps` and `props`
-**Don't wrap floating content in `{#if open}`** — bits-ui's `PresenceManager` handles visibility internally. Premature DOM removal via `{#if open}` prevents `AnimationsComplete.run()` from firing its completion callback, which causes `ScrollLock` to persist indefinitely (`document.body.style.pointerEvents = "none"` and `document.body.style.overflow = "hidden"` never get cleared). This was a critical bug discovered and fixed across all floating components during Batch 5.
-**Don't override internal handlers without `mergeProps`** — if you need to add your own event handlers while keeping Bits UI's internal handlers, import `mergeProps` from `"bits-ui"`
-**Don't import from deep subpaths** — always import from `"bits-ui"` (e.g., `import { Dialog } from "bits-ui"`, not `import Dialog from "bits-ui/dialog"`)
## Resources
| Resource | Link |
| ----------------------------- | -------------------------------------------------------------------- |
| Full Documentation (LLMs.txt) | [bits-ui.com/docs/llms.txt](https://bits-ui.com/docs/llms.txt) |
| GitHub Repository | [github.com/huntabyte/bits-ui](https://github.com/huntabyte/bits-ui) |
| Author | Hunter Johnston ([@huntabyte](https://github.com/huntabyte)) |
@@ -0,0 +1,198 @@
---
description: 'Use when implementing or modifying CDN storage, image upload, asset management, or URL construction for The Collective Hub. Covers the CDN helper, upload flow, image processing with sharp, and asset library patterns.'
applyTo: 'src/lib/server/cdn.ts', 'src/routes/api/assets/*.ts', 'src/routes/admin/assets/*.svelte'
---
# CDN & Assets
## Architecture
The Collective Hub stores **only CDN keys (paths) in the database**. Full URLs are constructed at render time using the `CDN_BASE_URL` environment variable.
```
Database stores: "sites/bad-movies-theater/logo.webp"
CDN_BASE_URL: "https://cdn.example.com"
Full URL: "https://cdn.example.com/sites/bad-movies-theater/logo.webp"
```
This means:
- **No URL updates needed** if the CDN provider changes — just change `CDN_BASE_URL`
- **No full URLs in the database** — ever
- **CDN migration is trivial** — one env var change
## CDN Path Convention
```
sites/{siteSlug}/{type}/{filename}
```
Examples:
```
sites/bad-movies-theater/logo.webp
sites/bad-movies-theater/background.webp
sites/bad-movies-theater/events/movie-night-june.webp
sites/garbage-day/logo.webp
```
## CDN Helper
See [`src/lib/server/cdn.ts`](src/lib/server/cdn.ts):
```ts
import { env } from '$env/dynamic/private';
const BASE_URL = env.CDN_BASE_URL;
/**
* Construct a full CDN URL from a key.
* Returns null if the key is null or empty.
*/
export function cdnUrl(key: string | null): string | null {
if (!key) return null;
// Ensure no double slashes
const cleanKey = key.startsWith('/') ? key.slice(1) : key;
return `${BASE_URL}/${cleanKey}`;
}
/**
* Construct a CDN key for a given site, type, and filename.
*/
export function cdnKey(siteSlug: string, type: string, filename: string): string {
return `sites/${siteSlug}/${type}/${filename}`;
}
```
## Upload Flow
```
User selects file
→ Client-side validation (type, size)
→ POST to /api/assets/upload (multipart form)
→ Server-side validation
→ sharp converts to webp + optimizes
→ Upload to CDN storage
→ Create asset record in database
→ Return asset URL
```
### Server-Side Upload Handler
```ts
// src/routes/api/assets/+server.ts
import { json } from '@sveltejs/kit';
import sharp from 'sharp';
import { env } from '$env/dynamic/private';
import { db } from '$lib/server/db';
import { assets } from '$lib/server/db/schema';
import { cdnKey } from '$lib/server/cdn';
export async function POST({ locals, request }) {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const form = await request.formData();
const file = form.get('file') as File;
if (!file) {
return json({ error: 'No file provided' }, { status: 400 });
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
return json({ error: 'Invalid file type' }, { status: 400 });
}
// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
return json({ error: 'File too large' }, { status: 400 });
}
// Convert to webp and optimize
const buffer = Buffer.from(await file.arrayBuffer());
const webpBuffer = await sharp(buffer)
.webp({ quality: 80, lossless: false })
.toBuffer();
// Generate CDN key
const filename = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}.webp`;
const key = cdnKey(locals.siteSlug, 'uploads', filename);
// Upload to CDN (implementation depends on CDN provider)
// await uploadToCdn(key, webpBuffer, 'image/webp');
// Create asset record
const [asset] = await db.insert(assets).values({
siteId: locals.site.id,
uploadedByUserId: locals.user.id,
type: 'image',
filename: file.name,
mimeType: 'image/webp',
size: webpBuffer.length,
cdnKey: key,
altText: (form.get('altText') as string) || '',
}).returning();
return json({ asset, url: cdnUrl(key) });
}
```
## Image Processing with Sharp
All uploaded images are converted to **webp** format:
```ts
import sharp from 'sharp';
// Convert to webp with quality optimization
const webpBuffer = await sharp(inputBuffer)
.webp({ quality: 80, lossless: false })
.toBuffer();
// Resize if needed (e.g., for hero backgrounds)
const resized = await sharp(inputBuffer)
.resize(1920, 1080, { fit: 'cover', position: 'centre' })
.webp({ quality: 80 })
.toBuffer();
```
## Asset Library (Admin)
The assets page in the admin panel allows site owners to:
1. **Browse** all uploaded assets for their site
2. **Upload** new files (drag-and-drop or file picker)
3. **Copy CDN URLs** for use in branding/content settings
4. **Delete** assets (with confirmation)
5. **Edit alt text** for accessibility
```svelte
<!-- Example: Asset grid item -->
<script lang="ts">
let { asset }: { asset: Asset } = $props();
let copied = $state(false);
function copyUrl() {
navigator.clipboard.writeText(asset.cdnUrl);
copied = true;
setTimeout(() => copied = false, 2000);
}
</script>
<div class="border rounded-lg p-2">
<img src={asset.cdnUrl} alt={asset.altText} class="w-full h-32 object-cover rounded" />
<p class="text-xs mt-1 truncate">{asset.filename}</p>
<button onclick={copyUrl} class="text-xs text-blue-500 mt-1">
{copied ? 'Copied!' : 'Copy URL'}
</button>
</div>
```
## Key Rules
1. **Never store full CDN URLs** in the database — only keys
2. **Never hardcode `CDN_BASE_URL`** in code — always use `env.CDN_BASE_URL`
3. **Always validate file types and sizes** on the server — never trust client validation alone
4. **Always convert to webp** for consistent format and smaller files
5. **Always scope assets by `siteId`** in queries
6. **CDN keys must not start with `/`** — the helper handles this, but be consistent
@@ -0,0 +1,123 @@
---
description: 'Use when creating, editing, or organizing Svelte components for The Collective Hub. Enforces folder structure, naming, prop typing, and composition conventions for src/lib/components/.'
applyTo: 'src/lib/components/**/*.svelte'
---
# Component Conventions
## Folder Structure
```
src/lib/components/
ui/ ← Generic, reusable UI primitives (no business logic)
layout/ ← Structural shell components (Header, Footer, Navigation)
```
Place new components in the most specific folder:
- If it's a general-purpose display or form element → `ui/`
- If it's a page-level structural shell → `layout/`
- Do **not** create new folders without a clear category need
## Naming
- Files: **PascalCase**`SectionCard.svelte`, `StatusBadge.svelte`
- One component per file
- Name reflects what it **is**, not what it **does**: `StatCard` not `DisplayStat`
## Imports
Always import from the full `$lib/components/...` path — do not use relative paths from within `src/`:
```ts
// ✅
import SectionCard from '$lib/components/ui/SectionCard.svelte';
// ❌
import SectionCard from '../components/ui/SectionCard.svelte';
```
## Props
Type props inline using `$props<{...}>()` — do not use a separate `interface` or `type` alias unless it's shared across multiple components:
```svelte
<script lang="ts">
let {
title,
description = undefined,
children
} = $props<{
title: string;
description?: string;
children: Snippet;
}>();
</script>
```
- Optional props must have a default value (`= undefined` or a real default)
- Use `Snippet` from `'svelte'` for composable markup slots
- Use `Snippet<[T]>` for typed render props (e.g., row renderers in `DataTable`)
## Composition: Snippets over Slots
Accept content via `Snippet` props. Common pattern:
```svelte
<!-- SectionCard: accepts children + optional headerAction -->
{#if headerAction}
{@render headerAction()}
{/if}
{@render children()}
```
Named snippets in the consumer:
```svelte
{#snippet headerAction()}
<button class="btn-ghost">Export</button>
{/snippet}
<SectionCard title="Movies" {headerAction}>
<!-- body -->
</SectionCard>
```
> **Note on dynamic components:** In Svelte 5, `<svelte:component>` is deprecated. Use `{@const ComponentVar = ...}` in the template and render it as `<ComponentVar />` instead. See [`svelte5.instructions.md`](svelte5.instructions.md#deprecated-apis) for details.
### No `svelte-ignore` Suppression
`svelte-ignore` comments must NOT be used to suppress svelte-check errors or warnings. All accessibility and type issues must be fixed properly — never hidden behind a suppression comment.
## Admin Components
Admin-specific components live alongside their route pages in `src/routes/admin/` rather than in `src/lib/components/`. Shared admin UI patterns (form fields, buttons, layouts) should be placed in `src/lib/components/admin/` if reused across multiple admin pages.
## Current Component Inventory
| Component | Path | Description |
|-----------|------|-------------|
| Public site sections | [route-level](src/routes/) | Hero, About, Events, Social Links — defined in the route or as page-specific components |
| Layout components | [`$lib/components/layout/`](src/lib/components/layout/) | Header, Footer, Navigation |
| UI primitives | [`$lib/components/ui/`](src/lib/components/ui/) | Container, SectionHeading, buttons, cards |
> The component inventory evolves as the platform grows. When adding a new component, place it in the most specific folder: `ui/` for generic UI, `layout/` for structural shells, or a route directory for page-specific components.
## Visual Style
All components follow The Collective Hub theme system — see [`public-site-theming.instructions.md`](public-site-theming.instructions.md) for CSS custom property conventions and [`tailwindcss.instructions.md`](tailwindcss.instructions.md) for token and color usage.
Use CSS custom properties for theme-aware colors:
```svelte
<div
class="rounded-lg p-4"
style="background-color: var(--bg); color: var(--text);"
>
<h2 style="color: var(--accent);">Section Title</h2>
...
</div>
```
- `rounded-lg` — standard border radius
- Use CSS custom properties for all colors (never hardcode)
- Use consistent spacing and alignment
@@ -0,0 +1,338 @@
---
description: 'Use when creating or modifying database tables, writing Drizzle queries, adding migrations, or understanding the schema. Covers all tables, relationships, indexes, migration strategy, and Drizzle ORM patterns for The Collective Hub.'
applyTo: 'src/lib/server/db/**/*.ts', 'drizzle/**/*.sql'
---
# Database Schema
## Design Principles
- **`siteId` on every site-owned table** — non-negotiable
- **Prefer normalized tables over JSON columns** — except for theme/branding settings where flexibility is valuable
- **Timestamps on every table** (`createdAt`, `updatedAt`)
- **Use UUIDs for primary keys** — avoids sequential ID enumeration and works well distributed
- **Index `siteId` on every table that has it** — it's the most common query filter
- **Soft deletes where appropriate** — prefer `deletedAt` over hard deletes
## Entity Relationship
```mermaid
erDiagram
sites ||--o{ memberships : "has"
sites ||--|| siteSettings : "has"
sites ||--o{ assets : "owns"
sites ||--o{ navLinks : "has"
sites ||--o{ socialLinks : "has"
sites ||--o{ events : "hosts"
users ||--o{ memberships : "has"
users ||--o{ assets : "uploads"
sites {
uuid id PK
text slug UK
text name
boolean isActive
}
users {
uuid id PK
text discordId UK
text discordUsername
text discordAvatar
}
memberships {
uuid id PK
uuid siteId FK
uuid userId FK
enum role
}
siteSettings {
uuid id PK
uuid siteId FK UK
jsonb settings
}
events {
uuid id PK
uuid siteId FK
text title
timestamptz startTime
boolean isPublished
}
```
## Table Reference
### `sites`
The core tenant table. One row per deployed site.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | `defaultRandom()` |
| `slug` | `text` (UNIQUE, NOT NULL) | Matches `SITE_SLUG` env var |
| `name` | `text` (NOT NULL) | Display name |
| `isActive` | `boolean` (default true) | Soft disable a site |
| `createdAt` | `timestamptz` | `defaultNow()` |
| `updatedAt` | `timestamptz` | `defaultNow()` + `$onUpdate()` |
**Indexes:** UNIQUE on `slug`.
### `users`
Auth users. Created automatically on first Discord login.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | `defaultRandom()` |
| `discordId` | `text` (UNIQUE, NOT NULL) | Discord user ID |
| `discordUsername` | `text` | Display name from Discord |
| `discordAvatar` | `text` | Avatar hash/URL from Discord |
| `email` | `text` | If available from Discord scope |
| `createdAt` | `timestamptz` | |
| `updatedAt` | `timestamptz` | |
| `lastLoginAt` | `timestamptz` | |
**Indexes:** UNIQUE on `discordId`.
> Note: Better Auth manages its own session/account tables. The `users` table here is the application-level user profile.
### `memberships`
Links users to sites with a role. A user can be a member of multiple sites with different roles.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | |
| `siteId` | `uuid``sites.id` (NOT NULL) | |
| `userId` | `uuid``users.id` (NOT NULL) | |
| `role` | `enum('owner', 'admin', 'editor')` | See role definitions |
| `createdAt` | `timestamptz` | |
| `updatedAt` | `timestamptz` | |
**Indexes:** UNIQUE on `(siteId, userId)`. INDEX on `siteId`. INDEX on `userId`.
### `siteSettings`
Single JSON column per site for all configuration.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | |
| `siteId` | `uuid``sites.id` (UNIQUE, NOT NULL) | One settings row per site |
| `settings` | `jsonb` (NOT NULL, default `{}`) | All site settings as JSON |
| `createdAt` | `timestamptz` | |
| `updatedAt` | `timestamptz` | |
**Indexes:** UNIQUE on `siteId`.
The `settings` JSON structure (typed in [`src/lib/shared/types.ts`](src/lib/shared/types.ts)):
```typescript
interface SiteSettingsData {
branding: {
siteName: string;
tagline: string;
logoCdnKey: string | null;
backgroundCdnKey: string | null;
faviconCdnKey: string | null;
};
theme: {
preset: 'dark' | 'light' | 'custom';
accentColor: string;
backgroundColor: string;
textColor: string;
};
homepage: {
heroTitle: string;
heroSubtitle: string;
aboutText: string;
primaryButtonText: string;
primaryButtonLink: string;
showNextEvent: boolean;
showSchedule: boolean;
};
layout: {
preset: 'standard';
};
}
```
**Why JSON for settings?** Settings are read as a batch, rarely queried individually, and benefit from schema flexibility. Adding a new setting requires no migration.
### `assets`
Records of uploaded media files stored in the CDN.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | |
| `siteId` | `uuid``sites.id` (NOT NULL) | |
| `uploadedByUserId` | `uuid``users.id` | Nullable for system assets |
| `type` | `text` (NOT NULL) | e.g., `image`, `document` |
| `filename` | `text` (NOT NULL) | Original filename |
| `mimeType` | `text` | e.g., `image/webp` |
| `size` | `integer` | Bytes |
| `cdnKey` | `text` (NOT NULL) | Path within CDN bucket |
| `altText` | `text` | Accessibility description |
| `createdAt` | `timestamptz` | |
| `updatedAt` | `timestamptz` | |
**Indexes:** INDEX on `siteId`. INDEX on `cdnKey`.
### `navLinks`
Custom navigation links for a site's header/footer.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | |
| `siteId` | `uuid``sites.id` (NOT NULL) | |
| `label` | `text` (NOT NULL) | Display text |
| `url` | `text` (NOT NULL) | Link target |
| `position` | `text` (default `'header'`) | `header` or `footer` |
| `sortOrder` | `integer` (default 0) | Ordering within position |
| `isExternal` | `boolean` (default true) | Open in new tab? |
| `createdAt` | `timestamptz` | |
| `updatedAt` | `timestamptz` | |
**Indexes:** INDEX on `(siteId, position, sortOrder)`.
### `socialLinks`
Social media / external platform links.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | |
| `siteId` | `uuid``sites.id` (NOT NULL) | |
| `platform` | `text` (NOT NULL) | e.g., `discord`, `twitter`, `youtube`, `twitch` |
| `label` | `text` | Display label, defaults to platform name |
| `url` | `text` (NOT NULL) | |
| `icon` | `text` | Icon identifier if custom |
| `sortOrder` | `integer` (default 0) | |
| `createdAt` | `timestamptz` | |
| `updatedAt` | `timestamptz` | |
**Indexes:** INDEX on `(siteId, sortOrder)`.
### `events`
Scheduled events / watch parties / screenings.
| Column | Type | Notes |
|--------|------|-------|
| `id` | `uuid` (PK) | |
| `siteId` | `uuid``sites.id` (NOT NULL) | |
| `title` | `text` (NOT NULL) | |
| `description` | `text` | |
| `eventType` | `text` (default `'screening'`) | `screening`, `watch_party`, `meetup`, `other` |
| `startTime` | `timestamptz` (NOT NULL) | |
| `endTime` | `timestamptz` | Optional duration |
| `timezone` | `text` (default `'America/New_York'`) | IANA timezone |
| `location` | `text` | e.g., "Discord Stage", "VR Chat" |
| `externalLink` | `text` | Link to event page, stream |
| `imageCdnKey` | `text` | Optional event image |
| `isPublished` | `boolean` (default false) | Draft mode |
| `isRecurring` | `boolean` (default false) | Placeholder for future |
| `createdAt` | `timestamptz` | |
| `updatedAt` | `timestamptz` | |
**Indexes:** INDEX on `(siteId, startTime)`. INDEX on `(siteId, isPublished)`.
## Drizzle Patterns
### Schema Definition ([`src/lib/server/db/schema.ts`](src/lib/server/db/schema.ts))
```ts
import { pgTable, uuid, text, boolean, timestamp, integer, jsonb, index, uniqueIndex, pgEnum } from 'drizzle-orm/pg-core';
export const roleEnum = pgEnum('role', ['owner', 'admin', 'editor']);
export const events = pgTable(
'events',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
// ...
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
},
(table) => [
index('events_site_id_start_time_idx').on(table.siteId, table.startTime),
]
);
```
### Querying with Site Scope
```ts
import { eq, and, desc, asc } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { events } from '$lib/server/db/schema';
// Scoped to current site
const upcomingEvents = await db
.select()
.from(events)
.where(
and(
eq(events.siteId, locals.site.id),
eq(events.isPublished, true),
gte(events.startTime, new Date())
)
)
.orderBy(asc(events.startTime));
```
### Inserting
```ts
const [newEvent] = await db
.insert(events)
.values({
siteId: locals.site.id,
title: 'Bad Movie Night',
startTime: new Date('2025-06-15T20:00:00Z'),
isPublished: false,
})
.returning();
```
### Updating
```ts
await db
.update(events)
.set({ title: 'Updated Title', isPublished: true })
.where(
and(
eq(events.id, eventId),
eq(events.siteId, locals.site.id) // NEVER forget this!
)
);
```
## Migration Strategy
1. **Additive changes only** in production — new columns, new tables. Avoid renames or destructive changes.
2. **Make schema changes in `schema.ts` first**, then generate the migration:
```bash
npx drizzle-kit generate
```
3. **Apply via the primary deployment** (the one with `RUN_MIGRATIONS=true`)
4. **JSON columns for settings** reduce migration frequency for feature additions
5. **Seed script** at [`scripts/seed.mjs`](scripts/seed.mjs) for local dev setup
## What This Schema Intentionally Avoids (V1)
- **No `accounts` or `sessions` tables** — Better Auth manages those
- **No `pages` table** — homepage content lives in `siteSettings.homepage` JSON
- **No `reviews`, `comments`, `posts` tables** — future phases
- **No `featureFlags` table** — use env vars or settings JSON
- **No `domains` table** — single `SITE_SLUG` resolution in V1
- **No `auditLog` table** — future phase
- **No `invitations` table** — owner adds admins directly
@@ -0,0 +1,132 @@
---
description: 'Use when deploying a new site instance, setting up Coolify, configuring Docker, managing migrations, or troubleshooting deployment issues for The Collective Hub.'
applyTo: 'Dockerfile', 'docker-compose.yml', '.dockerignore', 'Coolify'
---
# Deployment Guide
## Deployment Model
The Collective Hub uses **multiple Coolify deployments** from a single Git repository. Each deployment runs the same Docker image but is configured with different environment variables — most importantly, a different `SITE_SLUG`.
```
Git Repo (main branch)
├── Coolify Deployment "bad-movies-theater"
│ ├── SITE_SLUG=bad-movies-theater
│ ├── PUBLIC_SITE_URL=https://badmovies.example.com
│ ├── OWNER_DISCORD_ID=... (site owner)
│ └── RUN_MIGRATIONS=false
├── Coolify Deployment "garbage-day"
│ ├── SITE_SLUG=garbage-day
│ ├── PUBLIC_SITE_URL=https://garbageday.example.com
│ ├── OWNER_DISCORD_ID=... (site owner)
│ └── RUN_MIGRATIONS=false
└── Coolify Deployment "primary" (migration runner)
├── SITE_SLUG=primary
├── PUBLIC_SITE_URL=https://primary.example.com
├── OWNER_DISCORD_ID=... (David's Discord ID)
└── RUN_MIGRATIONS=true
```
## Docker
The project uses a multi-stage Docker build (see [`Dockerfile`](Dockerfile)):
```dockerfile
# Stage 1: Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:22-alpine AS runner
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
CMD ["node", "build"]
```
### Local Development with Docker Compose
See [`docker-compose.yml`](docker-compose.yml) for local development setup, which includes:
- Postgres database container
- The SvelteKit app container
- Proper environment variables for local dev
```bash
docker compose up
```
## Adding a New Site
To add a new community/theater site:
1. **Insert a site row in the database:**
```sql
INSERT INTO sites (slug, name) VALUES ('new-community', 'New Community');
INSERT INTO site_settings (site_id, settings) VALUES (
(SELECT id FROM sites WHERE slug = 'new-community'),
'{"branding":{"siteName":"New Community","tagline":"Coming soon"},"theme":{"preset":"dark","accentColor":"#e63946","backgroundColor":"#1a1a2e","textColor":"#eaeaea"},"homepage":{"heroTitle":"Welcome","heroSubtitle":"","aboutText":"","primaryButtonText":"Join us on Discord","primaryButtonLink":"","showNextEvent":true,"showSchedule":true},"layout":{"preset":"standard"}}'
);
```
Or use the seed script pattern.
2. **Create a new Coolify deployment:**
- Point to the same Git repo + branch
- Set `SITE_SLUG=new-community`
- Set `PUBLIC_SITE_URL=https://newcommunity.example.com`
- Set `OWNER_DISCORD_ID` to the site owner's Discord user ID
- Set `RUN_MIGRATIONS=false` (unless this is the designated migration runner)
3. **Configure DNS:** Point the domain to the Coolify deployment's IP/URL
## Environment Variables Per Deployment
Each deployment needs its own set of env vars. See [`docs/04-environment-variables.md`](docs/04-environment-variables.md) for the full reference.
**Rules:**
- Shared vars (`DATABASE_URL`, `DISCORD_CLIENT_ID`, `CDN_BASE_URL`) must be identical across all deployments
- Per-site vars (`SITE_SLUG`, `PUBLIC_SITE_URL`, `OWNER_DISCORD_ID`) are unique per deployment
- `RUN_MIGRATIONS=true` on exactly one deployment (the primary/migration runner)
## Migration Runner
The migration runner deployment is critical:
- It runs `RUN_MIGRATIONS=true` and executes schema migrations on startup
- It must be deployed **first** when schema changes are included in a release
- Other deployments should be deployed after the migration completes
- If the migration runner is down, other deployments still serve traffic (they just won't have schema changes)
## Coolify Configuration
In Coolify, each deployment:
- Uses the **same Git repository** and branch
- Has its own **unique environment variables**
- Can have its own **domain/SSL configuration**
- Is deployed independently (zero-downtime per deployment)
### Deploy Strategy
1. Deploy the migration runner first (`RUN_MIGRATIONS=true`)
2. Wait for it to be healthy (migrations complete)
3. Deploy other sites in any order
4. Each deployment is independent — one failing doesn't affect others
## Troubleshooting
| Symptom | Likely Cause | Fix |
|---------|-------------|-----|
| "Site not found" on load | No matching row in `sites` table for `SITE_SLUG` | Insert the site row or fix `SITE_SLUG` env var |
| Login redirects to wrong URL | `PUBLIC_SITE_URL` / `BETTER_AUTH_URL` mismatch | Ensure both match the deployment's actual URL |
| "Already exists" errors on deploy | Two deployments running migrations simultaneously | Check only one has `RUN_MIGRATIONS=true` |
| Images not loading | `CDN_BASE_URL` missing or wrong, or `cdnKey` starts with `/` | Ensure no leading slash on CDN keys |
| Auth callbacks failing | Discord OAuth redirect URI doesn't match deployment URL | Add the correct URL to Discord Developer Portal |
@@ -0,0 +1,110 @@
---
description: 'Use when maintaining project documentation in the docs/ directory. Covers guidelines for updating docs when code changes, keeping docs in sync with implementation, when to create new docs vs update existing ones, and the documentation review process for The Collective Hub.'
applyTo: 'docs/*.md'
---
# Documentation Workflow
> This instruction file covers conventions for maintaining the project documentation in [`docs/`](docs/). The Collective Hub's documentation is technical project documentation — architecture plans, database schemas, UX plans, environment variable references, and development roadmaps.
## Overview
The `docs/` directory contains the canonical technical reference for The Collective Hub:
| File | Purpose |
| ---- | ------- |
| [`00-project-brief.md`](docs/00-project-brief.md) | Project overview — what The Collective Hub is and who it's for |
| [`01-architecture-plan.md`](docs/01-architecture-plan.md) | Technical architecture — stack, site resolution, deployment model |
| [`02-database-plan.md`](docs/02-database-plan.md) | Database schema — all tables, relationships, migration strategy |
| [`03-feature-roadmap.md`](docs/03-feature-roadmap.md) | Feature phases — Phase 1 (Foundation) through Phase 4 |
| [`04-environment-variables.md`](docs/04-environment-variables.md) | Environment variable reference — shared vs per-site |
| [`05-admin-ux-plan.md`](docs/05-admin-ux-plan.md) | Admin panel UX — layout, navigation, settings pages |
| [`06-public-site-ux-plan.md`](docs/06-public-site-ux-plan.md) | Public site UX — hero, about, events, social links, footer |
| [`07-development-plan.md`](docs/07-development-plan.md) | Development workflow — git flow, Docker, testing, deployment |
| [`08-open-questions.md`](docs/08-open-questions.md) | Open questions — decisions deferred for later resolution |
| [`09-risks-and-rules.md`](docs/09-risks-and-rules.md) | Critical risks — migration conflicts, data leaks, deployment strategy |
## When to Update Documentation
### Code Changes That Require a Docs Update
Update the relevant `docs/*.md` file when making code changes that affect:
- **Database schema** — new tables, columns, indexes, or relationship changes → update [`02-database-plan.md`](docs/02-database-plan.md) and [`database-schema.instructions.md`](.github/instructions/database-schema.instructions.md)
- **Architecture** — site resolution flow, env vars, deployment model → update [`01-architecture-plan.md`](docs/01-architecture-plan.md) and [`04-environment-variables.md`](docs/04-environment-variables.md)
- **Environment variables** — new required env vars → update [`04-environment-variables.md`](docs/04-environment-variables.md) and the `.env` stub in [`ci-web.yml`](.github/workflows/ci-web.yml)
- **Admin panel** — new admin pages, changed settings flow → update [`05-admin-ux-plan.md`](docs/05-admin-ux-plan.md) and [`admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md)
- **Public site** — new sections, changed rendering logic → update [`06-public-site-ux-plan.md`](docs/06-public-site-ux-plan.md) and [`public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md)
- **Feature roadmap** — new features, changed priorities, phase shifts → update [`03-feature-roadmap.md`](docs/03-feature-roadmap.md)
- **Risks** — newly discovered risks or changed mitigations → update [`09-risks-and-rules.md`](docs/09-risks-and-rules.md)
### When to Update vs Create
| Situation | Action |
| --------- | ------ |
| A feature or configuration covered by an existing doc changes | **Update** the existing file |
| A new aspect of the project needs documentation that fits an existing doc | **Update** the existing file with a new section |
| A fundamentally new concern emerges that doesn't fit any existing doc | **Create** a new `docs/XX-topic.md` file and add it to the table in AGENTS.md and this file |
| A code change is purely internal refactoring with no architectural impact | **No docs update needed** |
## Documentation Standards
### Format
- All docs are Markdown (`.md`) files with GitHub-flavored Markdown
- Use ATX headings (`#`, `##`, `###`) — no Setext headings
- Include a table of contents for files longer than ~100 lines
- Use relative links to reference other docs and code files
- Code blocks should specify the language for syntax highlighting
### Cross-Referencing
- Reference tech stack instruction files in `.github/instructions/` where relevant
- Reference code paths with links to the actual file (e.g., [`site-resolver.ts`](src/lib/server/site-resolver.ts))
- Keep the file reference index in [`AGENTS.md`](AGENTS.md) up to date when adding new documentation
### Style
- Write for **David (system maintainer)** as the primary audience — assume technical competence but need clarity
- Be **specific and concise** — avoid marketing language, focus on technical accuracy
- Use **tables** for structured information (env vars, routes, schema columns)
- Use **bullet lists** for unordered items, **numbered lists** for sequential steps
- Include **example code** where it clarifies usage
## Documentation Review Process
### During Development
1. **Self-review**: When implementing a feature, check whether any `docs/*.md` files need updating
2. **Co-located changes**: Include documentation updates in the same PR/commit as the code change — never in a separate "update docs" pass
3. **Cross-reference check**: After updating a doc, verify all cross-references still resolve (file paths, section anchors)
### Before Merge
1. **Freshness check**: Does the doc still reflect the current state of the code?
2. **Accuracy check**: Are all code examples, SQL queries, and configuration snippets up to date?
3. **Completeness check**: Does the doc cover edge cases and error states, or just the happy path?
4. **Link check**: Do all relative links resolve correctly?
### Periodic Maintenance
- When adding a new feature from the roadmap ([`03-feature-roadmap.md`](docs/03-feature-roadmap.md)), review all docs that reference the affected system
- After a deployment involving schema changes, verify [`02-database-plan.md`](docs/02-database-plan.md) matches the actual database state
- When a risk in [`09-risks-and-rules.md`](docs/09-risks-and-rules.md) materializes or is mitigated, update it promptly
## Related Instruction Files
The following instruction files in `.github/instructions/` provide detailed conventions that complement the project documentation:
| File | Focus |
| ---- | ----- |
| [`multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md) | Site resolution, data scoping, deployment model |
| [`database-schema.instructions.md`](.github/instructions/database-schema.instructions.md) | All tables, relationships, migration strategy |
| [`auth-and-roles.instructions.md`](.github/instructions/auth-and-roles.instructions.md) | Better Auth, Discord OAuth, role system |
| [`deployment-guide.instructions.md`](.github/instructions/deployment-guide.instructions.md) | Coolify multi-deploy, Docker, migration runner |
| [`admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md) | Admin layout, auth guards, form patterns |
| [`cdn-and-assets.instructions.md`](.github/instructions/cdn-and-assets.instructions.md) | CDN helpers, image upload, webp conversion |
| [`public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md) | SSR landing page, CSS custom properties |
| [`api-route-patterns.instructions.md`](.github/instructions/api-route-patterns.instructions.md) | API route conventions, validation, site scoping |
| [`testing-multi-tenant.instructions.md`](.github/instructions/testing-multi-tenant.instructions.md) | Multi-tenant test patterns, mocking, auth testing |
| [`server-ts.instructions.md`](.github/instructions/server-ts.instructions.md) | Server-side TypeScript patterns, Drizzle queries |
+186
View File
@@ -0,0 +1,186 @@
---
description: "Use when adding icons to components or pages for The Collective Hub. Covers @lucide/svelte usage patterns — import, pass, style, and render icons."
applyTo: "src/**/*.svelte", "src/**/*.ts"
---
# Icon Usage
## Overview
This project uses Lucide Icons for all UI iconography:
| Source | Package | Purpose |
| ---------------- | --------------------------------------------- | -------------------------------------------------------------- |
| **Lucide Icons** | [`@lucide/svelte`](https://lucide.dev/icons/) | General UI icons (arrows, actions, status, social media, etc.) |
---
## 1. Lucide Icons (`@lucide/svelte`)
### Import Pattern
Import individual icons as named exports from `@lucide/svelte` — there is no centralized icon barrel/index file:
```ts
import { Heart, Menu, X, BookOpen, Video, Star, ThumbsUp } from '@lucide/svelte';
```
Browse the full icon catalog at [lucide.dev/icons](https://lucide.dev/icons/).
> **Note:** There is no `$lib/components/ui/icons` barrel. All icon imports are direct from `@lucide/svelte`. If a centralized re-export pattern is desired in the future, it should be added to `src/lib/icons/` (which does not exist yet) and clearly marked as planned in this document.
### Component Props
Each Lucide icon component accepts these props:
| Prop | Type | Default | Description |
| ------------- | -------- | ------- | -------------------------------- |
| `size` | `number` | `24` | Width & height in pixels |
| `class` | `string` | `''` | Tailwind CSS classes for styling |
| `strokeWidth` | `number` | `2` | Stroke width of the icon paths |
### Rendering
Use icons as self-closing components in the template:
```svelte
<Heart size={20} class="text-cyan-400" />
<X size={18} class="text-slate-500 transition-colors hover:text-slate-300" />
<Video size={16} class="text-cyan-300" />
```
### Sizing Conventions
| Context | `size` | Example |
| ------------------------ | ------ | ----------------------------------------------- |
| Inline text / badge | `16` | `<Heart size={16} class="text-rose-400" />` |
| Stat card icons | `20` | `<Star size={20} class="text-cyan-400" />` |
| Decorative / empty state | `24` | `<BookOpen size={24} class="text-slate-600" />` |
### Dynamic Fill (Toggle Icons)
For icons like Hearts that toggle between filled and unfilled, control fill via the `fill-current` Tailwind utility:
```svelte
<Heart size={20} class={isActive ? 'fill-current text-rose-400' : 'text-slate-400'} />
```
The `fill-current` class tells Tailwind to use the current text color as the fill color.
### Passing Icons as Props to Other Components
Components that accept icons expect an **icon component constructor**, not a string. Use Svelte's `Component` type:
```svelte
<script lang="ts">
import type { Component } from 'svelte';
import { Heart } from '@lucide/svelte';
let {
title,
value,
icon = undefined
} = $props<{
title: string;
value: string | number;
icon?: Component;
}>();
</script>
{#if icon}
<svelte:component this={icon} size={20} class="text-cyan-400" />
{/if}
```
At the call site, pass the component constructor (not an instance):
```svelte
<script lang="ts">
import { Heart } from '@lucide/svelte';
</script>
<StatCard title="Likes" value="42" icon={Heart} />
```
### Dynamic Icons from Dictionaries
For dynamic icon selection (e.g., mapping status to icon), use a `Record<string, Component>`:
```ts
import type { Component } from 'svelte';
import { CheckCircle, XCircle, AlertTriangle } from '@lucide/svelte';
const statusIcons: Record<string, Component> = {
success: CheckCircle,
failure: XCircle,
warning: AlertTriangle
};
```
Then render with `<svelte:component>`:
```svelte
<svelte:component this={statusIcons[status]} size={16} class="text-cyan-400" />
```
---
## 2. Brand Icons (Social Media)
For social media / brand logos (Facebook, Instagram, LinkedIn, YouTube, etc.), use Lucide's brand icons or `simple-icons` package if Lucide doesn't cover them:
```svelte
<!-- For specific brand logos, use simple-icons -->
<script lang="ts">
import { siFacebook, siInstagram, siLinkedin, siYoutube } from 'simple-icons';
</script>
<!-- Using Lucide for social icons -->
<Globe size={18} class="text-slate-400" />
<Mail size={18} class="text-slate-400" />
<svg viewBox="0 0 24 24" class="h-5 w-5 fill-current text-slate-400">
<path d={siFacebook.path} />
</svg>
```
---
## 3. Removed Patterns (What NOT To Do)
These patterns should not be used:
### ❌ No `lucide-svelte` package (wrong package name)
```ts
// OLD — DO NOT USE
import { Heart } from 'lucide-svelte';
```
Use `@lucide/svelte` instead:
```ts
// ✅ CORRECT
import { Heart } from '@lucide/svelte';
```
---
## 4. Exceptions
These cases still use `{@html}` legitimately — they are **not** icon-related:
- **JSON-LD structured data injection** (in `<svelte:head>`):
```svelte
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html `<script type="application/ld+json">${jsonLd}</script>`}
```
---
## 5. Guidelines
- Prefer `@lucide/svelte` components for all UI icons
- Use `simple-icons` for brand logos not available in Lucide
- Use `size` prop matching the text size context (16 for small, 20 for default, 24 for large)
- Use `class` for color and styling via Tailwind utilities
@@ -0,0 +1,162 @@
---
description: 'Use when understanding or modifying the multi-tenant architecture of The Collective Hub. Covers site resolution flow, data scoping rules, deployment model, migration safety, and environment variable conventions.'
applyTo: 'src/**/*.ts', 'src/**/*.svelte', 'docs/*.md'
---
# Multi-Tenant Architecture
## Overview
The Collective Hub is a **multi-tenant SvelteKit application**. One codebase powers multiple branded community/theater landing pages. Each deployment is differentiated only by its `SITE_SLUG` environment variable.
```
One Git Repo
├── Coolify Deployment "bad-movies-theater"
│ └── SITE_SLUG=bad-movies-theater
├── Coolify Deployment "garbage-day"
│ └── SITE_SLUG=garbage-day
└── Shared Infrastructure
├── PostgreSQL Database (all sites, scoped by siteId)
└── CDN / Object Storage (all sites, scoped by path)
```
## Site Resolution Flow
Every request follows this path:
```
HTTP Request
→ hooks.server.ts (reads SITE_SLUG from env)
→ site-resolver.ts (queries DB for site + settings by slug)
→ event.locals.site / event.locals.siteSettings (attached for request lifetime)
→ App renders with site context
```
### Implementation
In [`src/hooks.server.ts`](src/hooks.server.ts):
```ts
const slug = env.SITE_SLUG;
const siteContext = await getSiteBySlug(slug);
event.locals.site = siteContext.site;
event.locals.siteSlug = slug;
event.locals.siteSettings = siteContext.settings;
```
In [`src/lib/server/site-resolver.ts`](src/lib/server/site-resolver.ts):
```ts
export async function getSiteBySlug(slug: string): Promise<SiteContext> {
const [site] = await db.select().from(sites).where(eq(sites.slug, slug)).limit(1);
// ...throws if not found
const [settingsRow] = await db.select().from(siteSettings).where(eq(siteSettings.siteId, site.id)).limit(1);
return { site, settings: (settingsRow?.settings ?? {}) };
}
```
### `locals` Typing
See [`src/app.d.ts`](src/app.d.ts) for the full `locals` type definition. Key properties:
| Property | Type | Description |
|----------|------|-------------|
| `locals.site` | `Site` | The current site record from the DB |
| `locals.siteSlug` | `string` | The `SITE_SLUG` env var value |
| `locals.siteSettings` | `SiteSettingsData` | Parsed settings JSON for the site |
| `locals.user` | `User \| null` | Authenticated user (or null for visitors) |
| `locals.membership` | `Membership \| null` | User's membership/role for this site |
## Data Scoping Rule
**Every site-owned record MUST include a `siteId` column.**
This applies to: `siteSettings`, `events`, `assets`, `navLinks`, `socialLinks`, `memberships`, and any future content types.
```sql
-- Always filter by siteId
SELECT * FROM events WHERE site_id = $currentSiteId ORDER BY start_time ASC;
```
In Drizzle:
```ts
const events = await db
.select()
.from(eventsTable)
.where(
and(
eq(eventsTable.siteId, locals.site.id),
eq(eventsTable.isPublished, true)
)
)
.orderBy(asc(eventsTable.startTime));
```
**Consequences of this rule:**
- No cross-site data leaks
- Database is logically multi-tenant
- Future single-deployment/multi-domain model requires no schema changes
## Deployment Model
### Current: Multiple Coolify Deployments
Each Coolify deployment:
- Points to the same Git repo + branch
- Has its own set of environment variables
- Connects to the same database
- Uses the same CDN bucket
- Is completely isolated at the container level
### Future: Single Deployment / Multi-Domain (Optional)
If needed later, the system could switch to a single deployment that resolves the site by domain name instead of `SITE_SLUG`. The architecture supports this because all data is already scoped by `siteId`.
## Migration Safety
Multiple deployments sharing one database means migrations must be handled carefully:
- **Migrations run automatically on startup**, but only on the deployment with `RUN_MIGRATIONS=true`
- All other deployments (`RUN_MIGRATIONS=false`) skip migrations entirely
- **Exactly one deployment** must be designated the migration runner
- The migration runner must be deployed first when schema changes are included in a release
- This is enforced by convention (the `RUN_MIGRATIONS` flag), not by a distributed lock
```ts
// In hooks.server.ts — runs at module level, before any request
if (!building && env.RUN_MIGRATIONS === 'true') {
await runMigrations();
}
```
## Environment Variables
Variables are classified as **shared** (same value for all deployments) or **per-site** (unique per deployment):
| Variable | Scope | Purpose |
|----------|-------|---------|
| `SITE_SLUG` | Per-site | Identifies which site this deployment serves |
| `PUBLIC_SITE_URL` | Per-site | Public URL, used for auth callbacks |
| `DATABASE_URL` | Shared | Postgres connection string |
| `BETTER_AUTH_SECRET` | Shared | Session signing key |
| `DISCORD_CLIENT_ID` | Shared | Discord OAuth app ID |
| `DISCORD_CLIENT_SECRET` | Shared | Discord OAuth app secret |
| `OWNER_DISCORD_ID` | Per-site | Discord user ID of the site owner |
| `SUPER_ADMIN_DISCORD_IDS` | Shared | Comma-separated super admin Discord IDs |
| `CDN_BASE_URL` | Shared | Base URL for CDN URLs |
| `RUN_MIGRATIONS` | Per-site (one `true`) | Whether this deployment runs migrations |
Full reference: [`docs/04-environment-variables.md`](docs/04-environment-variables.md)
## Key Risks
1. **Missing `siteId` on queries** — Use a query helper wrapper in Phase 2+ to enforce this at the type level
2. **Hardcoding CDN URLs** — Store only `cdnKey` paths in DB, construct full URLs with `CDN_BASE_URL`
3. **Site-specific conditionals** — Never write `if (site.slug === 'bad-movies-theater')`. All customization comes from DB settings
4. **Multiple migrations running** — Only one deployment should have `RUN_MIGRATIONS=true`
See [`docs/09-risks-and-rules.md`](docs/09-risks-and-rules.md) for the full risk assessment.
@@ -0,0 +1,202 @@
---
description: 'Use when building or modifying the public-facing landing page for The Collective Hub. Covers SSR-only rendering, section structure, CSS custom properties for theming, theme presets, and dynamic branding from settings.'
applyTo: 'src/routes/+page.svelte', 'src/routes/+layout.svelte', 'src/routes/layout.css', 'src/routes/+page.server.ts'
---
# Public Site & Theming
## Architecture
The public site is a **single SSR-only landing page**. There is no client-side routing beyond the initial page load, no SPA navigation, and no JavaScript-driven content switching.
```
Single page structure:
Hero → About → Events → Social Links → Footer
```
## Section Structure
The page is composed of clearly separated sections:
```svelte
<!-- src/routes/+page.svelte (conceptual) -->
<script lang="ts">
let { data } = $props();
let { settings } = data;
</script>
<Hero {settings} />
<About {settings} />
<Events {settings} />
<SocialLinks {settings} />
<Footer {settings} />
```
Each section is a Svelte component in `src/lib/components/` (for the public site) or defined locally in the route if it's too specific.
## Data Loading
The homepage data is loaded in `+page.server.ts`:
```ts
// src/routes/+page.server.ts
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const settings = locals.siteSettings;
// Optionally load events, nav links, etc.
// const events = await loadEvents(locals.site.id);
return {
settings,
// events,
};
};
```
## Theme System
The site uses **CSS custom properties** generated from the site's theme settings. These are applied to the HTML element and cascade through all components.
### Theme Settings Shape
```typescript
interface ThemeSettings {
preset: 'dark' | 'light' | 'custom';
accentColor: string; // e.g., "#e63946"
backgroundColor: string; // e.g., "#1a1a2e"
textColor: string; // e.g., "#eaeaea"
}
```
### Applying Theme in Layout
In [`src/routes/+layout.svelte`](src/routes/+layout.svelte) or the root layout, apply theme as inline styles:
```svelte
<script lang="ts">
let { data, children } = $props();
let theme = $derived(data.settings?.theme ?? { preset: 'dark', accentColor: '#e63946', backgroundColor: '#1a1a2e', textColor: '#eaeaea' });
</script>
<div
style="--accent: {theme.accentColor}; --bg: {theme.backgroundColor}; --text: {theme.textColor};"
class="min-h-screen"
style="background-color: var(--bg); color: var(--text);"
>
{@render children()}
</div>
```
### Theme Presets
Built-in presets provide sensible defaults:
| Preset | Background | Text | Accent | Vibe |
|--------|-----------|------|--------|------|
| `dark` (default) | `#1a1a2e` | `#eaeaea` | `#e63946` | Cinematic, theater-like |
| `light` | `#ffffff` | `#1a1a2e` | `#e63946` | Clean, readable |
| `custom` | Per-site config | Per-site config | Per-site config | Fully customized |
## Dynamic Branding
Branding settings control the visual identity:
### Logo
```svelte
<script lang="ts">
let { branding }: { branding: BrandingSettings } = $props();
let logoSrc = $derived(branding.logoCdnKey ? cdnUrl(branding.logoCdnKey) : null);
</script>
{#if logoSrc}
<img src={logoSrc} alt="{branding.siteName} logo" class="h-12 w-auto" />
{:else}
<h1 class="text-2xl font-bold">{branding.siteName}</h1>
{/if}
```
### Favicon
Set in [`src/app.html`](src/app.html) via the loaded settings:
```html
<link rel="icon" href="{faviconUrl || '/favicon.png'}" />
```
## Section Components
### Hero Section
```svelte
<script lang="ts">
let { settings }: { settings: SiteSettingsData } = $props();
let { homepage, branding } = settings;
</script>
<section class="py-20 text-center">
<h1 class="text-4xl md:text-6xl font-bold" style="color: var(--accent)">
{homepage.heroTitle || branding.siteName}
</h1>
<p class="mt-4 text-lg">{homepage.heroSubtitle || branding.tagline}</p>
{#if homepage.primaryButtonLink}
<a
href={homepage.primaryButtonLink}
class="mt-8 inline-block px-6 py-3 rounded-lg font-semibold"
style="background-color: var(--accent); color: white;"
>
{homepage.primaryButtonText || 'Join Us'}
</a>
{/if}
</section>
```
### About Section
Displays the `homepage.aboutText` content. Supports markdown rendering for basic formatting.
### Events Section
Shows upcoming events if `homepage.showNextEvent` or `homepage.showSchedule` is enabled. Events are loaded server-side and passed through `data`.
### Social Links Section
Renders social platform links from the `socialLinks` table with platform-appropriate icons.
## CSS Custom Properties Reference
```css
/* Defined at runtime via inline styles on the root element */
:root {
--accent: #e63946; /* Primary accent color */
--bg: #1a1a2e; /* Page background */
--text: #eaeaea; /* Text color */
--accent-hover: #ff6b6b; /* Optional: accent hover state */
}
```
Use these in components instead of hardcoding colors:
```css
/* ✅ */
.button {
background-color: var(--accent);
color: white;
}
/* ❌ */
.button {
background-color: #e63946;
color: white;
}
```
## Rendering Rules
1. **SSR-only**: All content is rendered on the server. No client-side data fetching for page content.
2. **No JavaScript required**: The public page should be fully functional without JS (links, text, images).
3. **Theme-first**: Always use CSS custom properties for colors. Never hardcode theme colors.
4. **Responsive**: The single layout must work on mobile and desktop.
5. **Accessible**: Proper heading hierarchy, alt text on images, sufficient color contrast.
@@ -0,0 +1,176 @@
---
description: 'Use when writing or editing SvelteKit server modules for The Collective Hub: load functions, form actions, API routes, hooks, or any server-side logic. Enforces environment variable safety, Drizzle database patterns, site context access, and form action conventions.'
applyTo: 'src/**/*.server.ts'
---
# Server Module Rules
## Environment Variables
Always use SvelteKit's env modules — never `process.env` directly.
| Scope | Module | When to use |
| ------ | ---------------------- | -------------------------------------------------------------------------- |
| Secret | `$env/dynamic/private` | Runtime env evaluation — convenient in development; this project's default |
| Secret | `$env/static/private` | Build-time inlining — preferred for production (faster, fails at build) |
| Public | `$env/dynamic/public` | Runtime env evaluation — use when env changes without rebuild |
| Public | `$env/static/public` | Build-time inlining — preferred for production |
```ts
// ✅ (this project's convention — runtime evaluation)
import { env } from '$env/dynamic/private';
const dbUrl = env.DATABASE_URL;
// ✅ (production-optimised alternative — build-time inlining)
import { DATABASE_URL } from '$env/static/private';
// ❌
const url = process.env.DATABASE_URL;
```
> **Why this project uses `$env/dynamic/private`**: The `dynamic` variant evaluates environment variables at runtime, which is convenient during development and when deploying to platforms where env vars aren't available at build time (e.g., ephemeral preview deployments). For production deployments where all env vars are known at build time, `$env/static/private` is preferred because it inlines values and fails early at build time on missing variables.
**Never** import any `$env/*/private` module from:
- `.svelte` component files
- `+page.ts` or `+layout.ts` (run on both server and client)
- Any module that may be imported client-side
## Drizzle Database Patterns
### Import
```ts
import { db } from '$lib/server/db';
import { eq, and, desc, asc, gte, lte } from 'drizzle-orm';
import { sites, users, memberships, siteSettings, assets, navLinks, socialLinks, events } from '$lib/server/db/schema';
```
### Querying with Site Scope
All queries must filter by `siteId` from `locals.site`:
```ts
import { eq, and, asc } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { events } from '$lib/server/db/schema';
const upcomingEvents = await db
.select()
.from(events)
.where(
and(
eq(events.siteId, locals.site.id),
eq(events.isPublished, true),
gte(events.startTime, new Date())
)
)
.orderBy(asc(events.startTime));
```
### Inserting
```ts
const [newEvent] = await db
.insert(events)
.values({
siteId: locals.site.id,
title: 'Bad Movie Night',
startTime: new Date('2025-06-15T20:00:00Z'),
isPublished: false,
})
.returning();
```
### Updating
**Never forget to scope updates by `siteId`:**
```ts
await db
.update(events)
.set({ title: 'Updated Title', isPublished: true })
.where(
and(
eq(events.id, eventId),
eq(events.siteId, locals.site.id) // CRITICAL — prevents cross-site writes
)
);
```
### Deleting
```ts
await db
.delete(events)
.where(
and(
eq(events.id, eventId),
eq(events.siteId, locals.site.id)
)
);
```
## Site Context Access
Server modules have access to the current site through `locals`:
| Property | Type | Description |
|----------|------|-------------|
| `locals.site` | `Site` | Current site record (id, slug, name, isActive) |
| `locals.siteSlug` | `string` | The `SITE_SLUG` env var value |
| `locals.siteSettings` | `SiteSettingsData` | Parsed settings JSON (branding, theme, homepage, layout) |
| `locals.user` | `User \| null` | Authenticated user or null |
| `locals.membership` | `Membership \| null` | User's role for this site or null |
```ts
export const load: PageServerLoad = async ({ locals }) => {
const siteId = locals.site.id;
const settings = locals.siteSettings;
const userRole = locals.membership?.role;
// Use site context to scope queries
const navLinks = await db
.select()
.from(navLinksTable)
.where(eq(navLinksTable.siteId, siteId))
.orderBy(navLinksTable.sortOrder);
return { navLinks, settings };
};
```
## Form Actions
```ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user) redirect(302, '/login');
const data = await request.formData();
const name = data.get('name');
if (!name || typeof name !== 'string') {
return fail(422, { name, error: 'Name is required' });
}
// mutate...
redirect(303, '/dashboard');
}
};
```
- Return `fail(status, data)` for validation/business errors (client sees `form` prop)
- Use `redirect(303, path)` after successful mutations
- Use `error(status, message)` from `@sveltejs/kit` for unexpected/fatal errors
## Types
Always annotate `load` and `actions` with generated types from `./$types`:
```ts
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ params, locals }) => { ... };
export const actions: Actions = { ... };
```
@@ -0,0 +1,67 @@
---
description: 'Use when writing or editing .svelte.ts reactive state modules. Enforces Svelte 5 runes-based shared state patterns and forbids svelte/store primitives.'
applyTo: '**/*.svelte.ts'
---
# Svelte Reactive Module Rules (`.svelte.ts`)
Use `.svelte.ts` files for **global or cross-tree shared reactive state** that needs to be accessed outside of a component hierarchy. For state scoped to a component subtree, prefer `setContext` / `getContext` instead.
## Module-Scope State
Declare `$state` at module scope to create a reactive singleton. Export a factory function or a plain object with getter/setter methods — not the raw `$state` variable, which would allow callers to reassign it.
```ts
// ✅ src/lib/state/services.svelte.ts
const _state = $state({ list: [] as Service[], loading: false });
export const servicesStore = {
get list() {
return _state.list;
},
get loading() {
return _state.loading;
},
set(services: Service[]) {
_state.list = services;
},
setLoading(v: boolean) {
_state.loading = v;
}
};
```
```ts
// ❌ — callers can do `list = []` bypassing reactivity
export let list = $state<Service[]>([]);
```
## Derived State
Use `$derived` at module scope for computed values:
```ts
const _state = $state({ list: [] as Service[], search: '' });
export const filtered = $derived(_state.list.filter((s) => s.name.includes(_state.search)));
```
## Never Use svelte/store
Do not use `writable`, `readable`, or `derived` from `svelte/store` in new code.
| Instead of | Use |
| ------------------------ | ---------------------------------------------- |
| `writable(value)` | `$state(value)` in a `.svelte.ts` module |
| `derived(store, fn)` | `$derived(fn)` in a `.svelte.ts` module |
| `readable(value, start)` | `$state` + a setup function in the module body |
## When to Use `.svelte.ts` vs `setContext/getContext`
| Scenario | Pattern |
| --------------------------------------------------- | -------------------------------------------------------------------- |
| State needed across unrelated component trees | `.svelte.ts` module singleton |
| State scoped to one subtree (e.g. a form, a widget) | `setContext` in parent, `getContext` in children |
| State that needs SSR / per-request isolation | `setContext` in `+layout.svelte` (never module singletons on server) |
> **Warning**: Module singletons are shared across all users in SSR. Only use `.svelte.ts` singletons for state that is client-side only (e.g. UI state, client-cached data). For per-user server state, always use context.
@@ -0,0 +1,87 @@
---
description: 'Use when writing or editing Svelte components, reactive state, props, effects, or event handlers for The Collective Hub. Enforces Svelte 5 runes syntax and forbids legacy Svelte 4 patterns.'
applyTo: '**/*.svelte'
---
# Svelte 5 Rules
## Always Use Runes
| Need | Use | Never |
| --------------------- | ------------------------ | --------------------------------- |
| Local state | `$state()` | `let x = 1` (mutable) |
| Computed value | `$derived(expr)` | `$: x = expr` |
| Side effects | `$effect(() => { ... })` | `onMount` (unless cleanup needed) |
| Component props | `let { x } = $props()` | `export let x` |
| Two-way bindable prop | `$bindable()` | — |
```svelte
<script lang="ts">
let { label, value = $bindable('') }: { label: string; value?: string } = $props();
let upper = $derived(value.toUpperCase());
</script>
```
## Event Handlers
Use direct HTML event attributes — not `on:` directive syntax.
```svelte
<!-- ✅ -->
<button onclick={handleClick}>Click</button>
<input oninput={(e) => (search = e.currentTarget.value)} />
<!-- ❌ -->
<button on:click={handleClick}>Click</button>
```
## Composition: Snippets over Slots
```svelte
<!-- ✅ -->
{#snippet row(item)}
<tr><td>{item.name}</td></tr>
{/snippet}
{@render row(movie)}
<!-- ❌ -->
<slot name="row" />
```
To accept snippets as props: `let { children } = $props()` + `{@render children()}`.
## Cross-Component State
- Prefer `setContext` / `getContext` for tree-scoped state
- Use `.svelte.ts` modules (with `$state` at module scope) for global/shared reactive state
- Avoid `svelte/store` (`writable`, `readable`, `derived`) in new code
## Always
- `<script lang="ts">` on every component
- Prefer `$derived` over `$effect` for computed values — `$effect` is for DOM/third-party side effects only
## Deprecated APIs
### `<svelte:component>` — Use component variables instead
`<svelte:component>` is **deprecated in Svelte 5**. Instead, assign a component constructor to a variable in the template using `{@const}` and render it directly as `<ComponentVar />`.
```svelte
<!-- ❌ Deprecated in Svelte 5 -->
<svelte:component this={iconMap[key]} size={12} />
<!-- ✅ Svelte 5 approach -->
{@const Icon = iconMap[key]}
{#if Icon}
<Icon size={12} />
{/if}
```
The `{#if}` guard ensures the component only renders when the variable is truthy, which also matches the previous runtime-guard behavior of `<svelte:component>`.
## Code Quality
### No `svelte-ignore` Suppression
`svelte-ignore` comments must NOT be used to suppress svelte-check errors or warnings. All accessibility (a11y), type, and other issues flagged by `svelte-check` must be fixed properly — never hidden behind a suppression comment.
@@ -0,0 +1,114 @@
---
description: "Use when writing or editing Tailwind CSS classes, styling Svelte components, adding design tokens, or modifying layout.css for The Collective Hub. Enforces Tailwind v4 syntax, project design tokens, and component utility conventions."
applyTo: "**/*.svelte", "src/routes/layout.css"
---
# Tailwind CSS Rules
## Version & Setup
This project uses **Tailwind CSS v4** with a CSS-first configuration — there is no `tailwind.config.js`.
### Current State
The project's [`src/routes/layout.css`](../src/routes/layout.css) currently contains only base Tailwind imports:
```css
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
```
No custom `@theme` tokens, `@layer components`, or utility classes have been defined yet. The sections below document the **intended patterns** — these utilities need to be added to `layout.css` **before** they can be used in components.
All theme tokens, component utilities, and plugin registrations live in `src/routes/layout.css`.
## Design Tokens: Always Use Named Tokens
Inline arbitrary values are **forbidden** for anything already defined in `@theme`. Prefer named tokens.
When a value isn't covered by an existing token, **add it to `@theme` in `layout.css`** first, then reference it by name.
## Color Palette
The Web Cooperative website follows a professional, trustworthy aesthetic reflecting the brand's direct and helpful personality.
| Role | Tailwind classes |
| ------------------ | ------------------------------------------- |
| Page background | `bg-slate-950` or `bg-white` |
| Surface / card | `bg-white/5`, `bg-slate-900/40` |
| Border | `border-slate-700/50` |
| Text primary | `text-slate-100`, `text-slate-900` |
| Text muted | `text-slate-400`, `text-slate-500` |
| Accent — primary | `text-cyan-400`, `border-cyan-500/50` |
| Accent — secondary | `text-emerald-400`, `border-emerald-500/50` |
Use opacity modifiers (`/10`, `/20`, `/30`, `/50`) for translucent backgrounds and borders. Reserve full-opacity fills for active/focus states only.
## Component Utilities (Planned — Add to `layout.css` Before Use)
The following utility classes are **planned but not yet defined**. Add them to `src/routes/layout.css` inside `@layer components` **before** referencing them in components:
```css
/* src/routes/layout.css — add inside @layer components */
@layer components {
.input {
@apply block w-full rounded-lg border border-slate-700/50 bg-white/5 px-3 py-2 text-sm text-slate-100 placeholder-slate-500 transition-all duration-300 focus:border-cyan-500/50 focus:outline-none;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-400;
}
.btn {
@apply inline-flex items-center justify-center rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white transition-all duration-300 hover:bg-cyan-500 focus:ring-2 focus:ring-cyan-500/50 focus:outline-none;
}
.btn-ghost {
@apply inline-flex items-center justify-center rounded-lg border border-slate-700/50 bg-transparent px-4 py-2 text-sm font-medium text-slate-300 transition-all duration-300 hover:border-cyan-500/50 hover:text-cyan-400 focus:outline-none;
}
}
```
| Class | Use for |
| ------------ | ------------------------------------------------- |
| `.input` | `<input>`, `<select>`, `<textarea>` form controls |
| `.label` | Micro-label headings |
| `.btn` | Primary action button |
| `.btn-ghost` | Secondary / ghost button |
When a pattern repeats 3+ times across components, extract it into `@layer components` in `layout.css` using `@apply`.
## Interaction Patterns
Use consistent transitions and hover patterns:
```html
<!-- Card with border + hover effect -->
<div class="border border-slate-700/50 transition-all duration-300 hover:border-cyan-500/50">
<div class="group">
<span class="text-slate-400 transition-colors duration-300 group-hover:text-cyan-400">
Content
</span>
</div>
</div>
```
- Always pair state changes with `transition-all duration-300`
- Use `group` + `group-hover:` for parent-triggered child transitions
## Responsive Layout
Use Tailwind breakpoint prefixes for responsive grids:
```html
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"></div>
```
Standard breakpoints: `sm` (640px), `md` (768px), `lg` (1024px), `xl` (1280px).
Content pages should be readable and well-spaced on all devices — prioritize mobile layouts since many small business owners browse on phones.
## Avoid
- Arbitrary values (`shadow-[...]`, `text-[11px]`) for anything already in `@theme`
- `style=""` attributes for values achievable with utility classes
- Adding colors outside the established brand palette without discussion
- New `@theme` tokens that duplicate existing ones at similar opacity levels
@@ -0,0 +1,238 @@
---
description: 'Use when writing or editing tests for The Collective Hub. Covers multi-tenant test patterns, mocking site context, database mocking, auth testing, and coverage targets for multi-tenant code paths.'
applyTo: 'src/**/*.test.ts', 'src/**/*.spec.ts', 'src/**/*.svelte.test.ts'
---
# Testing: Multi-Tenant Patterns
> **Base testing rules** are covered in [`testing.instructions.md`](testing.instructions.md). This file covers patterns specific to testing multi-tenant code.
## Overview
The Collective Hub's multi-tenant architecture introduces testing concerns that don't exist in single-tenant apps:
1. **Site scoping** — every query must filter by `siteId`
2. **Auth state** — routes behave differently based on user role
3. **Site context**`locals.site`, `locals.siteSettings` drive rendering
4. **Data isolation** — one site's data must never leak to another
## Mocking Site Context
### In Server Tests
When testing server code (load functions, form actions, API routes), mock the `locals` object:
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockSite = {
id: 'test-site-uuid',
slug: 'test-site',
name: 'Test Site',
isActive: true,
};
const mockSettings = {
branding: { siteName: 'Test Site', tagline: 'Testing', logoCdnKey: null, backgroundCdnKey: null, faviconCdnKey: null },
theme: { preset: 'dark' as const, accentColor: '#e63946', backgroundColor: '#1a1a2e', textColor: '#eaeaea' },
homepage: { heroTitle: 'Welcome', heroSubtitle: '', aboutText: '', primaryButtonText: 'Join', primaryButtonLink: '', showNextEvent: true, showSchedule: true },
layout: { preset: 'standard' as const },
};
function createMockLocals(overrides: Partial<App.Locals> = {}): App.Locals {
return {
site: mockSite,
siteSlug: 'test-site',
siteSettings: mockSettings,
user: { id: 'user-uuid', discordId: '12345', discordUsername: 'testuser' },
membership: { id: 'membership-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'owner' },
...overrides,
} as App.Locals;
}
```
### Testing with Different Auth States
```ts
describe('admin page load', () => {
it('redirects to login when unauthenticated', async () => {
const locals = createMockLocals({ user: null, membership: null });
// expect redirect(302, '/login')
});
it('allows access for authenticated owner', async () => {
const locals = createMockLocals({ user: mockUser, membership: { ...mockMembership, role: 'owner' } });
// expect data to load
});
it('allows access for admin', async () => {
const locals = createMockLocals({ user: mockUser, membership: { ...mockMembership, role: 'admin' } });
// expect data to load
});
it('redirects editor from settings page', async () => {
const locals = createMockLocals({ user: mockUser, membership: { ...mockMembership, role: 'editor' } });
// expect redirect(302, '/admin') or 403
});
});
```
## Mocking the Database
Use `vi.hoisted()` with `vi.mock()` for Drizzle mocking:
```ts
const { mockSelect, mockInsert, mockUpdate, mockDelete } = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockInsert: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
}));
vi.mock('$lib/server/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
},
}));
```
### Testing Site-Scoped Queries
Verify that queries filter by `siteId`:
```ts
it('queries events scoped to current site', async () => {
const mockWhere = vi.fn();
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: mockWhere.mockResolvedValue([]),
orderBy: vi.fn().mockResolvedValue([]),
}),
});
// Call the function being tested
await loadEvents({ siteId: mockSite.id });
// Verify the where clause includes siteId
expect(mockWhere).toHaveBeenCalled();
const whereArgs = mockWhere.mock.calls[0][0];
// whereArgs should contain eq(eventsTable.siteId, mockSite.id)
});
```
## Testing Auth Guards
### Server Load Functions
```ts
import { redirect } from '@sveltejs/kit';
it('throws redirect when user is not authenticated', async () => {
const locals = createMockLocals({ user: null });
try {
await load({ locals, params: {}, url: new URL('http://test.com') } as any);
expect.unreachable('Should have thrown redirect');
} catch (e) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login');
}
});
```
### Form Actions
```ts
it('returns 401 fail for unauthenticated form submission', async () => {
const locals = createMockLocals({ user: null });
const request = new Request('http://test.com/admin/settings', { method: 'POST' });
const result = await actions.default({ locals, request } as any);
expect(result.status).toBe(401);
});
```
## Testing Data Isolation
Ensure one site's data doesn't leak into another:
```ts
it('only returns events for the current site', async () => {
const siteAEvents = [{ id: 'event-1', siteId: 'site-a' }];
const siteBEvents = [{ id: 'event-2', siteId: 'site-b' }];
// Mock to return siteA events when filtering by site-a
mockWhere.mockImplementation((...args) => {
// Inspect args to ensure siteId filtering
return siteAEvents;
});
const result = await loadEvents({ siteId: 'site-a' });
expect(result).toHaveLength(1);
expect(result[0].siteId).toBe('site-a');
// No site-b events leaked
expect(result.some((e: any) => e.siteId === 'site-b')).toBe(false);
});
```
## Testing Admin Form Actions
```ts
it('saves site settings correctly', async () => {
const locals = createMockLocals();
const formData = new FormData();
formData.append('siteName', 'Updated Site Name');
mockUpdate.mockResolvedValue([{ id: mockSite.id }]);
const result = await actions.default({ locals, request: new Request('http://test.com/admin/settings', {
method: 'POST',
body: formData,
}) } as any);
expect(mockUpdate).toHaveBeenCalled();
expect(result).toEqual({ success: true, message: 'Settings saved' });
});
```
## Testing API Routes
```ts
it('rejects unauthenticated upload', async () => {
const locals = createMockLocals({ user: null });
const request = new Request('http://test.com/api/assets', { method: 'POST' });
const response = await POST({ locals, request } as any);
expect(response.status).toBe(401);
});
it('validates file type', async () => {
const locals = createMockLocals();
const formData = new FormData();
formData.append('file', new File(['not-an-image'], 'test.txt', { type: 'text/plain' }));
const response = await POST({ locals, request: new Request('http://test.com/api/assets', {
method: 'POST',
body: formData,
}) } as any);
expect(response.status).toBe(400);
});
```
## Coverage Targets
Focus test coverage on multi-tenant code paths:
| Component | Target Coverage | Key Scenarios |
|-----------|----------------|---------------|
| Site resolver | 90%+ | Found, not found, inactive site |
| Auth guards | 90%+ | Unauthenticated, wrong role, super admin bypass |
| Admin form actions | 80%+ | Save success, validation failure, auth failure |
| API routes | 80%+ | CRUD operations, auth, site scoping |
| DB queries | 90%+ | Correct `siteId` filtering, ordering, null handling |
| Public page render | 70%+ | All theme presets, missing settings defaults |
@@ -0,0 +1,250 @@
---
description: "Use when writing or editing tests for The Collective Hub. Covers the two Vitest project environments (client browser tests and server node tests), e2e Playwright tests, file naming conventions, and the requireAssertions rule."
applyTo: "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.e2e.ts", "src/**/*.svelte.test.ts", "src/**/*.svelte.spec.ts"
---
# Testing Rules
## Two Test Environments
This project uses a split Vitest config with two projects. Use the correct file extension to target the right runner:
| Environment | File pattern | Runner | Use for |
| -------------------- | --------------------------------------- | ------------------------------- | -------------------------------------------------------- |
| **Client** (browser) | `*.svelte.test.ts` / `*.svelte.spec.ts` | Playwright (Chromium, headless) | Svelte component tests, DOM interaction |
| **Server** (Node) | `*.test.ts` / `*.spec.ts` | Node | Server logic, load functions, utilities, EspoCRM API client |
> The client project **excludes** `src/lib/server/**` — never write browser tests for server modules.
## File Placement
Co-locate test files with the code they test:
```
src/lib/components/ui/ServiceCard.svelte
src/lib/components/ui/ServiceCard.svelte.test.ts ← client test
src/lib/server/db/queries.ts
src/lib/server/db/queries.test.ts ← server test
```
## requireAssertions
`requireAssertions` is enabled globally in [`vitest.config.ts`](vitest.config.ts) via `expect: { requireAssertions: true }`**every `it` block must have at least one `expect()`**:
```ts
// ✅
it('returns formatted text', () => {
expect(formatText('hello')).toBe('Hello');
});
// ❌ — will fail at runtime even if no error thrown
it('does something', () => {
formatText('hello');
});
```
## `expect.element()` DOM Assertions (Auto-Retry) — v3-Compatible Alternative
The `expect.element()` method is available when using `vitest/browser` (import `page` from `vitest/browser` and query via `page.getByText(...)`, `page.getByRole(...)`, etc.). It **automatically retries** the assertion until it passes or the timeout is reached — this handles async rendering and DOM updates without manual waiting:
```ts
import { page } from 'vitest/browser';
// Auto-retries until the element matches or timeout
await expect.element(page.getByText('Success')).toBeVisible();
```
This API is a v3-compatible alternative to [`@testing-library/svelte`](#client-component-tests-sveltetestts) queries. The primary recommended approach is `@testing-library/svelte`, but `expect.element()` remains valid for cases where you prefer the Playwright-like query syntax.
- `expect.element()` is available from `vitest` directly (no extra import needed beyond `page`)
- The retry behavior mirrors Playwright's built-in auto-waiting
- All component tests using this API should be `async` to support `await expect.element(...)`
- See the [Client Component Tests](#client-component-tests-sveltetestts) section below for the primary `@testing-library/svelte` approach
## `expect.poll()` Async State Polling
`expect.poll()` allows retrying a custom assertion function until it passes or the timeout is reached. This is useful for polling async state changes, such as waiting for a value to reach an expected state:
```ts
// ✅ Retries getCount() every 50ms until it returns 5 or timeout
await expect.poll(() => getCount(), { timeout: 2000 }).toBe(5);
```
- The poll interval defaults to `50ms` and can be configured via `interval` in the options
- The overall timeout defaults to the test timeout and can be overridden per-poll
- Useful in both server and client tests when waiting for asynchronous side effects
## Client Component Tests (`.svelte.test.ts`)
This project uses `@testing-library/svelte` for rendering components and querying the DOM in browser-based tests (Vitest v3 + Playwright/Chromium). Do **not** use `vitest-browser-svelte` (that is a v4-only package).
### Rendering Components
Use `render` and `screen` from `@testing-library/svelte`:
```ts
import { render, screen } from '@testing-library/svelte';
import { expect, it } from 'vitest';
import Welcome from './Welcome.svelte';
it('renders greetings for host and guest', () => {
render(Welcome, { props: { host: 'SvelteKit', guest: 'Vitest' } });
expect(screen.getByRole('heading', { level: 1 })).toBeTruthy();
expect(screen.getByText('Hello, Vitest!')).toBeTruthy();
});
```
> See the canonical example at [`src/lib/vitest-examples/Welcome.svelte.spec.ts`](../src/lib/vitest-examples/Welcome.svelte.spec.ts).
### Async DOM Assertions
For assertions that need to wait for async rendering or DOM updates, use `waitFor` from `@testing-library/svelte` or `vi.waitFor()` from Vitest. These retry the assertion until it passes or the timeout is reached:
```ts
import { render, screen, waitFor } from '@testing-library/svelte';
import { expect, it } from 'vitest';
it('shows success message after loading', async () => {
render(MyComponent);
await waitFor(() => {
expect(screen.getByText('Success')).toBeTruthy();
});
});
```
### Guidelines
- Prefer `@testing-library/svelte` queries (`screen.getByText`, `screen.getByRole`, etc.) over `document.querySelector`
- Do not import from `$lib/server/**` in client tests (the client project excludes these)
- All component tests should use `@testing-library/svelte` as the primary query API
## Server Tests (`.test.ts`)
Plain Vitest tests running in Node. Use for pure functions, server utilities, and EspoCRM API client logic:
```ts
import { describe, it, expect } from 'vitest';
import { formatServiceName } from './format';
describe('formatServiceName', () => {
it('formats the service name correctly', () => {
expect(formatServiceName('seo audit')).toBe('SEO Audit');
});
});
```
### EspoCRM API Client Mocking
Server tests that depend on EspoCRM should mock the client:
```ts
import { describe, it, expect, vi } from 'vitest';
// Mock the EspoCRM client at module scope
const mockCreateLead = vi.fn();
vi.mock('$lib/server/espocrm', () => ({
espocrm: {
createLead: mockCreateLead,
request: vi.fn()
}
}));
```
Example test for the contact form:
```ts
import { POST } from './+server';
import { espocrm } from '$lib/server/espocrm';
describe('POST /api/contact', () => {
it('creates a lead via EspoCRM on valid submission', async () => {
mockCreateLead.mockResolvedValue({ status: 200, data: { id: 'abc123' } });
const response = await POST({
request: {
json: () => ({ name: 'John', email: 'j@example.com', services: ['SEO'] })
}
} as any);
const body = await response.json();
expect(body.success).toBe(true);
expect(mockCreateLead).toHaveBeenCalledWith({
name: 'John',
email: 'j@example.com',
services: ['SEO']
});
});
});
```
The `{ spy: true }` option can be passed to `vi.mock()` to auto-spy on all exported functions of a module without overriding implementations:
```ts
// Automatically wraps every export in a vi.fn() spy — useful for
// asserting calls were made without replacing module behavior
vi.mock('$lib/server/email', { spy: true });
```
## E2E Tests (`.e2e.ts`)
End-to-end tests use Playwright directly (not Vitest). They run against the built preview server on port `4173`:
```ts
// src/routes/demo/playwright/page.svelte.e2e.ts
import { test, expect } from '@playwright/test';
test('homepage loads successfully', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});
```
- File pattern: `**/*.e2e.{ts,js}` — picked up by `playwright.config.ts`
- Run with: `npx playwright test`
- Do not use `vitest` imports in `.e2e.ts` files — use `@playwright/test` only
## Reference Examples
The project includes reference examples demonstrating each test type. Use these as canonical patterns:
| Test type | File | What it demonstrates |
| ------------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| **Client component test** | [`src/lib/vitest-examples/Welcome.svelte.spec.ts`](../src/lib/vitest-examples/Welcome.svelte.spec.ts) | Canonical Vitest browser component test — `render()` and `screen` from `@testing-library/svelte`, with `expect.element()` alternative |
| **Server unit test** | [`src/lib/vitest-examples/greet.spec.ts`](../src/lib/vitest-examples/greet.spec.ts) | Simple unit test pattern — pure function testing with `describe`/`it`/`expect` |
| **Playwright E2E test** | [`src/routes/demo/playwright/page.svelte.e2e.ts`](../src/routes/demo/playwright/page.svelte.e2e.ts) | Playwright E2E test pattern — `@playwright/test` imports, `page.goto`, `locator` assertions |
## Code Coverage
Coverage reporting is configured with [`@vitest/coverage-v8`](https://www.npmjs.com/package/@vitest/coverage-v8):
```bash
# Run tests with coverage
npx vitest --coverage
```
Coverage reports are output to the `coverage/` directory (gitignored). Thresholds and include/exclude patterns can be configured in [`vitest.config.ts`](vitest.config.ts).
## Running Tests
```bash
# All Vitest tests (both client and server)
npm run test
# Watch mode
npx vitest
# E2E only (requires build first)
npx playwright test
```
## Multi-Tenant Testing
The Collective Hub's multi-tenant architecture introduces additional testing concerns:
- **Mocking `locals`** — every test that touches server code needs a mock `locals` object with `site`, `user`, `membership`, `siteSettings`
- **Site-scoped queries** — verify that every query filters by `siteId`
- **Auth guards** — test unauthenticated, authenticated, and role-based access
- **Data isolation** — ensure one site's data never leaks into another
See [`testing-multi-tenant.instructions.md`](testing-multi-tenant.instructions.md) for detailed patterns and examples.
@@ -0,0 +1,79 @@
---
description: 'Scaffold a new admin page for The Collective Hub. Generates +page.server.ts (auth guard, load data, form actions) and +page.svelte (Svelte 5, form, Tailwind).'
agent: 'agent'
---
Scaffold a new admin page at `admin/{section}/` for The Collective Hub.
Follow all conventions in:
- [admin-panel.instructions.md](.github/instructions/admin-panel.instructions.md)
- [auth-and-roles.instructions.md](.github/instructions/auth-and-roles.instructions.md)
- [svelte5.instructions.md](.github/instructions/svelte5.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to Create
### 1. `+page.server.ts`
```ts
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { siteSettings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
// Load current data
return {
settings: locals.siteSettings,
// Add more data as needed
};
};
export const actions: Actions = {
default: async ({ locals, request }) => {
if (!locals.user) return fail(401, { error: 'Unauthorized' });
const form = await request.formData();
// const value = form.get('fieldName') as string;
// Validate
// if (!value) return fail(400, { error: 'Field is required' });
// Save to DB (scoped by locals.site.id)
// await db.update(siteSettings)...
return { success: true, message: 'Saved' };
},
};
```
### 2. `+page.svelte`
```svelte
<script lang="ts">
import type { PageData } from './$types';
let { data, form }: { data: PageData; form: import('./$types').ActionData | null } = $props();
</script>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Page Title</h1>
<form method="POST" class="space-y-4">
<!-- Form fields -->
{#if form?.error}
<p class="text-red-500 text-sm">{form.error}</p>
{/if}
{#if form?.success}
<p class="text-green-500 text-sm">{form.message}</p>
{/if}
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Save Changes
</button>
</form>
</div>
```
@@ -0,0 +1,67 @@
---
description: 'Scaffold a new SvelteKit API endpoint (+server.ts) for The Collective Hub. Generates route handler with auth, site scoping, and validation patterns.'
agent: 'agent'
---
Scaffold a new API endpoint at `api/{resource}/` for The Collective Hub.
Follow all conventions in:
- [api-route-patterns.instructions.md](.github/instructions/api-route-patterns.instructions.md)
- [auth-and-roles.instructions.md](.github/instructions/auth-and-roles.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to Create
### `+server.ts`
Determine the HTTP methods needed (GET, POST, PUT, PATCH, DELETE) based on the resource.
```ts
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { eq, and } from 'drizzle-orm';
import { /* yourTable */ } from '$lib/server/db/schema';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, url }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const siteId = locals.site.id;
const items = await db
.select()
.from(/* yourTable */)
.where(eq(/* yourTable */.siteId, siteId))
.orderBy(/* yourTable */.createdAt);
return json({ data: items });
};
export const POST: RequestHandler = async ({ locals, request }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate body...
const [created] = await db
.insert(/* yourTable */)
.values({
siteId: locals.site.id,
// ... validated fields
})
.returning();
return json({ data: created }, { status: 201 });
};
```
## Notes
- Always check `locals.user` before accessing `locals.site`
- Always filter queries by `locals.site.id`
- Return appropriate HTTP status codes (200, 201, 400, 401, 403, 404, 500)
- For file uploads, use multipart form data via `request.formData()` instead of `request.json()`
+274
View File
@@ -0,0 +1,274 @@
---
description: 'Generate Vitest server tests for SvelteKit +server.ts API routes in The Collective Hub. Covers multi-tenant mocking, Drizzle database mocking, Better Auth / Discord OAuth mocking, form action testing, and API route test patterns.'
agent: 'agent'
---
Generate Vitest server tests for a SvelteKit API route (`+server.ts`) in The Collective Hub.
Follow all testing conventions in:
- [testing-multi-tenant.instructions.md](.github/instructions/testing-multi-tenant.instructions.md)
- [testing.instructions.md](.github/instructions/testing.instructions.md)
- [api-route-patterns.instructions.md](.github/instructions/api-route-patterns.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to Create
### Test file placement
Place the test file next to the source, dropping the `+` prefix:
| Source file | Test file |
| ----------- | --------- |
| `src/routes/api/contact/+server.ts` | `src/routes/api/contact/server.test.ts` |
| `src/routes/api/events/+server.ts` | `src/routes/api/events/server.test.ts` |
| `src/routes/admin/settings/+page.server.ts` | `src/routes/admin/settings/page.server.test.ts` |
> SvelteKit errors on `+server.test.ts` at build time, so drop the `+` prefix for route test files.
### Basic test template
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from './+server';
import type { RequestEvent } from '@sveltejs/kit';
// --- MOCKS ---
const { mockSelect, mockInsert, mockUpdate, mockDelete } = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockInsert: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
}));
vi.mock('$lib/server/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
},
}));
// --- HELPERS ---
const mockSite = {
id: 'test-site-uuid',
slug: 'test-site',
name: 'Test Site',
isActive: true,
};
function createMockLocals(overrides: Partial<App.Locals> = {}): App.Locals {
return {
site: mockSite,
siteSlug: 'test-site',
siteSettings: null as any,
user: { id: 'user-uuid', discordId: '12345', discordUsername: 'testuser' },
membership: { id: 'membership-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'owner' },
...overrides,
} as App.Locals;
}
function createRequestEvent(method: string, overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
request: new Request(`http://localhost/api/resource`, { method }),
locals: createMockLocals(),
params: {},
url: new URL(`http://localhost/api/resource`),
...overrides,
} as unknown as RequestEvent;
}
// --- TESTS ---
describe('POST /api/resource', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 401 when unauthenticated', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({ user: null, membership: null }),
});
const response = await POST(event);
expect(response.status).toBe(401);
const body = await response.json();
expect(body).toHaveProperty('error');
});
it('returns 400 for invalid input', async () => {
const formData = new FormData();
// Missing required field
formData.append('name', '');
const event = createRequestEvent('POST', {
request: new Request('http://localhost/api/resource', {
method: 'POST',
body: formData,
}),
});
const response = await POST(event);
expect(response.status).toBe(400);
});
it('creates a resource successfully', async () => {
const createdResource = { id: 'new-uuid', siteId: mockSite.id, name: 'Test Resource' };
mockInsert.mockReturnValue({ values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([createdResource]) }) });
const formData = new FormData();
formData.append('name', 'Test Resource');
const event = createRequestEvent('POST', {
request: new Request('http://localhost/api/resource', {
method: 'POST',
body: formData,
}),
});
const response = await POST(event);
expect(response.status).toBe(201);
const body = await response.json();
expect(body.data).toEqual(createdResource);
});
});
```
### Testing different auth roles
When testing role-based access, use the `membership.role` field:
```ts
it('allows owner to create resource', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({
membership: { id: 'm-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'owner' },
}),
});
// expect 201
});
it('allows admin to create resource', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({
membership: { id: 'm-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'admin' },
}),
});
// expect 201
});
it('rejects editor from creating resource', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({
membership: { id: 'm-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'editor' },
}),
});
// expect 403
});
it('allows super admin from any site', async () => {
// Super admins are identified by SUPER_ADMIN_DISCORD_IDS env var
// Mock the env check or auth module to return super admin status
const event = createRequestEvent('POST', {
locals: createMockLocals({
user: { id: 'super-uuid', discordId: '999999999999999999', discordUsername: 'superadmin' },
membership: null, // super admins may not have a membership
}),
});
// expect 201 if super admin bypass is implemented
});
```
### Testing site-scoped queries
Verify that API routes filter by `locals.site.id`:
```ts
it('queries scoped to current site', async () => {
const mockWhere = vi.fn();
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: mockWhere.mockResolvedValue([]),
orderBy: vi.fn().mockResolvedValue([]),
}),
});
const response = await GET(createRequestEvent('GET'));
expect(mockWhere).toHaveBeenCalled();
// The where clause should include eq(table.siteId, mockSite.id)
});
```
### Testing JSON API endpoints
For endpoints accepting JSON body instead of FormData:
```ts
it('accepts JSON body', async () => {
const payload = { name: 'Test', description: 'Description' };
const event = createRequestEvent('POST', {
request: new Request('http://localhost/api/resource', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
});
const response = await POST(event);
expect(response.status).toBe(201);
});
```
### Testing error responses
```ts
it('returns 404 when resource not found', async () => {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
const response = await GET(createRequestEvent('GET'));
expect(response.status).toBe(404);
});
it('returns 500 on database error', async () => {
mockSelect.mockImplementation(() => {
throw new Error('Database connection failed');
});
const response = await GET(createRequestEvent('GET'));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe('Internal server error');
});
```
## File placement rules
| Source file | Test file |
| ----------- | --------- |
| `src/routes/api/{resource}/+server.ts` | `src/routes/api/{resource}/server.test.ts` |
| `src/routes/admin/{section}/+page.server.ts` | `src/routes/admin/{section}/page.server.test.ts` |
> Always drop the `+` prefix from route filenames when creating test files.
## Key patterns to follow
1. **Mock the database** with `vi.hoisted()` and `vi.mock('$lib/server/db')` — never connect to a real database
2. **Mock site context** via `locals.site`, `locals.siteSlug`, `locals.siteSettings`
3. **Test auth states** — unauthenticated, owner, admin, editor, super admin
4. **Test site scoping** — verify queries filter by `locals.site.id`
5. **Test request validation** — missing fields, invalid types, empty bodies
6. **Test response formats** — success JSON, error JSON, redirects
7. **Use `createMockLocals()`** helper for consistent locals mocking
8. **Use `createRequestEvent()`** helper for consistent RequestEvent construction
9. **Reset mocks** in `beforeEach` to prevent test pollution
@@ -0,0 +1,95 @@
---
agent: 'agent'
description: Generate a Vitest browser component test for a Svelte 5 component
---
You are generating a Vitest browser component test for a Svelte 5 component in The Collective Hub project.
## Prerequisites
Before generating tests, confirm that `@testing-library/svelte` and `@vitest/browser` are installed. If not, tell the user to run:
```sh
npm install -D @testing-library/svelte @vitest/browser playwright
npx playwright install chromium
```
And that the vitest config needs a `client` project block added (describe what to add).
## Your task
Read the target `.svelte` component (provided by the user or open in the editor), then generate a co-located `*.svelte.test.ts` file.
## Rules
- File goes next to the source: `src/lib/components/ui/StatCard.svelte``src/lib/components/ui/StatCard.svelte.test.ts`
- Use `.svelte.test.ts` extension — this runs under Vitest's browser/Playwright environment
- Import `render`, `screen`, `fireEvent` (or `userEvent`) from `'@testing-library/svelte'`
- Import `describe`, `it`, `expect`, `vi` from `'vitest'`
- `requireAssertions` is enabled globally in [`vitest.config.ts`](vitest.config.ts) via `expect: { requireAssertions: true }`**every `it` block must have at least one `expect()`**
- Never import from `$lib/server/**` — those modules are excluded from the browser environment
- Use Testing Library queries (`getByText`, `getByRole`, `getByLabelText`, etc.) — not `document.querySelector`
## Svelte 5 rendering
Svelte 5 components use runes. Props are passed as the `props` option:
```ts
import { render, screen } from '@testing-library/svelte';
import MyComponent from './MyComponent.svelte';
render(MyComponent, { props: { title: 'Hello', value: 42 } });
```
For components with snippet props, you may need to render wrapper markup — note this in the test as a limitation.
## Test structure
For each component, test:
1. **Renders with default/required props** — check the key text/elements appear
2. **Conditional rendering** — if the component has `{#if}` blocks, test both branches
3. **Prop variations** — test meaningful prop combinations (not exhaustive)
4. **User interaction** — if the component has buttons/inputs, test click/input events
5. **Accessibility** — check that interactive elements have appropriate roles/labels
```ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect, vi } from 'vitest';
import StatCard from './StatCard.svelte';
describe('StatCard', () => {
it('renders the title and value', () => {
render(StatCard, { props: { title: 'Movies', value: 42 } });
expect(screen.getByText('Movies')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});
it('renders a loading state when value is undefined', () => {
render(StatCard, { props: { title: 'Movies', value: undefined } });
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
```
## What to analyze in the component
Read the `.svelte` file carefully:
- What props (`$props()`) does it accept? What are their types and defaults?
- What does the component render? What are the key visible elements?
- Are there conditional blocks (`{#if}`, `{#each}`) to test both branches of?
- Does it emit events or call callbacks passed as props?
- Does it use `$bindable()` for two-way binding?
- Does it fetch data or call server functions? If so, mock those.
- What ARIA roles or labels are present?
Generate a complete, focused test file. Keep tests small and single-purpose. Prefer `getByRole` over `getByText` for interactive elements.
## Project context
- Components live under `src/lib/components/` (ui/, layout/, auth/) or `src/routes/**/*.svelte`
- Icons use `@lucide/svelte` components
- Tailwind classes are not assertion targets — test behavior and text content
@@ -0,0 +1,63 @@
---
description: 'Scaffold a new Drizzle database migration for The Collective Hub. Generates schema additions in schema.ts and runs drizzle-kit generate to produce the SQL migration.'
agent: 'agent'
---
Generate a Drizzle database migration for: ${input}
Follow all conventions in:
- [database-schema.instructions.md](.github/instructions/database-schema.instructions.md)
- [multi-tenant-architecture.instructions.md](.github/instructions/multi-tenant-architecture.instructions.md)
## Rules
1. **Additive changes only** — never add destructive operations (ALTER DROP, RENAME) in a production migration
2. **Every new table must have a `siteId` column** — references `sites.id` with `onDelete: 'cascade'`
3. **Every new table must have:**
- `id: uuid('id').defaultRandom().primaryKey()`
- `createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()`
- `updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date())`
4. **Use snake_case for column names** in the database
5. **Add indexes** on `siteId` and any commonly filtered columns
6. **Use appropriate types:** `text`, `boolean`, `integer`, `jsonb`, `timestamp`
## Steps
1. Add the new table/column definition to [`src/lib/server/db/schema.ts`](src/lib/server/db/schema.ts)
2. Run `npx drizzle-kit generate` to create the SQL migration file
3. Review the generated SQL in `drizzle/` to ensure it matches expectations
## Example: New Table
```ts
// In schema.ts
export const newTable = pgTable(
'new_table',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
index('new_table_site_id_idx').on(table.siteId),
]
);
```
## Example: New Column
```ts
// Add to an existing table definition
export const events = pgTable('events', {
// ... existing columns
newColumn: text('new_column'), // Add new optional column
});
```
@@ -0,0 +1,90 @@
---
description: 'Scaffold seed data script for The Collective Hub. Generates a seed.mjs file that creates a local dev site, default settings, and optional sample data.'
agent: 'agent'
---
Generate or update a seed script for The Collective Hub.
Follow the conventions in:
- [database-schema.instructions.md](.github/instructions/database-schema.instructions.md)
- [multi-tenant-architecture.instructions.md](.github/instructions/multi-tenant-architecture.instructions.md)
## Purpose
The seed script creates a local development site so that the app runs without requiring an existing site in the database.
## Script Structure ([`scripts/seed.mjs`](scripts/seed.mjs))
```js
// @ts-check
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../src/lib/server/db/schema.js';
const connectionString = process.env.DATABASE_URL || 'postgresql://localhost:5432/collective_hub';
const client = postgres(connectionString);
const db = drizzle(client, { schema });
async function seed() {
console.log('🌱 Seeding database...');
// Upsert site
const [site] = await db
.insert(schema.sites)
.values({
slug: 'local-dev',
name: 'Local Dev Site',
isActive: true,
})
.onConflictDoUpdate({ target: schema.sites.slug, set: { name: 'Local Dev Site' } })
.returning();
// Upsert site settings
const defaultSettings = {
branding: {
siteName: 'Local Dev Site',
tagline: 'A community for watching bad movies',
logoCdnKey: null,
backgroundCdnKey: null,
faviconCdnKey: null,
},
theme: {
preset: 'dark',
accentColor: '#e63946',
backgroundColor: '#1a1a2e',
textColor: '#eaeaea',
},
homepage: {
heroTitle: 'Welcome to Local Dev Site',
heroSubtitle: 'We watch bad movies so you don\'t have to',
aboutText: 'A community of bad movie enthusiasts who gather weekly to watch, laugh, and critique the worst cinema has to offer.',
primaryButtonText: 'Join us on Discord',
primaryButtonLink: 'https://discord.gg/example',
showNextEvent: true,
showSchedule: true,
},
layout: {
preset: 'standard',
},
};
await db
.insert(schema.siteSettings)
.values({ siteId: site.id, settings: defaultSettings })
.onConflictDoUpdate({ target: schema.siteSettings.siteId, set: { settings: defaultSettings } });
console.log(`✅ Seeded site: ${site.slug} (${site.id})`);
await client.end();
}
seed().catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
});
```
## Environment
- Set `DATABASE_URL` env var for the target database
- Defaults to `postgresql://localhost:5432/collective_hub` for local dev
- Run with: `node scripts/seed.mjs`
@@ -0,0 +1,71 @@
---
description: 'Scaffold a new Svelte 5 component for The Collective Hub. Follows runes convention, snippets over slots, Tailwind CSS, and Lucide icons.'
agent: 'agent'
---
Scaffold a Svelte 5 component for The Collective Hub: ${input}
Follow all conventions in:
- [components.instructions.md](.github/instructions/components.instructions.md)
- [svelte5.instructions.md](.github/instructions/svelte5.instructions.md)
- [icons.instructions.md](.github/instructions/icons.instructions.md)
- [tailwindcss.instructions.md](.github/instructions/tailwindcss.instructions.md)
## Conventions
1. **PascalCase filename**`TheComponent.svelte`
2. **Place in appropriate folder:**
- `src/lib/components/ui/` — generic reusable UI primitives
- `src/lib/components/layout/` — structural shell components
- Route directory — page-specific components
3. **Use Svelte 5 runes**:
- `$props()` for component props with inline type
- `$state()` for local state
- `$derived()` for computed values
- `$effect()` for side effects (sparingly)
- `$bindable()` for two-way bindings
4. **Snippets over slots** for composition patterns
5. **Tailwind CSS** for styling
6. **Lucide icons** from `@lucide/svelte` — import individual icons by name
7. **No `svelte-ignore` comments** — fix accessibility and type issues properly
## Example Component
```svelte
<script lang="ts">
import { Heart } from '@lucide/svelte';
let {
title,
description = '',
liked = $bindable(false),
}: {
title: string;
description?: string;
liked?: boolean;
} = $props();
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold">{title}</h2>
<button
onclick={() => (liked = !liked)}
class="ml-auto"
aria-label={liked ? 'Unlike' : 'Like'}
>
<Heart class={liked ? 'fill-red-500 text-red-500' : ''} />
</button>
</div>
{#if description}
<p class="mt-2 text-gray-600">{description}</p>
{/if}
<p class="mt-2">Count: {count} (doubled: {doubled})</p>
<button onclick={() => count++} class="mt-2 rounded bg-blue-600 px-3 py-1 text-white">
Increment
</button>
</div>
```
+80
View File
@@ -0,0 +1,80 @@
---
description: 'Scaffold a new SvelteKit route with a server load function and Svelte 5 page component for The Collective Hub. Generates +page.server.ts (auth guard, site-scoped Drizzle query, typed return) and +page.svelte (runes, Tailwind layout).'
name: 'New Route'
argument-hint: "Route path and purpose (e.g. 'admin/events detail page' or 'public schedule page')"
agent: 'agent'
---
Scaffold a new SvelteKit route for: ${input}
Follow all conventions in:
- [multi-tenant-architecture.instructions.md](.github/instructions/multi-tenant-architecture.instructions.md)
- [auth-and-roles.instructions.md](.github/instructions/auth-and-roles.instructions.md)
- [svelte5.instructions.md](.github/instructions/svelte5.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to create
Determine the route path from the input (e.g. `admin/events``src/routes/admin/events/`).
### 1. `+page.server.ts`
```ts
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { eq, and } from 'drizzle-orm';
import { /* yourTable */ } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.user) redirect(302, '/login');
const siteId = locals.site.id;
// Site-scoped query — always filter by siteId
const items = await db
.select()
.from(/* yourTable */)
.where(
and(
eq(/* yourTable */.siteId, siteId),
// Add more filters as needed
)
)
.orderBy(/* yourTable */.createdAt);
return {
items,
settings: locals.siteSettings,
};
};
```
- For **admin routes**, add a role check after the auth guard: `if (locals.membership?.role !== 'owner' && locals.membership?.role !== 'admin') redirect(302, '/admin');`
- For **public routes**, skip the auth guard entirely — just load data scoped by `siteId`
- Add `actions: Actions` only if the route has a form — skip it for read-only pages
- Use `fail` / `redirect` from `@sveltejs/kit` in actions per [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
### 2. `+page.svelte`
```svelte
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let { items, settings } = data;
</script>
<div class="p-6">
<h1 class="text-2xl font-bold"><!-- Page title --></h1>
<!-- Render {items} here -->
</div>
```
- Use Svelte 5 runes throughout (`$props`, `$state`, `$derived`)
- Tailwind utility classes for layout (`p-6`, `flex`, `gap-4`, etc.)
- No `export let`, no `on:` directives, no `<slot>`
## After generating
List the files created and their paths. Ask if a form action should be added.
+190
View File
@@ -0,0 +1,190 @@
---
agent: 'agent'
description: Analyse test coverage gaps for a file or directory and generate missing tests
---
You are performing test coverage analysis and generating missing tests for The Collective Hub project.
## Your task
The user has provided one or more source files or a directory to analyse. For each file:
1. Read the source file
2. Identify all exported functions, components, handlers, and branches that lack test coverage
3. Check whether a co-located test file already exists
4. Generate or extend the test file to cover the gaps
If the user has not specified a file, ask them to provide one before proceeding.
---
## Coverage analysis checklist
For each source file, enumerate all of the following that need tests:
### Server files (`*.server.ts`, `+server.ts`, `+page.server.ts`)
- [ ] Every exported `load` function — success path, unauthenticated redirect, DB error
- [ ] Every form `action` — valid input, `fail()` path, redirect path
- [ ] Every HTTP handler (`GET`, `POST`, `PATCH`, `DELETE`) — 2xx and 4xx/5xx paths
- [ ] Auth guard branches — unauthenticated (`locals.user` is null), insufficient role
- [ ] Query results — empty array, single result, multiple results
### Svelte components (`*.svelte`)
- [ ] Default render with required props
- [ ] Every `{#if}` branch (both true and false)
- [ ] Every `{#each}` branch — empty array, non-empty array
- [ ] User interactions — click, input, form submit
- [ ] Conditional class/style variations driven by props
- [ ] Slot/snippet presence and absence (note as limitation if not testable)
### Utility / library files (`*.ts`)
- [ ] Every exported function — happy path
- [ ] Edge cases: empty input, null/undefined, boundary values
- [ ] Error paths: thrown errors, returned error objects
---
## File placement rules
| Source file | Test file |
| ------------------------------------------- | -------------------------------------------------- |
| `src/lib/utils/format.ts` | `src/lib/utils/format.test.ts` |
| `src/lib/components/ui/ServiceCard.svelte` | `src/lib/components/ui/ServiceCard.svelte.test.ts` |
| `src/routes/api/contact/+server.ts` | `src/routes/api/contact/server.test.ts` |
| `src/routes/(app)/services/+page.server.ts` | `src/routes/(app)/services/page.server.test.ts` |
> **Drop the `+` prefix** for route test files — SvelteKit errors on `+server.test.ts` at build time.
> Server tests use `.test.ts`; component tests use `.svelte.test.ts`.
---
## Mocking rules
### Always mock the database
`vi.mock` is hoisted before `const` declarations. **Always use `vi.hoisted()`** for mock functions referenced inside `vi.mock` factories:
```ts
const { mockFindMany, mockInsert } = vi.hoisted(() => ({
mockFindMany: vi.fn(),
mockInsert: vi.fn()
}));
vi.mock('$lib/server/db', () => ({
db: {
query: {
services: { findMany: mockFindMany, findFirst: vi.fn() }
},
insert: mockInsert
}
}));
```
Reset mocks in `beforeEach`:
```ts
beforeEach(() => {
vi.clearAllMocks();
});
```
### Create a mock RequestEvent for +server.ts handlers
```ts
function mockEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
request: new Request('http://localhost/api/resource', { method: 'GET' }),
locals: { user: { id: 'user-1', role: 'user' } },
params: {},
url: new URL('http://localhost/api/resource'),
...overrides
} as unknown as RequestEvent;
}
```
### Create a mock PageServerLoadEvent for load functions
```ts
function mockLoadEvent(overrides = {}) {
return {
locals: { user: { id: 'user-1', role: 'user' } },
params: {},
url: new URL('http://localhost/page'),
...overrides
} as any;
}
```
### Stub fetch for external API calls
```ts
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 }))
);
```
---
## Test structure
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// mocks before imports that use them
const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
vi.mock('$lib/server/db', () => ({ db: { query: { table: { findMany: mockFn } } } }));
import { myFunction } from './myModule';
describe('myFunction', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns expected result for valid input', async () => {
mockFn.mockResolvedValue([{ id: 1 }]);
const result = await myFunction('valid');
expect(result).toEqual({ id: 1 });
});
it('returns null when not found', async () => {
mockFn.mockResolvedValue([]);
const result = await myFunction('missing');
expect(result).toBeNull();
});
});
```
---
## requireAssertions rule
`expect.requireAssertions: true` is set globally. **Every `it` block must contain at least one `expect()`** or the test will fail even if no error is thrown.
---
## Coverage run command
After generating tests, remind the user they can check coverage with:
```sh
npm run test:run -- --coverage
```
The HTML report is written to `coverage/index.html`.
---
## Output format
For each source file analysed:
1. **Coverage gaps** — bullet list of untested functions/branches
2. **Test file path** — where the new/updated test file will go
3. **Generated test file** — full content, ready to save
If a test file already exists, show only the **new `it` blocks** to add, not the full file.
+132
View File
@@ -0,0 +1,132 @@
name: CI - The Collective Hub
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main, development]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'svelte.config.js'
- 'vite.config.ts'
- '.github/workflows/ci-web.yml'
- '.github/instructions/**'
- '.github/prompts/**'
- 'AGENTS.md'
pull_request:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- 'svelte.config.js'
- 'vite.config.ts'
- '.github/workflows/ci-web.yml'
- '.github/instructions/**'
- '.github/prompts/**'
- 'AGENTS.md'
workflow_dispatch:
env:
NODE_VERSION: '22.x'
jobs:
validate-and-build:
name: Validate & Build
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Create stub .env for build
run: |
cat <<'EOF' > .env
DATABASE_URL=postgresql://ci:ci@localhost:5432/ci
BETTER_AUTH_SECRET=ci-secret-for-build
SITE_SLUG=ci-test-site
OWNER_DISCORD_ID=000000000000000000
CDN_BASE_URL=http://cdn.example.com
ORIGIN=http://localhost:3000
EOF
- name: Check types
run: npx svelte-check --tsconfig ./tsconfig.json
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Unit tests
run: npx vitest --run
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 30
- name: Docker build test
run: docker build -t collective-hub-ci --target runner .
- name: Validate Markdown links
run: npx markdown-link-check --config .mlc-config.json ./*.md .github/**/*.md 2>/dev/null || echo "markdown-link-check not configured, skipping"
e2e-tests:
name: E2E Tests
needs: validate-and-build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Create stub .env for build
run: |
cat <<'EOF' > .env
DATABASE_URL=postgresql://ci:ci@localhost:5432/ci
BETTER_AUTH_SECRET=ci-secret-for-build
SITE_SLUG=ci-test-site
OWNER_DISCORD_ID=000000000000000000
CDN_BASE_URL=http://cdn.example.com
ORIGIN=http://localhost:4173
EOF
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
+67
View File
@@ -0,0 +1,67 @@
name: Release
on:
push:
tags:
- 'content/*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to create a release for (e.g., content/v1.0.0)'
required: true
type: string
jobs:
release:
name: Create GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Resolve tag and version
id: resolve
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
else
TAG="${{ github.ref_name }}"
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "Resolved tag: $TAG"
- name: Generate changelog
id: changelog
run: |
TAG="${{ steps.resolve.outputs.tag }}"
PREV_TAG=$(git tag --list "content/*" --sort=-version:refname | grep -v "^${TAG}$" | head -1 || true)
if [ -n "$PREV_TAG" ]; then
echo "Generating changelog from $PREV_TAG to $TAG"
CHANGELOG=$(git log "$PREV_TAG..$TAG" --pretty=format:"- %s (%h)" --no-merges 2>/dev/null || echo "- Initial release")
else
echo "No previous tag found — using full log"
CHANGELOG=$(git log --pretty=format:"- %s (%h)" --no-merges 2>/dev/null | head -50 || echo "- Initial release")
fi
[ -z "$CHANGELOG" ] && CHANGELOG="- No changes logged"
DELIM=$(openssl rand -hex 8)
echo "changelog<<$DELIM" >> $GITHUB_OUTPUT
echo "$CHANGELOG" >> $GITHUB_OUTPUT
echo "$DELIM" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.resolve.outputs.tag }}
name: ${{ steps.resolve.outputs.tag }}
body: |
## The Collective Hub — ${{ steps.resolve.outputs.tag }}
### What's Changed
${{ steps.changelog.outputs.changelog }}
token: ${{ secrets.GITHUB_TOKEN }}
+367
View File
@@ -0,0 +1,367 @@
## Project Configuration
- **Language**: TypeScript
- **Package Manager**: npm
- **Runtime**: Node.js 22
- **Framework**: SvelteKit 2.8.1 + Svelte 5
- **Database**: PostgreSQL
- **ORM**: Drizzle ORM 0.45 (with Kysely 0.29 as query builder)
- **Auth**: Better Auth 1.6 (Discord OAuth)
- **Image processing**: sharp 0.34
- **Dev tools**: prettier, eslint, vitest, playwright, tailwindcss v4
- **Deployment**: Coolify (multi-deploy), Docker
- **CDN**: Bunny CDN or S3-compatible
- **Add-ons**: mcp
---
# The Collective Hub — AI Agent Instructions
> **Single entry point for all AI assistants (Cline, Roo Code, Continue.dev, etc.) working on this project.**
---
## 1. Project Identity
**The Collective Hub** — A reusable SvelteKit website template system for launching branded landing pages for online theater hosts, watch-party communities, and Discord communities. One codebase → multiple deployed sites → one shared database + CDN.
We help community organizers look professional online, show their events, attract new members, and build a home for their community — without needing to build a website from scratch.
| Resource | Link |
| -------- | ---- |
| This file | [`AGENTS.md`](AGENTS.md) |
| Project brief | [`docs/00-project-brief.md`](docs/00-project-brief.md) |
| Architecture plan | [`docs/01-architecture-plan.md`](docs/01-architecture-plan.md) |
| Database plan | [`docs/02-database-plan.md`](docs/02-database-plan.md) |
| Feature roadmap | [`docs/03-feature-roadmap.md`](docs/03-feature-roadmap.md) |
| Environment variables | [`docs/04-environment-variables.md`](docs/04-environment-variables.md) |
| Admin UX plan | [`docs/05-admin-ux-plan.md`](docs/05-admin-ux-plan.md) |
| Public site UX plan | [`docs/06-public-site-ux-plan.md`](docs/06-public-site-ux-plan.md) |
| Development plan | [`docs/07-development-plan.md`](docs/07-development-plan.md) |
| Open questions | [`docs/08-open-questions.md`](docs/08-open-questions.md) |
| Risks and rules | [`docs/09-risks-and-rules.md`](docs/09-risks-and-rules.md) |
| Multi-tenant architecture | [`multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md) |
| Database schema | [`database-schema.instructions.md`](.github/instructions/database-schema.instructions.md) |
| Auth & roles | [`auth-and-roles.instructions.md`](.github/instructions/auth-and-roles.instructions.md) |
| Deployment guide | [`deployment-guide.instructions.md`](.github/instructions/deployment-guide.instructions.md) |
| Admin panel | [`admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md) |
| CDN & assets | [`cdn-and-assets.instructions.md`](.github/instructions/cdn-and-assets.instructions.md) |
| Public site theming | [`public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md) |
| API route patterns | [`api-route-patterns.instructions.md`](.github/instructions/api-route-patterns.instructions.md) |
| Testing (multi-tenant) | [`testing-multi-tenant.instructions.md`](.github/instructions/testing-multi-tenant.instructions.md) |
---
## 2. Platform Capabilities
### Core Platform
- **Multi-deployment model**: Each Coolify deployment runs the same Docker image; differentiated only by `SITE_SLUG` env var
- **Data scoping**: Every table has `siteId` — multi-tenant from day one
- **Site resolution**: [`hooks.server.ts`](src/hooks.server.ts) → [`site-resolver.ts`](src/lib/server/site-resolver.ts) → `locals.site` for all downstream code
- **Auth**: Owner bootstrap via `OWNER_DISCORD_ID`; super admin via `SUPER_ADMIN_DISCORD_IDS`; roles: owner/admin/editor
- **CDN**: Database stores only CDN keys (paths); full URLs constructed via `CDN_BASE_URL` env var
- **Image processing**: Automatic webp conversion + optimization via sharp on upload
### Admin Panel
- **Settings**: Edit site name, tagline, and general configuration
- **Branding**: Upload logo, background image, favicon; select color theme
- **Homepage editor**: Hero title, subtitle, about text, CTA button text/link
- **Links manager**: Add/edit/delete/reorder nav links and social links
- **Events manager**: Create, edit, publish/unpublish events with timezone support
- **Asset library**: Browse, upload, and manage media files with CDN URL copying
### Public Site
- **SSR-only single landing page**: Hero → About → Events → Social Links → Footer
- **Theme-aware**: CSS custom properties generated from site settings (dark/light/custom presets)
- **Responsive**: Works on mobile and desktop without client JS
- **Accessible**: Proper heading hierarchy, alt text, sufficient contrast
### Infrastructure
- **Coolify multi-deploy**: One Git repo, multiple deployments, shared database
- **Docker containerized**: Multi-stage Dockerfile, deployed via Coolify
- **Migration safety**: One deployment runs migrations (`RUN_MIGRATIONS=true`); others skip
- **Shared CDN**: Single bucket, site-scoped paths (`sites/{siteSlug}/...`)
---
## 3. Target Audience
### David (System Maintainer)
- Deploys new sites, maintains the shared codebase, pushes updates
- Has super admin access across all sites via `SUPER_ADMIN_DISCORD_IDS`
- Needs: reliable deployment process, clean migration strategy, maintainable code
### Site Owners (Theater Hosts / Community Organizers)
- Log in via Discord, customize their site through the admin panel
- Non-technical but motivated — need a simple, intuitive admin UI
- Needs: change branding, edit content, manage events, upload images
### Site Visitors (Community Members & Newcomers)
- Visit the public landing page to learn about the community
- Want to see what the community is about, when events happen, how to join
- Needs: fast-loading page, clear info, easy access to Discord/social links
---
## 4. Project Structure
```
.roo/
skills/ ← Roo Code skills for scaffolding and automation
admin-page/
api-endpoint/
db-migration/
generate-api-test/
generate-component-test/
new-component/
new-route/
run-tests/
test-coverage/
.github/
instructions/ ← Development guidelines for The Collective Hub
multi-tenant-architecture.instructions.md
database-schema.instructions.md
auth-and-roles.instructions.md
deployment-guide.instructions.md
admin-panel.instructions.md
cdn-and-assets.instructions.md
public-site-theming.instructions.md
api-route-patterns.instructions.md
testing-multi-tenant.instructions.md
bits-ui.instructions.md
components.instructions.md
icons.instructions.md
server-ts.instructions.md
svelte-ts.instructions.md
svelte5.instructions.md
tailwindcss.instructions.md
testing.instructions.md
prompts/ ← Scaffolding templates for routes, components, API, DB
new-route.prompt.md
generate-component-test.prompt.md
generate-api-endpoint.prompt.md
generate-admin-page.prompt.md
generate-db-migration.prompt.md
generate-svelte-component.prompt.md
generate-seed-data.prompt.md
test-coverage.prompt.md
workflows/ ← CI/CD and release automation
docs/ ← Architecture, database, roadmap, environment docs
00-project-brief.md
01-architecture-plan.md
02-database-plan.md
03-feature-roadmap.md
04-environment-variables.md
05-admin-ux-plan.md
06-public-site-ux-plan.md
07-development-plan.md
08-open-questions.md
09-risks-and-rules.md
drizzle/ ← Drizzle migration files
scripts/
seed.mjs ← Database seed script for local dev
src/
app.d.ts ← App types, locals augmentation
app.html ← HTML shell
hooks.server.ts ← Site resolver + auth + migration runner
lib/
server/
auth.ts ← Better Auth configuration
cdn.ts ← CDN URL helpers
site-resolver.ts ← Site loading by SITE_SLUG
db/
index.ts ← Drizzle + postgres connection
schema.ts ← All table definitions
seed.ts ← Optional seed data
migrate.ts ← Migration runner
shared/
types.ts ← Shared TypeScript types (SiteSettingsData, SiteContext, etc.)
routes/
+layout.server.ts ← Root layout, loads site for all pages
+layout.svelte ← Root layout shell
+page.server.ts ← Public homepage data loading
+page.svelte ← Public homepage
login/
+page.svelte ← Login page (Discord redirect)
admin/
+layout.server.ts ← Admin auth guard
+layout.svelte ← Admin shell/nav
+page.svelte ← Admin dashboard
settings/
+page.server.ts ← Site settings editor
+page.svelte
branding/
+page.server.ts ← Logo, colors, theme
+page.svelte
assets/
+page.server.ts ← Asset library
+page.svelte
api/
assets/
+server.ts ← Upload endpoint
static/ ← Favicon
```
> The [`.roo/skills/`](.roo/skills/) directory contains reusable skills for common development tasks — scaffolding admin pages, API endpoints, database migrations, components, routes, and generating tests. These are auto-detected by Roo Code when running in this project.
### Tech Stack Instructions
The repo includes development guidelines for the web application's tech stack:
| File | Purpose |
| ---- | ------- |
| [`multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md) | Site resolution, data scoping, deployment model, env vars |
| [`database-schema.instructions.md`](.github/instructions/database-schema.instructions.md) | All tables, relationships, indexes, migration strategy |
| [`auth-and-roles.instructions.md`](.github/instructions/auth-and-roles.instructions.md) | Better Auth, Discord OAuth, role system, super admin |
| [`deployment-guide.instructions.md`](.github/instructions/deployment-guide.instructions.md) | Coolify multi-deploy, Docker, migration runner, env setup |
| [`admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md) | Admin layout, auth guards, form patterns, admin pages |
| [`cdn-and-assets.instructions.md`](.github/instructions/cdn-and-assets.instructions.md) | CDN helpers, image upload, webp conversion, URL construction |
| [`public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md) | SSR-only landing page, CSS custom properties, theme presets |
| [`api-route-patterns.instructions.md`](.github/instructions/api-route-patterns.instructions.md) | API route conventions, asset upload, event CRUD, validation |
| [`testing-multi-tenant.instructions.md`](.github/instructions/testing-multi-tenant.instructions.md) | Multi-tenant test patterns, mocking site context, auth testing |
| [`bits-ui.instructions.md`](.github/instructions/bits-ui.instructions.md) | Component patterns for bits-ui library |
| [`components.instructions.md`](.github/instructions/components.instructions.md) | Svelte 5 component architecture and conventions |
| [`icons.instructions.md`](.github/instructions/icons.instructions.md) | Lucide icon usage guidelines |
| [`server-ts.instructions.md`](.github/instructions/server-ts.instructions.md) | SvelteKit server-side TypeScript patterns, Drizzle queries |
| [`svelte-ts.instructions.md`](.github/instructions/svelte-ts.instructions.md) | TypeScript conventions for Svelte projects |
| [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md) | Svelte 5 runes, snippets, and migrations |
| [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md) | Tailwind CSS v4 configuration and patterns |
| [`testing.instructions.md`](.github/instructions/testing.instructions.md) | Vitest + Playwright testing patterns |
> **Svelte 5 note:** `<svelte:component>` is deprecated. Use `{@const ComponentVar = ...}` in the template and render it as `<ComponentVar />` instead. See [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md#deprecated-apis) for details and migration examples.
---
## 5. How AI Agents Should Operate
This section defines how AI agents (Cline, Roo Code, Copilot, etc.) should work within this development context.
### Follow the Architecture
- **Always scope queries by `siteId`**. Every database query that touches site-owned data must filter by `siteId` from `locals.site`. Missing a `siteId` filter is the most common multi-tenant bug.
- **Respect the phase roadmap**. Phase 1 (Foundation) must be complete before Phase 2 starts. Don't build features from Phase 3+ before the core works. See [`docs/03-feature-roadmap.md`](docs/03-feature-roadmap.md).
- **Use the correct env pattern**. Server-side secret env vars use `$env/dynamic/private`. Never import private env modules from client-side code.
- **Store CDN keys, not URLs**. Database fields like `cdnKey` store paths (e.g., `sites/bad-movies-theater/logo.webp`), never full URLs. Use the `cdnUrl()` helper from [`src/lib/server/cdn.ts`](src/lib/server/cdn.ts) to construct full URLs.
### Stay On-Architecture
- **No site-specific conditionals**. Never write `if (site.slug === 'bad-movies-theater')`. All customization comes from database settings (branding, theme, homepage content).
- **Prefer JSON settings over migrations**. Adding a setting to the `siteSettings.settings` JSON column requires no database migration. Only create new tables/tables for structured data that needs indexes, foreign keys, or complex queries.
- **One layout in V1**. Don't create multiple layout options. One clean, flexible layout that adapts to content is better than three half-baked options.
- **Additive migrations only**. In production, always use additive changes (new columns, new tables). Avoid renames or destructive operations.
### Development Principles
- **Code for the shared codebase**. Every feature should work for all sites. If a feature is only useful for one site, reconsider whether it belongs in the shared codebase.
- **Keep the admin panel simple**. The admin exists to serve the public page. Don't build complex admin UIs before the public rendering works.
- **Security is everyone's responsibility**. Sanitize file uploads, validate all form input server-side, never expose private env vars to the client, protect against XSS in rendered content.
- **Test the boundary conditions**. Test with no data (empty site), test with missing settings, test with inactive sites, test with various role levels.
### When Creating Templates
- Use clear `[placeholders]` for variables that the developer must customize
- Include a brief example filled out so they see the expected pattern
- Reference the relevant instruction file for detailed conventions
---
## 6. Hard Rules (Non-Negotiable)
1. **Always filter by `siteId`.** Every Drizzle query on site-owned data must include a `siteId` filter. Missing this is the most common multi-tenant bug and causes data leaks between sites.
2. **Store CDN keys, not full URLs.** Database fields store paths like `sites/{slug}/logo.webp`. The `cdnUrl()` helper constructs full URLs using `CDN_BASE_URL`. Never store a full URL in the database.
3. **One deployment runs migrations.** Only the deployment with `RUN_MIGRATIONS=true` runs database migrations. Deploy this one first when schema changes are included in a release.
4. **No site-specific conditional logic.** Never write `if (site.slug === 'some-site')`. All per-site differences come from database settings. If a site genuinely needs a unique feature, build it as a configurable option for all sites.
5. **Never commit `.env` files.** Environment variables are configured per-deployment in Coolify. The `.env` file is in `.gitignore` and must never be committed.
6. **Use `$lib` aliases, not relative paths.** Always import from `$lib/server/db`, `$lib/components/`, etc. Never use relative imports like `../../lib/server/db`.
7. **Co-locate tests.** Test files go next to the source they test. Server tests use `.test.ts`, browser component tests use `.svelte.test.ts`.
8. **No `svelte-ignore` suppression comments.** All accessibility and type issues must be fixed properly. Never hide a warning behind a suppression comment.
9. **Prefer additive migrations.** In production, add new columns and tables. Avoid destructive operations (ALTER DROP, RENAME). JSON settings columns reduce migration frequency.
10. **Respect the phase roadmap.** Phase 1 must be fully working (public site + admin login + settings save) before Phase 2. A working simple site is more valuable than a half-built complex one.
---
## 7. File Reference Index
| File | Purpose |
| ---- | ------- |
| [`AGENTS.md`](AGENTS.md) | This file — master instructions for all AI agents working on The Collective Hub project |
| [`.github/copilot-instructions.md`](.github/copilot-instructions.md) | Copilot-specific instructions for development tasks |
| [`docs/00-project-brief.md`](docs/00-project-brief.md) | Project overview — what The Collective Hub is and who it's for |
| [`docs/01-architecture-plan.md`](docs/01-architecture-plan.md) | Technical architecture — stack, site resolution, deployment model |
| [`docs/02-database-plan.md`](docs/02-database-plan.md) | Database schema — all tables, relationships, migration strategy |
| [`docs/03-feature-roadmap.md`](docs/03-feature-roadmap.md) | Development phases and feature priorities across the roadmap |
| [`docs/04-environment-variables.md`](docs/04-environment-variables.md) | Environment variable reference — shared vs per-site |
| [`docs/05-admin-ux-plan.md`](docs/05-admin-ux-plan.md) | Admin panel user experience design and layout |
| [`docs/06-public-site-ux-plan.md`](docs/06-public-site-ux-plan.md) | Public site user experience and page structure |
| [`docs/07-development-plan.md`](docs/07-development-plan.md) | Step-by-step development plan and implementation order |
| [`docs/08-open-questions.md`](docs/08-open-questions.md) | Open design questions and pending decisions |
| [`docs/09-risks-and-rules.md`](docs/09-risks-and-rules.md) | Critical risks and design pitfalls to avoid |
| [`.github/instructions/multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md) | Site resolution flow, data scoping, deployment model |
| [`.github/instructions/database-schema.instructions.md`](.github/instructions/database-schema.instructions.md) | All tables, relationships, Drizzle query patterns |
| [`.github/instructions/auth-and-roles.instructions.md`](.github/instructions/auth-and-roles.instructions.md) | Better Auth, Discord OAuth, roles, auth guards |
| [`.github/instructions/deployment-guide.instructions.md`](.github/instructions/deployment-guide.instructions.md) | Coolify multi-deploy, Docker, migration runner |
| [`.github/instructions/admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md) | Admin routes, auth guards, form actions |
| [`.github/instructions/cdn-and-assets.instructions.md`](.github/instructions/cdn-and-assets.instructions.md) | CDN helpers, upload flow, sharp processing |
| [`.github/instructions/public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md) | SSR page, CSS custom properties, theme system |
| [`.github/instructions/api-route-patterns.instructions.md`](.github/instructions/api-route-patterns.instructions.md) | API route conventions, validation, site scoping |
| [`.github/instructions/testing-multi-tenant.instructions.md`](.github/instructions/testing-multi-tenant.instructions.md) | Multi-tenant test patterns, mocking, auth testing |
| [`.github/instructions/bits-ui.instructions.md`](.github/instructions/bits-ui.instructions.md) | Bits UI headless component patterns |
| [`.github/instructions/components.instructions.md`](.github/instructions/components.instructions.md) | Svelte 5 component architecture |
| [`.github/instructions/icons.instructions.md`](.github/instructions/icons.instructions.md) | Lucide icon usage guidelines |
| [`.github/instructions/server-ts.instructions.md`](.github/instructions/server-ts.instructions.md) | SvelteKit server-side TypeScript patterns |
| [`.github/instructions/svelte-ts.instructions.md`](.github/instructions/svelte-ts.instructions.md) | Svelte 5 .svelte.ts reactive module patterns |
| [`.github/instructions/svelte5.instructions.md`](.github/instructions/svelte5.instructions.md) | Svelte 5 runes, snippets, migrations |
| [`.github/instructions/tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md) | Tailwind CSS v4 configuration |
| [`.github/instructions/testing.instructions.md`](.github/instructions/testing.instructions.md) | Vitest + Playwright base testing patterns |
| [`.github/prompts/new-route.prompt.md`](.github/prompts/new-route.prompt.md) | Scaffold new SvelteKit routes with auth + site-scoped queries |
| [`.github/prompts/generate-component-test.prompt.md`](.github/prompts/generate-component-test.prompt.md) | Generate Vitest browser component tests |
| [`.github/prompts/test-coverage.prompt.md`](.github/prompts/test-coverage.prompt.md) | Analyse coverage gaps and generate missing tests |
| [`.github/prompts/generate-admin-page.prompt.md`](.github/prompts/generate-admin-page.prompt.md) | Scaffold admin page with auth guard + form |
| [`.github/prompts/generate-db-migration.prompt.md`](.github/prompts/generate-db-migration.prompt.md) | Scaffold Drizzle migration for schema changes |
| [`.github/prompts/generate-api-endpoint.prompt.md`](.github/prompts/generate-api-endpoint.prompt.md) | Scaffold API route with auth + site scoping |
| [`.github/prompts/generate-svelte-component.prompt.md`](.github/prompts/generate-svelte-component.prompt.md) | Scaffold Svelte 5 component with runes + Tailwind |
| [`.github/prompts/generate-seed-data.prompt.md`](.github/prompts/generate-seed-data.prompt.md) | Scaffold seed script for local dev |
| [`.github/workflows/`](.github/workflows/) | CI/CD and release automation |
| [`src/lib/server/site-resolver.ts`](src/lib/server/site-resolver.ts) | Site loading by SITE_SLUG — core multi-tenant logic |
| [`src/lib/server/auth.ts`](src/lib/server/auth.ts) | Better Auth with Discord OAuth configuration |
| [`src/lib/server/cdn.ts`](src/lib/server/cdn.ts) | CDN URL construction helpers |
| [`src/lib/server/db/schema.ts`](src/lib/server/db/schema.ts) | All Drizzle table definitions |
| [`src/lib/server/db/index.ts`](src/lib/server/db/index.ts) | Drizzle + postgres connection |
| [`src/lib/shared/types.ts`](src/lib/shared/types.ts) | SiteSettingsData, SiteContext, shared types |
| [`scripts/seed.mjs`](scripts/seed.mjs) | Database seed script for local dev |
| [`Dockerfile`](Dockerfile) | Multi-stage Docker build |
| [`docker-compose.yml`](docker-compose.yml) | Local development with Postgres |
---
## 8. Mode Responsibilities
This section maps AI agent operating modes to development tasks within The Collective Hub context.
| Mode | How It's Used Here |
| ---- | ------------------ |
| **🪃 Orchestrator** | Breaks down multi-tenant features into discrete tasks; coordinates site resolution, auth integration, admin panel work, and CDN setup; delegates research, implementation, and review stages across phases |
| **🏗️ Architect** | Plans technical architecture for multi-tenant features: site resolution flow, database schema additions, admin panel structure, deployment strategy, CDN integration patterns; creates technical specifications for new features |
| **💻 Code** | Builds and maintains the SvelteKit platform; implements Drizzle queries, admin form actions, public site rendering, auth guards, CDN upload flows; writes Vitest tests with multi-tenant mocking patterns |
| **❓ Ask** | Answers development questions about the codebase; explains multi-tenant architecture patterns (e.g., "how does site resolution work?"); provides recommendations on Drizzle queries, SvelteKit patterns, Postgres optimization |
| **🪲 Debug** | Troubleshoots multi-tenant issues (data leaks between sites, wrong site context, auth failures); diagnoses deployment problems (migration conflicts, env var misconfiguration, CDN URL issues); investigates database query performance |
---
> **For new AI agents**: Start by reading the relevant instruction file linked in [Section 7](#7-file-reference-index) above based on your task. When in doubt about architecture or patterns, review the multi-tenant architecture guidelines in [`multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md) and the risks document in [`docs/09-risks-and-rules.md`](docs/09-risks-and-rules.md). The core philosophy of this project is simple: one codebase, multiple sites, no data leaks, maintainable by one person.
+2 -120
View File
@@ -205,7 +205,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -222,7 +221,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -239,7 +237,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -256,7 +253,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -273,7 +269,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -290,7 +285,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -307,7 +301,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -324,7 +317,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -341,7 +333,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -358,7 +349,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -375,7 +365,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -392,7 +381,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -409,7 +397,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -426,7 +413,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -443,7 +429,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -460,7 +445,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -477,7 +461,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -494,7 +477,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -511,7 +493,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -528,7 +509,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -545,7 +525,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -562,7 +541,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -629,7 +607,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -646,7 +623,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -663,7 +639,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -680,7 +655,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -697,7 +671,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -714,7 +687,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -731,7 +703,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -748,7 +719,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -765,7 +735,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -782,7 +751,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -799,7 +767,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -816,7 +783,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -833,7 +799,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -850,7 +815,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -867,7 +831,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -884,7 +847,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -901,7 +863,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -918,7 +879,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -935,7 +895,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -952,7 +911,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -969,7 +927,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -986,7 +943,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1003,7 +959,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1020,7 +975,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1037,7 +991,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1054,7 +1007,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1945,7 +1897,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1959,7 +1910,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1973,7 +1923,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1987,7 +1936,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2001,7 +1949,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2015,7 +1962,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2029,7 +1975,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2043,7 +1988,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2057,7 +2001,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2071,7 +2014,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2085,7 +2027,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2099,7 +2040,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2113,7 +2053,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2127,7 +2066,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2141,7 +2079,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2155,7 +2092,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2169,7 +2105,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2183,7 +2118,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2197,7 +2131,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2211,7 +2144,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2225,7 +2157,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2239,7 +2170,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2253,7 +2183,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2267,7 +2196,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2281,7 +2209,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2619,7 +2546,7 @@
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz",
"integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3366,7 +3293,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3383,7 +3309,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3400,7 +3325,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3767,7 +3691,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -5120,7 +5043,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5137,7 +5059,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5154,7 +5075,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5171,7 +5091,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5188,7 +5107,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5205,7 +5123,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5222,7 +5139,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5239,7 +5155,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5256,7 +5171,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5273,7 +5187,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5290,7 +5203,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5307,7 +5219,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5324,7 +5235,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5341,7 +5251,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5358,7 +5267,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5375,7 +5283,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5392,7 +5299,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5409,7 +5315,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5426,7 +5331,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5443,7 +5347,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5460,7 +5363,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5477,7 +5379,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5494,7 +5395,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5563,7 +5463,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -5751,24 +5651,6 @@
"node": ">=0.4"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+584
View File
@@ -0,0 +1,584 @@
# Identity Adaptation Plan: The Web Cooperative → The Collective Hub
> **Goal:** Rewrite all project identity files from "The Web Cooperative" (marketing agency for small businesses) to "The Collective Hub" (multi-tenant SvelteKit template system for theater/community landing pages).
---
## Table of Contents
1. [AGENTS.md — Master Rewrite](#1-agentsmd--master-rewrite)
2. [.github/copilot-instructions.md — Rewrite](#2-githubcopilot-instructionsmd--rewrite)
3. [.github/instructions/ — Instruction Files](#3-githubinstructions--instruction-files)
4. [.github/prompts/ — Prompt Templates](#4-githubprompts--prompt-templates)
5. [.github/workflows/ — CI/CD Workflows](#5-githubworkflows--cicd-workflows)
6. [Summary: Files to Create vs Keep vs Delete](#6-summary-files-to-create-vs-keep-vs-delete)
7. [Execution Order](#7-execution-order)
---
## 1. AGENTS.md — Master Rewrite
**Action:** Full rewrite (~90% new content, ~10% salvageable structure)
### Section-by-Section Plan
| Section | Current (The Web Cooperative) | Target (The Collective Hub) |
|---------|------------------------------|-----------------------------|
| **Title** | "The Web Cooperative — AI Agent Instructions" | "The Collective Hub — AI Agent Instructions" |
| **Section 1: Project Identity** | Marketing help for small businesses: jargon-free, practical | Multi-tenant SvelteKit website template system for launching branded landing pages for online theater hosts, watch-party communities, and Discord communities. One codebase → multiple deployed sites → one shared database + CDN. |
| **Section 2: Services Offered** | 7 subsections of marketing services (strategy, SEO, ads, AI automation, etc.) | Replace with **Platform Capabilities** covering: multi-deployment model, site resolution, admin panel, public site rendering, CDN/assets, auth/roles, events/scheduling, theming/branding |
| **Section 3: Target Audience** | Small business owners, local service providers | Replace with **three personas**: David (system maintainer), Site Owners (theater hosts), Site Visitors (community members) — each with distinct needs and access levels |
| **Section 4: Project Structure** | Old `src/` structure with EspoCRM, marketing components | Update directory tree to match actual codebase — reflect `hooks.server.ts`, `site-resolver.ts`, `db/` schema, admin routes, asset management, CDN helpers |
| **Section 5: How AI Agents Should Operate** | Marketing-focused: write for small biz owners, budget-conscious, content generation principles | **Rewrite for development context:** Follow architecture patterns (siteId scoping, multi-tenant queries), respect phase roadmap (Phase 1 first), never write site-specific conditionals, prefer JSON settings over migrations, maintain Docker/Coolify compatibility |
| **Section 6: Hard Rules** | 9 marketing rules (no jargon, budget-conscious, templates, etc.) | **Replace with 10 dev-hard-rules:** (1) Always filter by `siteId`, (2) Store CDN keys not URLs, (3) One deployment runs migrations, (4) No site-specific conditionals, (5) Never commit `.env`, (6) Use `$lib` aliases not relative imports, (7) Co-locate tests, (8) No `svelte-ignore` comments, (9) Prefer additive migrations, (10) Respect the phase roadmap |
| **Section 7: File Reference Index** | All marketing files + old tech references | Update to reference: new instructions, prompt templates, docs/, schema files, auth helpers, CDN utilities |
| **Section 8: Mode Responsibilities** | Marketing-focused mode descriptions | **Rewrite for dev context:** Architect plans multi-tenant architecture; Code builds SvelteKit routes, DB queries, admin pages; Debug troubleshoots multi-deploy issues, migration conflicts; Ask answers SvelteKit/Drizzle/Postgres questions |
### Specific Content Changes
- **Line 1-5**: Update `AGENTS.md` header with Collective Hub project config (add Drizzle, Better Auth, PostgreSQL to add-ons)
- **Line 9**: `# The Collective Hub — AI Agent Instructions`
- **Line 17-32**: Complete rewrite of project identity with multi-tenant description
- **Line 36-127**: Replace "Services Offered" with "Platform Capabilities" — describe the system's features (site resolution, admin panel, auth, CDN, events, theming)
- **Line 130-148**: Replace target audience with the three personas
- **Line 152-227**: Update project structure to match actual codebase (verified from `src/` listing)
- **Line 235-248**: Keep tech stack instruction references but update file references
- **Line 252-281**: Rewrite "How AI Agents Should Operate" for development — focus on architecture patterns, not marketing
- **Line 286-301**: Replace hard rules with multi-tenant development rules
- **Line 306-341**: File reference — remove all marketing file references, add new instruction/prompt files
- **Line 344-355**: Mode responsibilities — rewrite for platform development
### Attachments to File
Add link to key documentation:
- [`docs/00-project-brief.md`](docs/00-project-brief.md) — Project overview
- [`docs/01-architecture-plan.md`](docs/01-architecture-plan.md) — Architecture decisions
- [`docs/02-database-plan.md`](docs/02-database-plan.md) — Schema documentation
- [`docs/04-environment-variables.md`](docs/04-environment-variables.md) — Env var reference
- [`docs/09-risks-and-rules.md`](docs/09-risks-and-rules.md) — Critical risks to avoid
---
## 2. .github/copilot-instructions.md — Rewrite
**Action:** Full rewrite (~95% new content)
| Current | Target |
|---------|--------|
| The Web Cooperative — Copilot Instructions | The Collective Hub — Copilot Instructions |
| Marketing-focused overview and file structure | Multi-tenant platform overview, architecture principles, key file references |
| References to marketing instruction files | References to dev instruction files (multi-tenant architecture, DB schema, auth, deployment) |
| "When asked to..." table for marketing tasks | "When working on..." table for dev tasks (admin pages, API routes, DB queries, auth, assets) |
---
## 3. .github/instructions/ — Instruction Files
### 3A. Tech Instructions to KEEP (with minor updates)
These files are mostly project-agnostic and need only `applyTo` description updates and path fixes.
#### [`bits-ui.instructions.md`](.github/instructions/bits-ui.instructions.md) — Minor Update
- **Description**: Change "The Web Cooperative website" → "The Collective Hub platform"
- **Content**: Already generic — keep as-is. Just update the `description` frontmatter line.
- **Changes**: ~2 lines
#### [`components.instructions.md`](.github/instructions/components.instructions.md) — Minor Update
- **Description**: Change "The Web Cooperative website" → "The Collective Hub platform"
- **Folder structure**: Keep `ui/` and `layout/` pattern, but add note about admin components living in `src/routes/admin/`
- **Changes**: ~3 lines (description, add admin component note)
#### [`icons.instructions.md`](.github/instructions/icons.instructions.md) — Minor Update
- **Description**: Change "The Web Cooperative website" → "The Collective Hub platform"
- **Content**: Already generic — keep as-is
- **Changes**: ~1 line
#### [`svelte-ts.instructions.md`](.github/instructions/svelte-ts.instructions.md) — No Changes
- Already completely generic (no project-specific references)
- **Changes**: None
#### [`svelte5.instructions.md`](.github/instructions/svelte5.instructions.md) — Minor Update
- **Description**: Change "The Web Cooperative website" → "The Collective Hub platform"
- **Content**: Already generic — keep as-is
- **Changes**: ~1 line
#### [`tailwindcss.instructions.md`](.github/instructions/tailwindcss.instructions.md) — Minor Update
- **Description**: Change "The Web Cooperative website" → "The Collective Hub platform"
- **Path reference**: Update layout.css path if needed (currently references `src/routes/layout.css`)
- **Changes**: ~2 lines
#### [`testing.instructions.md`](.github/instructions/testing.instructions.md) — Moderate Update
- **Description**: Change "The Web Cooperative website" → "The Collective Hub platform"
- **Add**: Section on testing with multi-tenant context (mocking `locals.site`, `locals.siteSettings`, `siteId` filtering)
- **Add**: Note about testing admin auth guards
- **Keep**: All existing patterns (Vitest browser/node split, `expect.element`, co-location, requireAssertions)
- **Changes**: ~15-20 lines added
#### [`server-ts.instructions.md`](.github/instructions/server-ts.instructions.md) — Significant Update
- **Description**: Change from "The Web Cooperative website... EspoCRM API client" → "The Collective Hub platform... Drizzle schema, site resolver, auth patterns"
- **Replace Section "EspoCRM API Client"** with **"Drizzle Database Patterns"**:
- Import `db` from `$lib/server/db`
- Always filter by `siteId` from `locals`
- Use `eq`, `and`, `desc` from `drizzle-orm`
- Pattern: `const { site } = locals; const items = await db.select().from(table).where(eq(table.siteId, site.id));`
- **Replace Section "Form Actions"** with **"Admin Form Actions"**:
- Auth guard first: `if (!locals.user) throw redirect(302, '/login');`
- Validate before write
- Use `fail` for validation errors
- **Add Section "Site Context Access"**:
- `locals.site` — site record
- `locals.siteSettings` — parsed settings JSON
- `locals.user` — authenticated user (or null)
- `locals.membership` — user's role for this site (or null)
- **Keep**: Env var rules (still valid), module sections
- **Changes**: ~50% of file rewritten
### 3B. [`docs-workflow.instructions.md`](.github/instructions/docs-workflow.instructions.md) — Complete Rewrite
Current: Legal documents workflow (master services agreement, privacy policy, etc.)
Target: Development documentation workflow
**New Content:**
- **Document Inventory**: Reference the docs/ folder files (00-09)
- **Source of Truth**: The docs/ `.md` files are authoritative
- **Update Workflow**: How to update architecture docs when decisions change
- **Phase Tracking**: How to mark phase deliverables as complete in roadmap
- **Template**: Standard doc format (title, purpose, decisions, risks)
### 3C. Marketing Instructions to REPLACE (9 files → 9 new dev instruction files)
| # | Old File | New File | Purpose |
|---|----------|----------|---------|
| 1 | `brand-voice.instructions.md` | `multi-tenant-architecture.instructions.md` | Site resolution, data scoping, deployment model, env vars |
| 2 | `copywriting.instructions.md` | `database-schema.instructions.md` | All tables, relationships, indexes, migration strategy |
| 3 | `seo.instructions.md` | `auth-and-roles.instructions.md` | Better Auth, Discord OAuth, role system, super admin |
| 4 | `local-seo.instructions.md` | `deployment-guide.instructions.md` | Coolify multi-deploy, Docker, migration runner, env setup |
| 5 | `social-media.instructions.md` | `admin-panel.instructions.md` | Admin layout, auth guards, form patterns, admin pages |
| 6 | `ad-copy.instructions.md` | `cdn-and-assets.instructions.md` | CDN helpers, image upload, webp conversion, URL construction |
| 7 | `ai-automation.instructions.md` | `public-site-theming.instructions.md` | SSR-only landing page, CSS custom properties, theme presets |
| 8 | `business-ops.instructions.md` | `api-route-patterns.instructions.md` | API route conventions, asset upload, event CRUD, validation |
| 9 | `tech-help.instructions.md` | `testing-multi-tenant.instructions.md` | Multi-tenant test patterns, mocking site context, auth testing |
#### New File Details
##### 1. [`multi-tenant-architecture.instructions.md`](.github/instructions/multi-tenant-architecture.instructions.md)
**Content:**
- Overview: One codebase, multiple Coolify deployments, one database + CDN
- Site resolution flow: `env.SITE_SLUG``hooks.server.ts``site-resolver.ts``locals.site`
- Data scoping rule: Every table has `siteId`, every query filters by it
- Deployment model diagram: Git repo → multiple Coolify deployments
- Migration safety: `RUN_MIGRATIONS` flag, exactly one deployment runs migrations
- Environment variable conventions (shared vs per-site)
- Risk reminders: No site-specific conditionals, no hardcoded CDN URLs
##### 2. [`database-schema.instructions.md`](.github/instructions/database-schema.instructions.md)
**Content:**
- Table reference: `sites`, `users`, `memberships`, `siteSettings`, `assets`, `navLinks`, `socialLinks`, `events`
- ER diagram (Mermaid)
- Key patterns: UUID PKs, snake_case columns, timestamps on every table
- Indexing rules: `siteId` indexed on every table, unique constraints
- JSON settings pattern: `siteSettings.settings` is a JSONB column
- Migration strategy: Additive only, one deployment runs migrations
- Drizzle patterns: How to write queries, schema definitions, typed inserts
##### 3. [`auth-and-roles.instructions.md`](.github/instructions/auth-and-roles.instructions.md)
**Content:**
- Better Auth with Discord OAuth setup
- Auth flow: Login → Discord redirect → callback → session creation
- Owner bootstrap: `OWNER_DISCORD_ID` env var
- Super admin: `SUPER_ADMIN_DISCORD_IDS` env var, cross-site access
- Role hierarchy: `owner` > `admin` > `editor`
- Auth guard patterns in `+layout.server.ts`
- `locals.user`, `locals.membership` — checking access in server code
##### 4. [`deployment-guide.instructions.md`](.github/instructions/deployment-guide.instructions.md)
**Content:**
- Coolify setup: One Git repo, multiple deployments
- Dockerfile build process
- Environment variable configuration per deployment
- Migration runner: Which deployment runs migrations, deploy order
- CDN storage configuration
- Adding a new site: Create DB row → deploy with new `SITE_SLUG` → set `OWNER_DISCORD_ID`
- Troubleshooting common deployment issues
##### 5. [`admin-panel.instructions.md`](.github/instructions/admin-panel.instructions.md)
**Content:**
- Admin route structure: `src/routes/admin/`
- Auth guard pattern in `+layout.server.ts`
- Admin layout shell with navigation
- Form action patterns: validate, save, redirect with flash
- Admin pages: settings, branding, homepage editor, links, events, assets
- Svelte 5 patterns in admin forms (two-way binding, `$bindable`, event handlers)
##### 6. [`cdn-and-assets.instructions.md`](.github/instructions/cdn-and-assets.instructions.md)
**Content:**
- CDN helper at `$lib/server/cdn.ts`
- URL construction: `CDN_BASE_URL + cdnKey`
- Upload flow: accept file → validate type/size → convert to webp (sharp) → upload to CDN → store record in `assets` table
- `cdnKey` convention: `sites/{siteSlug}/{type}/{filename}`
- Asset library browsing patterns
- Never store full URLs, only keys
##### 7. [`public-site-theming.instructions.md`](.github/instructions/public-site-theming.instructions.md)
**Content:**
- Public site: SSR-only single page, no client-side routing beyond initial load
- Section structure: `Hero → About → Events → Social Links → Footer`
- Theme system: CSS custom properties generated from `siteSettings.theme`
- Preset support: dark, light, custom presets
- Dynamic branding: logo, background image, favicon from CDN
- Layout presets (single layout for V1)
##### 8. [`api-route-patterns.instructions.md`](.github/instructions/api-route-patterns.instructions.md)
**Content:**
- SvelteKit `+server.ts` conventions
- API route structure: `src/routes/api/`
- Asset upload endpoint: multipart form, file validation, sharp conversion
- Event CRUD: list, create, update, delete with site scoping
- Form action alternatives (prefer form actions over API for admin)
- Authentication checks in API routes
- Response format conventions
##### 9. [`testing-multi-tenant.instructions.md`](.github/instructions/testing-multi-tenant.instructions.md)
**Content:**
- Testing with multi-tenant context: mocking `locals`
- Mocking the database: Drizzle mock patterns with `vi.hoisted`
- Testing auth guards: mock `locals.user`, `locals.membership`
- Testing site resolution: mock `getSiteBySlug` return values
- Testing admin form actions: `POST` to route, check redirects
- Testing API routes: mock file upload, verify CDN interaction
- Coverage targets for multi-tenant code paths
---
## 4. .github/prompts/ — Prompt Templates
### 4A. Tech Prompts to KEEP (with updates)
#### [`generate-api-test.prompt.md`](.github/prompts/generate-api-test.prompt.md) — Moderate Update
- **Description**: Already mentions "SvelteKit API handler"
- **Change**: Replace "The Web Cooperative project" → "The Collective Hub project"
- **Mocking patterns**: Update from generic to Collective Hub patterns (mock `$lib/server/db`, `$lib/server/cdn`, `$lib/server/auth`)
- **Add**: Multi-tenant test patterns (mock `locals.site`, test site-scoped queries)
- **Changes**: ~10-15 lines
#### [`generate-component-test.prompt.md`](.github/prompts/generate-component-test.prompt.md) — Minor Update
- **Description**: Replace "The Web Cooperative" → "The Collective Hub"
- **Content**: Likely already generic — verify and update project name references
- **Changes**: ~2-3 lines
#### [`new-route.prompt.md`](.github/prompts/new-route.prompt.md) — Moderate Update
- **Description**: Replace "The Web Cooperative website" → "The Collective Hub platform"
- **Auth guard pattern**: Update to use Collective Hub's `locals.site` and `locals.membership`
- **Load function template**: Add site-scoped query pattern
- **Add**: Note about admin routes vs public routes
- **Changes**: ~15 lines
#### [`test-coverage.prompt.md`](.github/prompts/test-coverage.prompt.md) — Moderate Update
- **Description**: Replace "The Web Cooperative" → "The Collective Hub"
- **Coverage targets**: Adjust for multi-tenant code paths
- **Focus areas**: Admin routes, API routes, site resolver, auth logic, DB queries
- **Changes**: ~10 lines
### 4B. Marketing Prompts to REPLACE (5 files → 5 new dev prompts)
| # | Old File | New File | Purpose |
|---|----------|----------|---------|
| 1 | `competitor-analysis.prompt.md` | `generate-admin-page.prompt.md` | Scaffold new admin page with auth guard, form, and save pattern |
| 2 | `generate-ad-copy.prompt.md` | `generate-db-migration.prompt.md` | Scaffold Drizzle migration for schema changes |
| 3 | `generate-seo-content.prompt.md` | `generate-api-endpoint.prompt.md` | Scaffold API route with auth, validation, site scoping |
| 4 | `generate-website-copy.prompt.md` | `generate-svelte-component.prompt.md` | Scaffold Svelte component with runes, Tailwind, snippets |
| 5 | `social-media-content.prompt.md` | `generate-seed-data.prompt.md` | Scaffold seed script for local dev and new site setup |
#### New Prompt Details
##### 1. [`generate-admin-page.prompt.md`](.github/prompts/generate-admin-page.prompt.md)
**Content:**
```markdown
Scaffold a new admin page for The Collective Hub at path `admin/{section}/`.
## What to create
### 1. `+page.server.ts`
- Auth guard: verify `locals.user` and `locals.membership` role
- Load current settings/data from DB scoped by `locals.site.id`
- Form actions: validate, save to DB, redirect with success message
### 2. `+page.svelte`
- Import types from `./$types`
- Use Svelte 5 runes: `$props()`, `$state()`, `$derived()`
- Form with Tailwind styling
- Save/cancel buttons
```
##### 2. [`generate-db-migration.prompt.md`](.github/prompts/generate-db-migration.prompt.md)
**Content:**
```markdown
Generate a Drizzle migration for The Collective Hub.
## Rules
- Additive changes only (new tables, new columns)
- Every new table must have a `siteId` foreign key
- Every new table must have UUID PK, createdAt, updatedAt
- Use `drizzle-kit generate` to create the SQL migration
- Update schema.ts first, then run generation
```
##### 3. [`generate-api-endpoint.prompt.md`](.github/prompts/generate-api-endpoint.prompt.md)
**Content:**
```markdown
Scaffold a SvelteKit API endpoint for The Collective Hub at `api/{resource}/`.
## What to create
### `+server.ts`
- Import `db` from `$lib/server/db`
- Access `locals.site` for site scoping
- All queries filter by `siteId`
- Auth check: `if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 })`
- Validate request body with Zod or manual checks
- Return `json()` responses
```
##### 4. [`generate-svelte-component.prompt.md`](.github/prompts/generate-svelte-component.prompt.md)
**Content:**
```markdown
Scaffold a Svelte 5 component for The Collective Hub.
## Conventions
- PascalCase filename
- `$lib/components/` path for shared components
- Route-level components stay in route directory
- Use `$props()` with inline type
- Use snippets over slots for composition
- Tailwind CSS for styling
- Lucide icons from `@lucide/svelte`
- No `svelte-ignore` comments
```
##### 5. [`generate-seed-data.prompt.md`](..github/prompts/generate-seed-data.prompt.md)
**Content:**
```markdown
Generate a seed script for The Collective Hub.
## What to create in `scripts/seed.mjs`
### Site Record
- Insert a site row with slug matching `SITE_SLUG` env var
- Name, isActive=true
### Site Settings
- Insert siteSettings row with default branding, theme, homepage JSON
### Default Data
- Optional default navLinks, socialLinks, sample events
- Only if the phase requires it
```
---
## 5. .github/workflows/ — CI/CD Workflows
### [`ci-web.yml`](.github/workflows/ci-web.yml) — Moderate Update
**Changes:**
1. **Title**: "CI" → "CI - The Collective Hub" (cosmetic)
2. **Env vars**: Add Collective Hub-specific stubs for build:
- `SITE_SLUG=ci-test` (needed for build)
- `OWNER_DISCORD_ID=000000000000000000` (stub)
- `CDN_BASE_URL=http://cdn.example.com` (stub)
3. **Trigger paths**: Already correct (src/, package.json, etc.)
4. **Steps**: Add Docker build test stage:
```yaml
- name: Build Docker image
run: docker build -t collective-hub-ci .
```
5. **Coverage**: Already using vitest, keep as-is
6. **Markdown validation**: Keep but update config to exclude old marketing files
### [`release.yml`](.github/workflows/release.yml) — Minor Update
**Changes:**
1. **Line 63**: Change `## The Web Cooperative — ${{ steps.resolve.outputs.tag }}` → `## The Collective Hub — ${{ steps.resolve.outputs.tag }}`
2. **Tag prefix**: Optionally change `content/*` to `release/*` for conventional release tags (or keep `release/*` — discuss with David)
---
## 6. Summary: Files to Create vs Keep vs Delete
### Files to CREATE (new content, no existing version useful)
| # | File Path |
|---|-----------|
| 1 | `.github/instructions/multi-tenant-architecture.instructions.md` |
| 2 | `.github/instructions/database-schema.instructions.md` |
| 3 | `.github/instructions/auth-and-roles.instructions.md` |
| 4 | `.github/instructions/deployment-guide.instructions.md` |
| 5 | `.github/instructions/admin-panel.instructions.md` |
| 6 | `.github/instructions/cdn-and-assets.instructions.md` |
| 7 | `.github/instructions/public-site-theming.instructions.md` |
| 8 | `.github/instructions/api-route-patterns.instructions.md` |
| 9 | `.github/instructions/testing-multi-tenant.instructions.md` |
| 10 | `.github/prompts/generate-admin-page.prompt.md` |
| 11 | `.github/prompts/generate-db-migration.prompt.md` |
| 12 | `.github/prompts/generate-api-endpoint.prompt.md` |
| 13 | `.github/prompts/generate-svelte-component.prompt.md` |
| 14 | `.github/prompts/generate-seed-data.prompt.md` |
### Files to REWRITE (existing file, >70% new content)
| # | File Path | New Content % |
|---|-----------|---------------|
| 1 | `AGENTS.md` | ~90% |
| 2 | `.github/copilot-instructions.md` | ~95% |
| 3 | `.github/instructions/docs-workflow.instructions.md` | ~95% |
| 4 | `.github/instructions/server-ts.instructions.md` | ~50% (keep env var rules, replace EspoCRM sections) |
### Files to UPDATE (existing file, <30% new content)
| # | File Path | Changes |
|---|-----------|---------|
| 1 | `.github/instructions/bits-ui.instructions.md` | Update description line only |
| 2 | `.github/instructions/components.instructions.md` | Update description, add admin component note |
| 3 | `.github/instructions/icons.instructions.md` | Update description line |
| 4 | `.github/instructions/svelte5.instructions.md` | Update description line |
| 5 | `.github/instructions/tailwindcss.instructions.md` | Update description, path references |
| 6 | `.github/instructions/testing.instructions.md` | Add multi-tenant testing section |
| 7 | `.github/prompts/generate-api-test.prompt.md` | Update project name, mock patterns |
| 8 | `.github/prompts/generate-component-test.prompt.md` | Update project name |
| 9 | `.github/prompts/new-route.prompt.md` | Update auth guard, add site-scoped patterns |
| 10 | `.github/prompts/test-coverage.prompt.md` | Update project name, coverage targets |
| 11 | `.github/workflows/ci-web.yml` | Add build env stubs, Docker build test |
| 12 | `.github/workflows/release.yml` | Update release title |
### Files to DELETE (no longer relevant)
None — the old marketing files (`brand-voice.instructions.md`, `seo.instructions.md`, etc.) are being replaced by new files with different names. The old files must be **removed** to avoid confusion. This is a delete-then-create operation for 9 instructions + 5 prompts = 14 files to delete.
### Files to KEEP AS-IS
| # | File Path | Reason |
|---|-----------|--------|
| 1 | `.github/instructions/svelte-ts.instructions.md` | Already generic, no project references |
---
## 7. Execution Order
The work should be done in this order, grouped by dependency:
### Phase A: Foundation (parallel-safe)
1. Delete 9 old marketing instruction files
2. Delete 5 old marketing prompt files
3. Create 9 new instruction files (all independent, can be done in parallel)
4. Create 5 new prompt files (all independent, can be done in parallel)
### Phase B: Updates (depends on Phase A for context)
5. Update `server-ts.instructions.md` — needs to know new instruction file names for cross-references
6. Update `testing.instructions.md` — add multi-tenant patterns
7. Update 4 tech prompts (api-test, component-test, new-route, test-coverage)
8. Update `docs-workflow.instructions.md`
### Phase C: Master files (depends on Phase B for file references)
9. Rewrite `AGENTS.md` — references all instruction and prompt files
10. Rewrite `.github/copilot-instructions.md` — references all instruction files
### Phase D: CI/CD (independent)
11. Update `ci-web.yml`
12. Update `release.yml`
### Phase E: Cleanup
13. Run `svelte-check` to verify no broken imports
14. Run `vitest` to verify tests still pass
15. Verify markdown links are valid using `markdown-link-check`
---
## Appendix: File Content Comparison
### Old vs New: Brand Voice → Multi-Tenant Architecture
**Old (brand-voice.instructions.md):**
```
# Brand Voice & Tone Guidelines
## Voice Characteristics
- Direct, Honest, Practical, Plain language, Authoritative but approachable
## Tone Ladder
- Educational, Direct feedback, Supportive, Firm, Casual
```
**New (multi-tenant-architecture.instructions.md):**
```
# Multi-Tenant Architecture
## Site Resolution Flow
- env.SITE_SLUG → hooks.server.ts → site-resolver.ts → locals.site
## Data Scoping
- Every table has siteId, every query filters by it
## Deployment Model
- Git repo → multiple Coolify deployments → same Docker image
## Migration Safety
- RUN_MIGRATIONS flag, exactly one deployment runs migrations
## Environment Variables
- Shared vs per-site variable classification
```
### Old vs New: Copywriting → Database Schema
**Old (copywriting.instructions.md):**
```
# Copywriting Best Practices
- PAS formula, AIDA formula
- Feature-to-benefit mapping
- Headline templates, CTA formulas
```
**New (database-schema.instructions.md):**
```
# Database Schema
## Tables
- sites, users, memberships, siteSettings, assets, navLinks, socialLinks, events
## ER Diagram (Mermaid)
## Indexing Strategy
## Migration Patterns
## Query Examples
```
### Old vs New: SEO → Auth & Roles
**Old (seo.instructions.md):**
```
# SEO Content & Strategy Guidelines
- Keyword research
- On-page optimization
- Content structuring
```
**New (auth-and-roles.instructions.md):**
```
# Authentication & Roles
## Better Auth with Discord OAuth
## Auth Flow: Login → Callback → Session
## Owner Bootstrap (OWNER_DISCORD_ID)
## Super Admin (SUPER_ADMIN_DISCORD_IDS)
## Role Hierarchy: owner > admin > editor
## Auth Guard Patterns
```
---
> **End of Plan**
>
> This plan covers 35 files total: 14 new, 4 rewritten, 12 updated, 1 kept as-is, and 14 deleted.