AGENTS.md
Rama main · réplica de lectura del repositorio original.
# Agent Guidance
This document collects implementation rules and workflow conventions for code changes.
## Writing Code
**Read [`skills/writing-code/SKILL.md`](skills/writing-code/SKILL.md) before writing or editing any code.** The comment policy there is mandatory: default to no comments, use JSDoc only for function contracts, and never add an inline comment to explain a design choice — refactor the code instead. The only allowed inline comments record an external constraint the code cannot express.
## Security reviews
**Load [`skills/security-audit/SKILL.md`](skills/security-audit/SKILL.md) when reviewing or writing a change that touches a trust boundary:** auth, sessions, API keys, roles and permissions, org/tenant isolation, file uploads and downloads, webhooks, payments, user-controlled HTML or URLs, or LLM/MCP tool input. Use its guidance mode for these reviews. Run its full six-phase audit only when someone explicitly asks for a security audit, pen test, or comprehensive security review. For public API routes and MCP tools, apply it alongside [`skills/public-api-review/SKILL.md`](skills/public-api-review/SKILL.md).
## Research Requirements
When a task requires factual information (API specifications, context window sizes, library versions, pricing, rate limits, etc.), **look it up** using web search. Do not rely on educated guesses or assumptions from training data. If you're unsure whether something is a guess, look it up anyway.
## Out of scope paths
The `prototypes/` directory holds standalone HTML/CSS design mocks for exploration. Do not treat prototype files as production code: avoid implementing features there unless explicitly asked, and do not apply production review standards (CodeRabbit/Greptile exclude this path via `.coderabbit.yaml` and `.greptile/config.json`).
## Help Center screenshots
**Read [`skills/add-docs-image/SKILL.md`](skills/add-docs-image/SKILL.md) before capturing, producing, or placing any screenshot under `apps/help/**`.** Every help screenshot is captured at 1350×830 with device scale 1 and framed in the ClassroomIO browser board with `pnpm --filter @cio/help frame-screenshot`. Never ship an unframed image, and do not try to fix a soft-looking screenshot by raising the capture scale.
## Translation, Formatting, and Git Workflow
- If `apps/dashboard/src/lib/utils/translations/en.json` changes, update the other dashboard locale files before staging or committing.
- To translate dashboard locale changes, run `cd apps/dashboard && pnpm translate`.
- After running translation, review the generated locale changes and make sure placeholders wrapped in `{}` were preserved exactly. Fix any translated variable names before continuing.
- Format changed files with the repo's changed-file formatter before staging. Prefer `pnpm format:changed` from the repository root. Use broader format commands only if the changed-file formatter is not sufficient for the task.
- If you changed files under `packages/ui/src/**`, make sure the required `ui:` Tailwind prefix rules still pass. Run `pnpm --filter @cio/ui prefix` if needed before the final formatting pass.
- Stage files only after translation and formatting are complete. Prefer explicit staging such as `git add path/to/file` or `git add <changed-files>` over broad staging commands.
- Before creating a commit, manually run the current `lefthook` checks that would run on `pre-commit`:
- `pnpm format:check`
- `pnpm --filter @cio/ui prefix:check:staged` if staged changes include `packages/ui/src/**/*.{svelte,ts,tsx,js,jsx}`
- Commit messages must follow the Conventional Commits 1.0.0 spec: https://www.conventionalcommits.org/en/v1.0.0/
- Never include agent-provider attribution in commits or commit trailers. Do not add `Co-authored-by` lines for Cursor, Claude, Codex, or any other tool.
## Verification before commit/PR
Before committing or opening/updating a PR, run the build checks that match the areas you changed. Use Node 24 (the repo's `.nvmrc` version):
```bash
export PATH="$HOME/.nvm/versions/node/v24.22.1/bin:$PATH"
```
- **Dashboard changes** (`apps/dashboard/**`, or shared packages consumed by the dashboard):
```bash
test -f apps/dashboard/.env || cp apps/dashboard/.env.example apps/dashboard/.env
pnpm --filter @cio/dashboard^... build && pnpm --filter @cio/dashboard build
```
- **API changes** (`apps/api/**`, or shared packages consumed by the API):
```bash
pnpm --filter @cio/api^... build && pnpm --filter @cio/api build
```
- **Certificate changes** (`packages/certificates/**`):
```bash
pnpm --filter @cio/certificates build
```
Also run `pnpm format:check` (see Translation, Formatting, and Git Workflow above). Do not commit if any verification step fails.
**Fact-check every commit against the docs.** Before each commit, spin up a sub agent to verify the commit's changes (`git diff --cached`) against our docs, help center, website and READMEs, and fix every inaccurate statement and stale screenshot it finds in the same commit. Procedure: [`skills/stage-and-commit/SKILL.md`](skills/stage-and-commit/SKILL.md), step 4.
## Database migrations
**One migration file per PR.** However many schema changes a branch makes, and however many times
you revise them while the PR is in review, they ship as a single migration. Fold later changes into
the file the branch already added rather than adding a second one.
**Block the merge if a new migration's `when` is not later than every migration already on `main`.**
`drizzle-orm` keeps no record of which migrations ran. It reads one high-water mark
(`select ... order by created_at desc limit 1`) and applies a migration only when
`lastCreatedAt < migration.folderMillis`, where `folderMillis` is the `when` in
`packages/db/src/migrations/meta/_journal.json`. A migration stamped below that mark never runs,
never errors, and never appears in `drizzle.__drizzle_migrations`: the objects it creates simply do
not exist. A future-dated `when` is the same bug mirrored, marking the ledger ahead of reality so
later migrations are skipped.
Fresh databases apply everything in journal order and are unaffected, so this passes on a rebuilt
local database and on CI, then silently no-ops everywhere else.
Check before merging any PR that adds a migration:
```bash
git show origin/main:packages/db/src/migrations/meta/_journal.json |
python3 -c "import json,sys; print(max(e['when'] for e in json.load(sys.stdin)['entries']))"
python3 -c "import json; print(max(e['when'] for e in json.load(open('packages/db/src/migrations/meta/_journal.json'))['entries']))"
```
The branch value must be greater. If it is not, restamp the new entry to `int(time.time() * 1000)`
and renumber the file so its index follows main's last migration. Renumber on a plain index
collision too: two branches both adding `0016_*` is the usual way this arises.
## Naming Convention
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
- **Variables must read clearly at the call site.** Avoid opaque single-letter or ultra-short names (`d`, `x`, `tmp`) unless they match a universal convention (e.g. `i` in a trivial index loop). Prefer names that state intent (`deadlineDate`, `parsedUrl`, `nextSection`).
- **After an early `return` (or other guard exit), leave a blank line** before the next statement so the “happy path” block is visually separated from guards.
- **Never put an `await` or a complex/multi-call expression inside an object literal** (construction or update). Assign each value to a well-named local first, then reference it in the object. This keeps the call site readable and debuggable.
```ts
// ❌ don't
org.limits = { students: toResourceUsage(await countActiveStudents(org.id), getPlanLimit('students', plan)) };
// ✅ do
const studentsUsed = await countActiveStudents(org.id);
const studentsLimit = getPlanLimit('students', plan);
org.limits = { students: toResourceUsage(studentsUsed, studentsLimit) };
```
## Persisted columns store data, not presentation
A user-content column (`content`, `body`, `description`, …) stores what the author typed and nothing else. Check this **at the write site** — the component or service building the value — not at the schema.
Never write into such a column:
- **Presentational markup** — wrapper elements, Tailwind classes, inline styles. Render that in a component instead.
- **Localized strings** — copy baked in at write time can never be re-translated, and it freezes the author's locale for every future reader.
- **Copied values from another row** — a quoted snippet, an author name, a title. Store the relationship (a real FK column) and join or derive the display value at render time, so it stays correct when the source row is edited or deleted.
```ts
// ❌ don't — markup, English copy and a copy of the parent all land in the column
const quote = `<blockquote class="...">Replying to @${target.fullname}"${target.snippet}"</blockquote>`;
await createComment(`${quote}${text}`, parentId);
// ✅ do — persist the relationship, render the label from live data
await createComment(text, parentId, target.commentId);
```
The tell that this went wrong: an edit form shows raw markup, because the markup *is* the content.
## Creating a New Route
### Step 1: Validation Schema
```typescript
// packages/utils/src/validation/account/account.ts
export const ZUpdateProfile = z.object({
fullname: z.string().min(5).optional(),
username: z.string().min(1).optional()
});
export type TUpdateProfile = z.infer<typeof ZUpdateProfile>;
```
### Step 2: Database query
Put all direct database access in pure query functions under `packages/db/src/queries/{domain}/`. Routes and services never call Drizzle/`db` (or equivalent) directly—they import and compose these helpers.
```typescript
// packages/db/src/queries/auth/profile.ts
export async function updateProfile(userId: string, data: Partial<TProfile>) {
// …db update returning the row or null if missing
}
```
### Step 3: Service
Services orchestrate one or more query functions (often inside a transaction), apply business rules, and raise `AppError` when something is invalid or missing. **Do not put SQL or other direct DB access in services**—only call query-layer functions and handle in-memory shaping or validation there.
```typescript
// apps/api/src/services/account.ts
import { updateProfile } from '@cio/db/queries/auth';
export async function updateUser(userId: string, data: Partial<TProfile>) {
const updated = await updateProfile(userId, data);
if (!updated) throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404);
return updated;
}
```
### Step 4: Route
Routes must return a single type (or a response shape whose `data` is a single type). If multiple shapes are needed, use separate endpoints or a keyed structure (e.g. `data` indexed by a key) so clients get one type per endpoint or key. Do not return a union of shapes from one route based on query parameters.
```typescript
// apps/api/src/routes/account/account.ts
export const accountRouter = new Hono().put(
'/user',
authMiddleware,
zValidator('json', ZUpdateProfile),
async (c) => {
try {
const user = c.get('user')!;
const data = c.req.valid('json');
const result = await updateUser(user.id, data);
return c.json({ success: true, data: result });
} catch (error) {
return handleError(c, error, 'Failed to update profile');
}
}
);
```
### Step 5: Register
```typescript
// apps/api/src/routes/account/index.ts
export * from './account';
// apps/api/src/app.ts
import { accountRouter } from '@api/routes/account';
export const app = new Hono().route('/account', accountRouter);
```
**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`.
```typescript
// apps/api/src/routes/internal/index.ts
import { Hono } from '@api/utils/hono';
import { internalAnalyticsRouter } from './analytics';
import { internalComplianceRouter } from './compliance';
export const internalRouter = new Hono()
.route('/compliance', internalComplianceRouter)
.route('/analytics', internalAnalyticsRouter);
// apps/api/src/app.ts
import { internalRouter } from '@api/routes/internal';
export const app = new Hono().route('/internal', internalRouter);
```
**Route mounts in `app.ts` must use a single root segment.** Do not mount nested paths like `.route('/internal/compliance', ...)` directly in `app.ts`. If a domain has sub-routers, compose them inside the domain's `routes/{domain}/index.ts` and expose a single aggregate router that `app.ts` mounts at `/${domain}`.
```typescript
// apps/api/src/routes/internal/index.ts
import { Hono } from '@api/utils/hono';
import { internalAnalyticsRouter } from './analytics';
import { internalComplianceRouter } from './compliance';
export const internalRouter = new Hono()
.route('/compliance', internalComplianceRouter)
.route('/analytics', internalAnalyticsRouter);
// apps/api/src/app.ts
import { internalRouter } from '@api/routes/internal';
export const app = new Hono().route('/internal', internalRouter);
```
### Step 6: Build
```bash
pnpm --filter @cio/api build
```
### Step 7: Frontend Types & API Class
```typescript
// apps/dashboard/src/lib/features/account/utils/types.ts
import { classroomio } from '$lib/utils/services/api';
export type UpdateProfileRequest = typeof classroomio.account.user.$put;
```
```typescript
// apps/dashboard/src/lib/features/account/api/account.svelte.ts
import type { UpdateProfileRequest } from '../utils/types';
class AccountApi extends BaseApiWithErrors {
async updateProfile(fields: TUpdateProfile) {
const result = ZUpdateProfile.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
return;
}
await this.execute<UpdateProfileRequest>({
requestFn: () => classroomio.account.user.$put({ json: result.data }),
logContext: 'updating profile',
onSuccess: () => {
snackbar.success('Profile updated');
this.success = true;
},
onError: (result) => {
if ('field' in result) this.errors[result.field] = result.error;
}
});
}
}
```
## Frontend Conventions
### Request Types and Where Types Live
Define all request and response-related types in the feature’s `utils/types.ts` file. **Do not define types or interfaces in `.svelte.ts` files** (e.g. `api/*.svelte.ts` or `store/*.svelte.ts`). Infer types from the API: request type from the RPC call, then success/data from the response type.
```typescript
// apps/dashboard/src/lib/features/org/utils/types.ts
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type GetTeamRequest = typeof classroomio.organization.team.$get;
export type CreateOrgRequest = typeof classroomio.organization.$post;
export type UpdateOrgRequest = typeof classroomio.organization.$put;
export type DeleteTeamRequest = (typeof classroomio.organization)['team'][':memberId']['$delete'];
export type GetTeamSuccess = Extract<InferResponseType<GetTeamRequest>, { success: true }>;
export type OrganizationTeamMembers = GetTeamSuccess['data'];
```
### Type Inference Rules
**Never import types from `@cio/db/queries` into dashboard code.**
**Never define types in `.svelte.ts` files.** Put them in `{domain}/utils/types.ts` and infer from API return types.
Pattern:
1. Define request type: `typeof classroomio.{route}.$method`
2. Infer response type: `InferResponseType<RequestType>`
3. Extract success response: `Extract<ResponseType, { success: true }>`
4. Extract data type: `SuccessType['data']`
For response shapes that need a small normalization (e.g. API returns `JSONValue`, app needs `Record<string, number>`), derive a normalized type in `types.ts` from the success `data` type (e.g. `Omit<Data, 'roleMapping'> & { roleMapping: Record<string, number> }`).
**Do not export custom types from `apps/api/src/rpc-types.ts`.** Only re-export Hono client types (e.g. `InferRequestType`, `InferResponseType`) and the typed client (`Client`, `hcWithType`). All feature-specific types belong in the dashboard's feature `utils/types.ts`, inferred from the API.
### API Class Pattern
API classes handle all logic; components should be thin.
### Translations (UI copy)
- No hardcoded user-facing text in components.
- Strings live in `apps/dashboard/src/lib/utils/translations/en.json`.
- Use `$t('key.path')` in markup and `t('key.path')` in script.
Example:
```svelte
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
</script>
<h1>{$t('course.navItem.lessons.heading_v2')}</h1>
```
Snackbar:
- Use translation keys directly (e.g. `snackbar.success('snackbar.profile.updated')`).
- Only use `t(...)` when combining keys or building dynamic strings.
Outside Svelte components:
- Use `t.get('key.path')` inside plain functions or utilities.
### Utility Functions Pattern
- Extract reusable transformations to `apps/dashboard/src/lib/features/{domain}/utils/{feature}-utils.ts`.
- Keep functions pure and accept all required data as parameters.
### Svelte store binding
You can bind form controls directly to a writable store’s properties. Svelte compiles `bind:value={$store.someProp}` so that reads come from the store and writes go back through the store (via `set`/`update`). Prefer this over duplicating state in the component and syncing with `$effect` (which can cause infinite loops or redundant updates).
```svelte
<script lang="ts">
import { inviteSettingsStore } from './store';
</script>
<InputField bind:value={$inviteSettingsStore.customExpiresAt} />
<Select.Root type="single" bind:value={$inviteSettingsStore.preset}>
```
### Form Structure
For large dashboard forms, group fields by section instead of rendering one long flat list of controls.
- Use a top-level `Field.Group` as the form container.
- Split each logical section into its own `Field.Set`.
- Use `Field.Legend` for the section title.
- Separate sibling sections with `Field.Separator`.
- Inside each section, wrap related controls in an inner `Field.Group`.
- Put every control inside `Field.Field`, and use `orientation="horizontal"` for toggle or checkbox rows.
- Use `Field.Description` for helper copy that applies to a whole section.
Pattern:
```svelte
<Field.Group>
<Field.Set>
<Field.Legend>{$t('settings.section.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$store.section.enabled} />
<Field.Label>{$t('settings.section.toggle')}</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.section.title')}</Field.Label>
<Input bind:value={$store.section.title} />
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.section.description')}</Field.Label>
<Textarea bind:value={$store.section.description} />
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.other.heading')}</Field.Legend>
<!-- more grouped fields -->
</Field.Set>
</Field.Group>
```
Use `@cio/ui/custom/*-field` wrappers for standard app forms. When building larger settings/editor screens that need legends, descriptions, separators, grouped rows, or mixed controls such as `Switch`, `Checkbox`, `RadioGroup`, image pickers, and action buttons, compose the form with `@cio/ui/base/field` primitives and the matching base inputs instead.
### Form inputs: live updates
- Anything the user should see react to typing (dirty state, save bars, enabled buttons, answers, counters, previews, search, error clearing) updates on `input`, never only on `change`/`blur`.
- Number inputs use `NumberField` (`@cio/ui/custom/number-field`). Never `Number(value)` an input directly.
- A field whose text is a formatted version of a parsed value (comma lists, JSON, trimmed lists) uses `FieldDraft` (`@cio/ui/hooks/field-draft.svelte`).
- Server-backed search boxes use `DebouncedSearch` (`$lib/utils/functions/debounced-search.svelte`).
- Edit page-local drafts, never global stores; never write the edited model from an `$effect` on mount.
### Reactive built-in collections
In Svelte 5, the built-in `Set` and `Map` classes are **not** reactive. Use `SvelteSet` and `SvelteMap` from `svelte/reactivity` instead. They are already reactive on their own — do **not** wrap them in `$state()`.
```svelte
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
let selectedIds = new SvelteSet<string>();
// Mutations are reactive — no clone-and-reassign needed
selectedIds.add('abc');
selectedIds.delete('abc');
selectedIds.clear();
</script>
```
### Avoiding Svelte 5 rerender and `$effect` loops
`$effect` re-runs when any reactive value it **reads** changes. Writing state inside an effect can retrigger it and cause infinite loops, redundant work, or flicker. Prefer explicit event handlers and bindings over effect-driven syncing.
**Do not reset UI state in an effect tied to a steady condition** (e.g. “whenever `open` is false”). While the condition stays true, the effect keeps running and rewriting state:
```svelte
// ❌ don't — resets on every run while closed; can loop with bound children
$effect(() => {
if (open) return;
step = STEPS.STEP_1;
fields = { fullname: '', email: '' };
errors = { fullname: '', email: '' };
});
```
**Reset on transitions** — when a dialog closes, a route changes, or the user submits — not on every render where a flag is false:
```svelte
// ✅ do — reset only when the dialog actually closes
function resetForm() {
step = STEPS.STEP_1;
fields.fullname = '';
fields.email = '';
errors.fullname = '';
errors.email = '';
}
function handleOpenChange(isOpen: boolean) {
open = isOpen;
if (!isOpen) {
resetForm();
}
}
```
```svelte
<Dialog.Root {open} onOpenChange={handleOpenChange}>
```
**Prefer `bind:` over `$effect` for keeping two sources in sync.** If a control can bind directly to a store or `$state` field, use that instead of reading one value in an effect and writing another (see **Svelte store binding** above).
**When resetting bound form state, mutate properties in place** instead of replacing the whole object. Reassigning `fields = { ... }` breaks `bind:value={fields.fullname}` and can trigger extra updates downstream:
```svelte
// ❌ don't
fields = { fullname: '', email: '' };
// ✅ do
fields.fullname = '';
fields.email = '';
```
**Other common pitfalls:**
- **Effect writes what it reads** — e.g. `$effect(() => { count = count + 1 })` loops forever.
- **Effect mirrors props into local state** — use `$bindable`, `bind:`, or derive a value; only copy props when you need a draft the user can cancel.
- **Effect fetches or navigates on every dependency tick** — gate with a guard, run on submit/route enter, or track “already loaded” so work runs once per intent.
When cleanup or reset must follow a specific lifecycle moment, use the matching hook: `onOpenChange` for dialogs, submit/success handlers for forms, `onMount` / load functions for one-time setup that does not depend on the current org. Org-scoped page data is covered below.
### Loading org-scoped page data
The app shell renders the page before `currentOrg` is set. A fetch in `onMount` that returns when the org id is missing does nothing on a full reload, then succeeds on the next client navigation, because the org is already in the store.
Load that data in an `$effect` that reads `$currentOrg.id` and returns until the id is set. Remember which org you already loaded only after the id is present, so the effect runs again when the id arrives. If the fetch bails on a missing org id, do not mark that load as done first — the next run has to be allowed to try again.
`onMount` stays right for one-time setup that does not need the org, such as reading `localStorage` or attaching a listener.
```svelte
let listedOrgId = '';
$effect(() => {
const organizationId = $currentOrg.id;
if (!organizationId || organizationId === listedOrgId) return;
listedOrgId = organizationId;
void courseTemplateApi.list();
});
```
### Skeleton cards
A card row or gallery shows skeleton cards until the first payload arrives. The gap before the request starts is still loading: `cards === null` while the loading flag is still false is an empty row, not a finished one.
Show skeletons while there are no cards yet. Keep the cards you already have during a later refetch. Use the card's `loading` prop, or `Skeleton` blocks with the same footprint, so the row does not jump when the data arrives. A small fixed count is enough — the template row uses three, the gallery four.
```svelte
{#if !courseTemplateApi.cards}
{#each [0, 1, 2] as index (index)}
<TemplateCard loading />
{/each}
{:else}
{#each templates as template (template.id)}
<TemplateCard title={template.title} />
{/each}
{/if}
```
### Server-Side API Calls
Use `.server.ts` files for server-side code to isolate API keys.
### Chart Imports and SSR
`layerchart` has internal circular dependencies that Vite cannot reliably evaluate during SSR. Never statically import `layerchart` or the `@cio/ui/base/chart` runtime barrel from a component that can enter the server module graph.
- Import chart types from the dependency-free types module: `import type { ChartConfig } from '@cio/ui/base/chart/types'` (or the equivalent relative path inside `packages/ui`).
- Load chart runtime components with a browser-only dynamic import and render them inside an `{#await}` block guarded by `browser` or `typeof window !== 'undefined'`.
- In exercise submission renderers, reuse `submission-response-bar-chart.svelte` or `submission-response-pie-chart.svelte`; do not import the chart barrel directly.
```svelte
<script module lang="ts">
function loadChart() {
if (typeof window === 'undefined') return Promise.reject(new Error('browser-only'));
return import('@cio/ui/base/chart');
}
</script>
<script lang="ts">
import { browser } from '$app/environment';
import type { ChartConfig } from '@cio/ui/base/chart/types';
</script>
{#if browser}
{#await loadChart() then Chart}
<Chart.ChartContainer {config}>
<Chart.BarChart data={chartData} x="label" y="value" />
</Chart.ChartContainer>
{/await}
{/if}
```
### UI Components
- Add new UI components under `packages/ui/src` following existing folder patterns.
- **All Tailwind utility classes in `packages/ui/src/**` must use the `ui:` prefix** (see `packages/ui/README.md`). Pre-commit and CI run `pnpm --filter @cio/ui prefix:check` on touched UI files; fix with `pnpm --filter @cio/ui prefix`.
- Document component usage and props in `packages/ui/README.md`.
- **Every new component in `packages/ui/src` ships with a Storybook story in the same change.** A component without a story is incomplete — do not open the PR without one.
- Path: `packages/storybook/src/{atoms|molecules}/{component-name}/{component-name}.stories.svelte`, plus a `fields.ts` exporting the `FIELDS` array used by `parameters.controls.include`.
- Use `defineMeta` from `@storybook/addon-svelte-csf` with `tags: ['autodocs']`, and set `argTypes` callbacks to `{ control: false }`.
- Cover every state the component can be in, not just the happy path — each variant, each boolean prop, loading and empty states, and any recursive or nested rendering.
- When adding a component to an existing family (e.g. a new `comment-tree-*` part), extend that family's existing story file rather than creating a second one.
- `ui:` classes used inside a story must also exist somewhere under `packages/ui/src`, otherwise they emit no CSS — use unprefixed layout classes in story wrappers when in doubt.
- See `packages/ui/README.md` and `packages/storybook/README.md` for full guidance.
- For dashboard forms, prefer `@cio/ui/custom/*-field` wrappers (for example `InputField`, `TextareaField`, `CheckboxField`) so label/error/spacing behavior stays consistent.
- Use base primitives (`@cio/ui/base/input`, `@cio/ui/base/textarea`, `@cio/ui/base/checkbox`, `@cio/ui/base/label`) only when creating/updating reusable UI components or when no custom field wrapper exists.
- In app-level form UIs, do not introduce native form controls (`<input>`, `<textarea>`, `<label>`) when equivalent `packages/ui` components exist.
- **Icon-only buttons** (a `Button` whose content is just an icon, e.g. `size="icon"`) must use `variant="secondary"`.
- **`ui:` prefix rules in consumer code (`apps/dashboard/**` & `packages/storybook/**`):**
- **Theme tokens and semantic colors:** Prefix with `ui:` for classes binding to `@cio/ui` theme variables (e.g. `ui:bg-background`, `ui:text-muted-foreground`, `ui:border-input`, `ui:ring-ring`, `ui:text-primary`, `ui:bg-muted`, `ui:border-border`).
- **Named z-index scale:** Prefix with `ui:` for custom stacking layers defined in `@cio/ui` (`ui:z-app-bar`, `ui:z-app-bar-elevated`, `ui:z-modal`, `ui:z-menu-elevated`).
- **Custom animations and font tokens:** Prefix with `ui:` for custom animations and font utilities in `@cio/ui` (e.g. `ui:font-cio`, `ui:animate-meteor`, `ui:animate-shine`).
- **Standard Tailwind utilities:** Do not prefix layout, sizing, typography, spacing, borders, standard animations, or fonts (e.g. `flex`, `grid`, `w-full`, `min-h-*`, `p-*`, `m-*`, `gap-*`, `rounded-*`, `border`, `text-sm`, `font-semibold`, `animate-spin`, `focus-visible:outline-none`, `focus-visible:ring-2`).
- **Inside `packages/ui/src/**` only:** ALL Tailwind utility classes must use the `ui:` prefix (enforced by `pnpm --filter @cio/ui prefix:check`).
- **Tailwind `ui:` variant prefix ordering:** When applying variants (`hover:`, `focus:`, `dark:`, `placeholder:`, `md:`) to `ui:` prefixed classes, `ui:` must come before the variant modifier (e.g. `ui:hover:text-primary`, `ui:focus-visible:ring-ring`, `ui:placeholder:text-muted-foreground`, `ui:dark:text-white`).
### Dialog button hierarchy
When a dialog has multiple actions in `Dialog.Footer`, use `size="sm"` on every button and follow this variant hierarchy (left to right):
- **Left / external action** (e.g. "Create another …") — `variant="secondary"`
- **Cancel / dismiss** (e.g. "Later", "Cancel") — `variant="outline"`
- **Main CTA** (e.g. "Open now", "Save", "Create") — default primary (`variant="default"`)
Place the main CTA on the right; group cancel and primary together when both appear on the right.
### Page layout and settings save bar
Use `@cio/ui/base/page` for dashboard page shells. See `packages/ui/README.md` § Page layout for the full component list.
**Every settings page with save/discard** must use `Page.SettingsActions` as the last child of `Page.Root` (after `Page.Body`). Do not put Save in `Page.Header` or inline at the bottom of form sections.
- Compact centered card (not full width), sticky at the viewport bottom while scrolling; docks naturally at the end of the page content
- Only appears when `hasChanges` is true
- Save is a primary button (not a split/combo control); Discard is secondary
- Labels come from dashboard translations: `common.unsaved_changes.label`, `common.discard`, `common.save_changes`
- Pair with `UnsavedChanges` for navigation guards where appropriate
```svelte
<Page.SettingsActions
hasChanges={hasUnsavedChanges}
loading={isSaving}
statusLabel={$t('common.unsaved_changes.label')}
discardLabel={$t('common.discard')}
saveLabel={$t('common.save_changes')}
onSave={handleSave}
onDiscard={handleDiscard}
/>
```
### Scroll to top
Any surface whose **main column can overflow the viewport** must use the shared `ScrollToTop` control. Do not invent a one-off back-to-top button, FAB, or “scroll up” link.
Full behavior, threshold, clearance, and mount points: `prd/scroll-to-top/README.md`.
**Rules:**
- Implement and import from `@cio/ui/custom/scroll-to-top` (see that spec if the file is not there yet — add the shared component, do not inline a button in the page).
- Hidden until the scroll container overflows **and** the user has scrolled at least **one viewport**. Hidden again below 0.8 viewports. Never visible at the top of the page.
- Click only scrolls that container to `top: 0`. Prefer `behavior: 'smooth'`; use instant when `prefers-reduced-motion` is set.
- Fixed to the **viewport**, bottom-right, icon-only `Button`/`IconButton` with `variant="secondary"`.
- Pass `label` from the host (`$t('common.scroll_to_top')` in the dashboard). The UI package does not own copy.
- Prefer **one mount per shell**, not per leaf page:
- Public lessons and exercises → `PublicCourse.PublicCourseShell` (`clearance="mobile-nav"`).
- Authenticated course contents list, lesson, and exercise → `routes/(app)/courses/[id]/+layout.svelte` (Ask AI clearance on lesson/exercise routes).
- New long-read pages (community thread, public course landing, long catalogs, etc.) must include the same component. Do not mount it on `(app)/+layout.svelte` or inside dialogs/sheets/nested panes.
```svelte
<script lang="ts">
import { ScrollToTop } from '@cio/ui/custom/scroll-to-top';
import { t } from '$lib/utils/functions/translations';
</script>
<ScrollToTop label={$t('common.scroll_to_top')} />
```
### Landing-page href sanitization
All user-controlled URLs in org landing pages (nav links, hero CTAs, footer links, callout buttons, links-section cards) must pass through `safeHref()` before reaching an `<a href>` attribute. This prevents `javascript:`, `data:`, and `vbscript:` XSS payloads from being rendered.
**Shared source of truth:** `packages/utils/src/validation/shared/safe-href.ts` exports two functions:
- `isAllowedHref(value)` — allowlist check: `http`, `https`, `mailto`, `tel`, `#`, `/`, `./`, `../`, plain paths (`courses/intro`).
- `containsDisallowedHrefs(value)` — recursive tree walker for Zod refinements on freeform JSON blobs.
**Three consumers, one rule:**
- **Render-time:** `packages/ui/src/custom/org-landing-page/safe-href.ts` re-exports `isAllowedHref` as `safeHref(value, fallback)` — wrap every user-controlled href. Wired into all 11 themes via shared components (`LandingButton`, `SecondaryActionButton`) and direct `<a>` bindings.
- **Dashboard normalization:** `normalizeHref()` in `apps/dashboard/src/lib/features/org/utils/landing-page.ts` imports `isAllowedHref` to strip bad schemes when loading/saving config.
- **API boundary:** Zod refinement on `ZUpdateOrganization.landingpage` imports `containsDisallowedHrefs` to reject bad schemes at write time.
**When adding new landing-page components or themes:** always route hrefs through `safeHref()`. Never pass a user-controlled string directly to `href`.
Theme previews in the dashboard are visual-only. Render preview content inside an `inert` container so links, buttons, and other controls cannot navigate or trigger actions while previewing a theme.
## Content blocks
Lesson content is a list of blocks (note, video, slides, document, image). **Read [`packages/utils/src/validation/block/ADDING_A_BLOCK_TYPE.md`](packages/utils/src/validation/block/ADDING_A_BLOCK_TYPE.md) before adding or changing a block type.**
- Each type is one contract file in `packages/utils/src/validation/block/definitions/` and one folder in `apps/dashboard/src/lib/features/block/components/`, registered in `BLOCK_DEFINITIONS`, `BLOCK_VIEWS` and `BLOCK_EDITORS`.
- Never branch on a block type outside those files. Server code reads capabilities (`localizedContent`, `media`, `toPublicData`) from the registry, and dashboard containers look the component up by type.
- View files must not import editor code: the public lesson page renders them.
## Emails: system vs org-branded
Every transactional email in `packages/email/src/emails` is one of two kinds — decide deliberately, because it changes the branding, the schema, and the `from` address.
- **System emails** — ClassroomIO (the platform) addressing a user/account owner/admin about their *ClassroomIO account*: auth (verify/reset password), billing, **plan limits & usage**, license, workspace. These are **ClassroomIO-branded**:
- No `branding` field in the schema.
- Render with `getDefaultTemplate(content)` (no branding arg) — falls back to the ClassroomIO logo + default button color.
- Send from the default `EMAIL_FROM`; do **not** override `from` with the org name.
- Examples: `welcome`, `forgot-password`, `on-password-reset`, `studentLimitReached`.
- **Org-branded emails** — an *organization* addressing its own members (students/tutors): course invites, welcome/completion, newsfeed, cohort, session reminders. These carry the org's identity:
- Add a `branding: ZEmailBranding` field.
- Render with `getDefaultTemplate(content, fields.branding)`.
- Callers build branding via `buildEmailBranding({ name, avatarUrl, theme })` and send with `from: buildEmailFromName('<Org> (via ClassroomIO.com)')`.
- Examples: `teacherStudentJoined`, `studentCourseWelcome`, `studentCourseInvite`, `verifyEmail` (org-scoped signup).
Rule of thumb: if the recipient is being addressed **as a ClassroomIO customer** (account/billing/limits), it's a system email → ClassroomIO branding. If they're addressed **as a member of a specific org**, it's org-branded.
### Email link URLs
Links embedded in transactional emails must use the correct dashboard host. Templates only receive pre-built URLs — callers build them in API services before `enqueueTransactionalEmail`. See `packages/email/README.md` § Email link URLs.
- **Teacher/tutor/admin dashboard actions** (course management, grading, team invites, auto-enroll): build URLs with `getAppBaseUrl()` from `@cio/core/config/dashboard-url`. This resolves to the admin app (`app.classroomio.com` in cloud, or `DASHBOARD_ORIGIN` / localhost in dev). Do **not** pass org `customDomain` or tenant `siteName` — staff sign in on the admin app, not the org public site.
- **Student/learner-facing links** (course enroll, org audience invites, login): build URLs with `getDashboardBaseUrl(org)` so links land on the org's public site (verified custom domain, tenant subdomain, or platform fallback).
## Certificate Templates & Dynamic Text Fitting (`packages/certificates`)
Certificate templates in `packages/certificates/src/templates/` render fixed-dimension certificate layouts for preview and PDF export. Dynamic user-generated fields (such as `orgName`, `courseName`, `recipientName`, `subtitle`, `description`, `certificateId`, and `date`) vary in length and must dynamically fit within their assigned layout boundaries without text clipping or layout overflow.
### Rules for Certificate Template Edits:
1. **Compute font sizes dynamically from font metrics — never hardcode fixed font sizes for dynamic text**:
- All user-generated content must have its font size computed via `prepareCertificateRenderContext(design, data, FIELDS)` and the font metrics engine (`font-metrics.ts`).
- Define a `FIELDS` constant object declaring `maxWidth`, `maxHeight`, `fontFamily`, `basePx`, `lineHeight`, `allowWrap`, `letterSpacingEm`, and `textTransform` for each field.
- Inject the computed font size via inline style in the HTML markup (e.g. `style="font-size: ${fontSizes.recipient}px;"`).
- Wrap all interpolated user strings with `escapeHtml(...)`.
2. **Define dedicated constants/variables for layout factors (e.g. paddings, gaps, borders, sibling columns, decorations) used in layout calculations**:
- Extract layout values into named constants when calculations or multiple dependent declarations use them (e.g. element paddings, flex/grid gaps, border widths, sibling column widths, or flanking decorations that reduce `FIELDS.maxWidth` or `FIELDS.maxHeight`, or values reused across CSS and FIELDS).
- Standalone, non-computed dimension literals (like fixed container limits) do not need redundant variable wrappers.
```ts
const recipientPaddingBottom = 14;
const recipientRowPaddingBottom = 14;
const recipientNumColumnWidth = 120;
const recipientRowGap = 30;
const borderLeftWidth = 12;
const certIdPaddingHorizontal = 10;
const certIdPaddingVertical = 6;
const subtitleDecorationLineWidth = 50;
const subtitleDecorationGap = 14;
const subtitleTotalDecorationWidth = (subtitleDecorationLineWidth + subtitleDecorationGap) * 2;
```
3. **`FIELDS` must subtract layout factors (e.g. paddings, gaps, borders, sibling elements) before computing font size**:
- The sizing engine uses `maxWidth` and `maxHeight` in `FIELDS` to measure available space for text glyphs.
- If an element has layout factors (e.g. CSS padding, flanking decorations, border widths, or shares a row with sibling columns/gaps), **subtract those dimensions from `FIELDS.maxWidth` or `FIELDS.maxHeight`** so the font size calculation accounts for the exact available text area:
```ts
const FIELDS = {
certId: {
maxWidth: 280 - certIdPaddingHorizontal * 2,
maxHeight: 60 - certIdPaddingVertical * 2,
fontFamily: FONTS.mono,
basePx: 11,
allowWrap: false,
textTransform: 'uppercase' as const
},
subtitle: {
maxWidth: 800 - subtitleTotalDecorationWidth,
maxHeight: 34,
fontFamily: FONTS.heading,
basePx: 13,
allowWrap: false,
letterSpacingEm: 0.35,
textTransform: 'uppercase' as const
},
recipient: {
maxWidth: 900 - borderLeftWidth - recipientNumColumnWidth - recipientRowGap,
maxHeight: 86 - recipientPaddingBottom,
fontFamily: FONTS.display,
basePx: 54,
lineHeight: 1.05,
allowWrap: true
}
} as const;
```
4. **CSS styles must use the dedicated layout variables**:
- Reference the same dedicated layout constants (e.g. paddings, gaps, column widths, border dimensions) in the template's CSS styles.
- Container max dimensions in CSS should reflect the total outer boundary where needed (e.g., inner width/height from `FIELDS` plus the padding):
```css
.t-minimal .recipient-row {
display: grid;
grid-template-columns: ${recipientNumColumnWidth}px 1fr;
gap: ${recipientRowGap}px;
padding-bottom: ${recipientRowPaddingBottom}px;
}
.t-classique .recipient {
padding-bottom: ${recipientPaddingBottom}px;
max-width: ${FIELDS.recipient.maxWidth}px;
max-height: ${FIELDS.recipient.maxHeight + recipientPaddingBottom}px;
overflow-wrap: break-word;
word-break: normal;
}
```
5. **Keep `FIELDS` typography configuration and CSS declarations strictly synchronized**:
- If CSS applies `letter-spacing`, `text-transform: uppercase`, or a specific `line-height`, the same values must be declared in `FIELDS` (`letterSpacingEm`, `textTransform: 'uppercase'`, `lineHeight`) so the advance-width and line-wrap calculations match the browser rendering.
## Best Practices Summary
### ✅ DO
- Use `AppError` with error codes and status codes
- Handle transactions in services
- Use `handleError` utility in route catch blocks
- Validate with Zod on both client and server
- Define request types in `{domain}/utils/types.ts` for reusability
- Use `this.execute<RequestType>()` in API classes (never call RPC directly)
- Keep components thin - only render UI and call API methods
- Use `.server.ts` for server-side API calls with API keys
- Use non-null assertion (`!`) for `user` when `authMiddleware` is present
- Put all user-facing copy in translations and reference by key
- Use `SvelteSet`/`SvelteMap` from `svelte/reactivity` for reactive collections (not `new Set`/`new Map`)
- Reset modal/form state in close/submit handlers or `onOpenChange`, not in `$effect` tied to a steady “closed” condition
- Mutate bound `$state` object fields in place when clearing forms (don't reassign the whole object)
- Load org-scoped page data in an `$effect` that waits for `$currentOrg.id` (see **Loading org-scoped page data**)
- Show skeleton cards until the first card payload arrives (see **Skeleton cards**)
- Use `ScrollToTop` (`@cio/ui/custom/scroll-to-top`) on any page whose main column can overflow the viewport (see **Scroll to top**)
- Add `testId` on `@cio/ui` wrappers or shell surfaces when Playwright needs a stable hook (see **E2E test hooks**)
- In certificate templates, compute font sizes for dynamic text using `FIELDS` and `prepareCertificateRenderContext` (`packages/certificates`)
- In certificate templates, extract layout factors (e.g. paddings, gaps, borders, sibling columns, decorations) into dedicated constants, subtract them in `FIELDS` before computing font sizes, and reference those constants in CSS
### ❌ DON'T
- Put business logic in routes or queries
- Include validation in queries (belongs in routes)
- Handle HTTP concerns in services
- Skip client-side validation
- Let components handle validation, navigation, or snackbar
- Include API key logic in `.svelte.ts` files
- **Define types or interfaces in `.svelte.ts` files** — use `{domain}/utils/types.ts` and infer from API types
- Call RPC client directly in server files (use `.server.ts` classes)
- Add unnecessary null checks for `user` when `authMiddleware` is present
- Write plain English strings in components
- Use `new Set()`/`new Map()` for mutable reactive state — use `SvelteSet`/`SvelteMap` instead
- Wrap `SvelteSet`/`SvelteMap` in `$state()` — they are already reactive
- **Use `$effect` to reset form/modal state whenever a boolean is false** — use `onOpenChange` or explicit handlers on close instead
- **Fetch org-scoped page data in `onMount`** — the org id is often still empty, so a full reload no-ops and the next client navigation is what finally loads (see **Loading org-scoped page data**)
- **Leave a card row blank on first load** — show skeleton cards until cards exist, including the gap before the request starts (see **Skeleton cards**)
- **Reassign whole bound state objects to clear forms** (e.g. `fields = {}`) — mutate properties in place
- **Use inline type imports** (e.g. `import('Package').Type` in type positions) — use top-level `import type` instead
- Build a one-off back-to-top button — use `ScrollToTop` (see **Scroll to top** and `prd/scroll-to-top/README.md`)
- Hardcode font sizes in CSS for user-generated certificate fields
- Use magic padding numbers in certificate templates without subtracting them from `FIELDS` bounding boxes
## E2E test hooks
Playwright specs and PR demos should use stable, locale-independent selectors. Full registry: `e2e/README.md`.
**Locator priority:** `getByRole` / `getByLabel` first, then legacy `#id`, then `getByTestId`. Never select by CSS class, Tailwind utility, or translated copy in CI.
**Infrastructure ids** ship in the app shell (login, org sidebar, header, settings save bar). Org nav ids are derived by `orgNavTestId()` — e.g. `/courses` → `org-nav-courses`.
**Call-site ids:** pass `testId` on `@cio/ui` field wrappers and `Button` when a feature needs a hook. Naming: kebab-case, feature-scoped — `{area}-{element}` or `{area}-{element}-{action}`.
```svelte
<InputField testId="course-settings-title" label={...} bind:value={...} />
<Button testId="course-create-submit">Create</Button>
```
Do not annotate every control — add hooks only for high-impact flows (auth, nav, save bars, primary actions).
## Checklist for New Routes
For public API design, security, contracts, MCP/API-key behavior, and dashboard parity, read [`skills/public-api-review/SKILL.md`](skills/public-api-review/SKILL.md) before implementation and review.
- [ ] **Validation**: Schema in `packages/utils/src/validation/{entity}/`
- [ ] **Query**: Pure functions in `packages/db/src/queries/{domain}/`
- [ ] **Service**: Business logic in `apps/api/src/services/`
- [ ] **Route**: Handler in `apps/api/src/routes/{domain}/` (each route returns a single type)
- [ ] **Registration**: Export and register in `app.ts`
- [ ] **Build**: Run `pnpm --filter @cio/api build`
- [ ] **Frontend Types**: Request types in `apps/dashboard/src/lib/features/{domain}/utils/types.ts` (infer from API; do not import from `rpc-types`)
- [ ] **Frontend API**: API class in `apps/dashboard/src/lib/features/{domain}/api/`
## Common Patterns
### Query error logging (`packages/db/src/queries`)
In every `catch` block, log the original error with the function name so logs are traceable:
```typescript
} catch (error) {
console.error('functionName error:', error);
throw new Error(`Failed to ...`);
}
```
### Transactions
When a service operation performs multiple related writes, establish one transaction at the service orchestration boundary and keep every database operation in that transaction. Do not commit a primary row update before a related write such as tags, memberships, or assignments can fail.
- Routes must delegate transaction ownership to services; routes should not perform direct database transactions.
- Query helpers that participate in composed operations must accept an optional `DbOrTxClient` and pass it to every read and write in the operation.
- A query helper may open its own transaction only when no client is supplied. When a transaction client is supplied, use it directly and never open a nested transaction.
- Validate all related identifiers and authorization requirements within the same transaction boundary before committing any writes.
- Add a database-backed integration test for rollback-sensitive flows where possible. At minimum, test that a failure in a later operation cannot leave an earlier operation persisted.
- Defer cache invalidation, notifications, and other non-transactional side effects until after the transaction commits.
```typescript
const result = await db.transaction(async (tx) => {
const org = await createOrganization({ name, siteName }, tx);
const member = await createOrganizationMember({ orgId: org.id, userId }, tx);
return { org, member };
});
```
### Pagination
```typescript
const paginationSchema = z.object({
page: z.string().transform(Number).pipe(z.number().min(1)).default('1'),
limit: z.string().transform(Number).pipe(z.number().min(1).max(100)).default('20')
});
return c.json({
success: true,
data: items,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
});
```
## Refactoring Components to Features/UI
When moving from `apps/dashboard/src/lib/components/` to `apps/dashboard/src/lib/features/ui/`:
1. **Single-file**: `components/CodeSnippet/index.svelte` → `features/ui/code-snippet.svelte`
2. **Multi-file**: `components/Confetti/` → `features/ui/confetti/confetti.svelte`
3. **Update exports** in `features/ui/index.ts`
4. **Update imports**: `import { Component } from '$features/ui';`
5. **Delete old files** and verify no remaining references
## Cursor Cloud specific instructions
The full setup/run flow lives in `README.md` and `DEV_SETUP_NOTES.md`. The notes below cover only the non-obvious, cloud-VM-specific caveats. The update script already runs `pnpm install` on startup.
### Node version (important)
The sandbox injects `/exec-daemon/node` (Node 22) at the front of `PATH`, so a bare `node`/`pnpm` runs under Node 22 even though the repo requires Node `^24.22.1` (`.nvmrc`). Node 24.22.1 is installed via nvm and set as the nvm default. Before running any `pnpm`/dev command, prepend the repo Node to `PATH`:
```bash
export PATH="$HOME/.nvm/versions/node/v24.22.1/bin:$PATH"
```
`pnpm install` itself succeeds under either version (there is no `engine-strict`), but run the dev servers and DB scripts on Node 24.
### Services & how to run them
Docker is installed and provides Postgres 16 + Redis 7 (the app does not run them natively). After a fresh VM boot the Docker daemon is usually not running — start it (it needs the snapshot's `/etc/docker/daemon.json`, which already sets `fuse-overlayfs` + disables the containerd snapshotter, plus `iptables-legacy`):
```bash
sudo dockerd > /tmp/dockerd.log 2>&1 &
sudo chmod 666 /var/run/docker.sock # or prefix docker commands with sudo
docker compose -f docker/docker-compose.yaml up -d postgres redis
```
The per-app `.env` files (`apps/api/.env`, `apps/dashboard/.env`, `apps/jobs/.env`, `packages/db/.env`) are gitignored but persist in the VM snapshot with a matching `PRIVATE_SERVER_KEY`. If any are missing, recreate them per `README.md` (generate secrets with `openssl rand -hex 32`; the two `PRIVATE_SERVER_KEY` values must match).
The Postgres docker volume persists in the snapshot already seeded with demo data (login `admin@test.com` / `123456`). Re-seed only if needed: `pnpm --filter @cio/db db:setup:seed`.
Run the app (two long-lived processes; use tmux):
- `pnpm api:dev` → API on `http://localhost:3002` + 5 BullMQ workers.
- `pnpm dashboard:dev` → dashboard on `http://localhost:5173` + the `@cio/ui` Tailwind CSS watcher.
Do not use `pnpm dev` (it trips turbo's concurrency cap). Build shared package `dist/` once with `pnpm build` if you see `Failed to resolve entry for package "@cio/*"`.
### Known caveats
- **Vite SSR circular dependency (layerchart):** authenticated/chart pages occasionally render "Something unexpected occurred" on a cold load. Reload the page (or restart `dashboard:dev`) and it renders — it is intermittent, not a setup failure.
- **Object storage is optional and not started by default.** Without it, image/media thumbnails show "Failed to load"; that is expected. Start the bundled SeaweedFS with `docker compose -f docker-compose.yaml --profile storage up -d storage storage-init` and add the `OBJECT_STORAGE_*` vars from `README.md` to `apps/api/.env`.
- **Pre-existing lint/test issues (not environment problems):** `pnpm --filter @cio/api lint` fails (missing ESLint v9 `eslint.config.*`); `pnpm --filter @cio/dashboard lint` runs but reports pre-existing errors; api `vitest run` passes 61 tests but 5 files fail to load `@cio/core/services/*/*` subpaths (Vite nested-wildcard exports quirk; Node resolves them fine); `pnpm --filter @cio/dashboard test` (jest) fails to parse `jest.config.ts`. The pre-commit gate `pnpm format:check` passes.
- The optional `@cio/storybook` build fails on an unresolved `@lucide/svelte/icons/bot` import; it does not affect api/dashboard.
### Deployment mode: self-hosted vs cloud (affects which features you can test)
A single flag, `PUBLIC_IS_SELFHOSTED`, switches the whole product between **self-hosted** and **cloud** behavior. It is read in two places: the dashboard via `$env/static/public` (e.g. `apps/dashboard/src/lib/features/app/layout-setup.ts`) and the API/db layer via `@cio/core/config/env` (e.g. `apps/api/src/services/license.ts`, `apps/api/src/middlewares/license.ts`, `apps/api/src/services/onboarding.ts`). `FEATURE_AUDIT.md` §5 is the source-of-truth feature-by-feature map.
**This VM's local env is configured for CLOUD mode** (`PUBLIC_IS_SELFHOSTED=false` in both `apps/dashboard/.env` and `apps/api/.env`) so multi-tenant and all license-gated features are testable. The README contributor default is self-hosted; to switch this VM back, set `PUBLIC_IS_SELFHOSTED=true` in both files (or remove it from `apps/api/.env`) and restart both dev servers.
- **Self-hosted (`PUBLIC_IS_SELFHOSTED=true`)** — the README contributor default:
- Single organization, single domain: creating a 2nd org is blocked (`onboarding.ts`), the "add org" UI is hidden, and the org is auto-assigned a `selfhosted` `ENTERPRISE` plan.
- Enterprise features `sso`, `token-auth`, `no-tracking` are gated behind `LICENSE_KEY`, verified against the external `https://enterprise-api.classroomio.dev`. Without a key, `requireLicense` returns 403.
- Polar billing and PostHog/Umami analytics are off.
- **Cloud (`PUBLIC_IS_SELFHOSTED` unset or `false`)**:
- Multi-tenant: multiple orgs per user, "add org" shown; org is resolved from the **subdomain** (`acme.<PRIVATE_APP_HOST>`) or a custom domain. Locally there are no subdomains, so an org public site is simulated with the `?org=<siteName>` query param (sets the `_orgSiteName` cookie — see `layout-setup.ts`).
- The license gate is a **no-op**, so all enterprise features are unlocked **without** a `LICENSE_KEY` — i.e. cloud mode is the easiest way to exercise SSO/token-auth/multi-org in dev.
- Polar billing and analytics activate but are themselves gated by their own keys (absent keys just no-op), so they don't block startup.
**Org membership invariant — admins/tutors vs students.** Self-hosted is single-org/single-domain, so one person must never hold a STUDENT membership in one org and an ADMIN/TUTOR membership in another. Concretely: the self-hosted auto-enroll path (`getAccountData` in `apps/api/src/services/account/profile.ts`) skips enrolling a user as STUDENT if `getUserOrgRolesMap` shows they are ADMIN/TUTOR anywhere. Cloud does **not** enforce this: multi-domain tenancy means the same person can legitimately be an admin of their own org and a student in someone else's, so the explicit signup/Join Academy service (`joinOrganization` in `apps/api/src/services/organization/join.ts`) intentionally has no such guard. When changing enrollment logic, keep this asymmetry deliberate.
Non-obvious gotcha: the README local-dev setup only sets `PUBLIC_IS_SELFHOSTED=true` in `apps/dashboard/.env`; `apps/api/.env` leaves it unset, so the **API behaves as cloud** (license no-op, 2nd org allowed) while the **dashboard UI is self-hosted**. To exercise a coherent mode, set the flag the same way in both files. The flag is read at process start, so restart the dev servers after editing it.
### Seeded tenants (good for multi-tenant testing)
The seed (`packages/db/src/utils/seed/organizationmember.ts`) creates **three independent organizations**, each with its own admin + student. All accounts use password `123456`:
| Org (siteName) | Admin | Student | Example courses |
|---|---|---|---|
| Udemy Test (`udemy-test`) | `admin@test.com` | `student@test.com` | Modern Web Development with React; Getting started with MVC; Data Science with Python and Pandas |
| Coursera Test (`coursera-test`) | `enterprise@test.com` | `enterprise-student@test.com` | SOC 2 Security Basics; HIPAA Awareness 2026 |
| Skillshare Test (`skillshare-test`) | `early-adopter@test.com` | `early-adopter-student@test.com` | Product Management Fundamentals |
In cloud mode you can demonstrate tenant isolation on the single local instance by visiting each org's public catalog via the `?org=<siteName>` param, e.g. `http://localhost:5173/?org=coursera-test` vs `?org=udemy-test` vs `?org=skillshare-test` — each renders its own branded catalog and courses. (Note: a user enrolled in courses across orgs becomes a member of multiple tenants, so after login the dashboard may open whichever org that user most recently used.)