# Agent Guidance This document collects implementation rules and workflow conventions for code changes. ## 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`). ## 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 ` 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 20 (the repo's `.nvmrc` version): ```bash export PATH="$HOME/.nvm/versions/node/v20.19.3/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 ``` Also run `pnpm format:check` (see Translation, Formatting, and Git Workflow above). Do not commit if any verification step fails. ## 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 = `
Replying to @${target.fullname}"${target.snippet}"
`; 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; ``` ### 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) { // …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) { 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({ 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, { 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` 3. Extract success response: `Extract` 4. Extract data type: `SuccessType['data']` For response shapes that need a small normalization (e.g. API returns `JSONValue`, app needs `Record`), derive a normalized type in `types.ts` from the success `data` type (e.g. `Omit & { roleMapping: Record }`). **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

{$t('course.navItem.lessons.heading_v2')}

``` 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 ``` ### 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 {$t('settings.section.heading')} {$t('settings.section.toggle')} {$t('settings.section.title')} {$t('settings.section.description')}