ο»Ώ# AGENTS.md β Pram Project Guide
This file is for AI agents. Read this first before making any changes.
1. Project Identity
- Name:
pramβ AI-powered multi-language test practice platform. - Repo type: Turborepo monorepo (Bun workspaces).
- Workspace packages:
apps/*,packages/*. - Runtime & package manager: Bun (
packageManager: "bun@1.3.11").- Do NOT use
pnpm,npm, oryarnunless explicitly required by a global tool.
- Do NOT use
2. Technology Stack
| Layer | Technology | Notes |
|---|---|---|
| Frontend | React 19 + Vite + TanStack Router | File-based routing. Not Next.js. |
| Backend | Hono + tRPC | Entry: apps/server/src/index.ts. Port 3000. |
| DB Engine | PostgreSQL | Managed via Drizzle. |
| DB ORM | Drizzle ORM | Not Prisma. |
| Auth | Better-Auth | Email/password + OAuth. |
| UI | shadcn/ui (shared) | Lives in packages/ui. Imported as @pram/ui/components/*. |
| AI | OpenAI-compatible API | User-managed API keys via Settings UI. |
| Build | Turborepo + tsdown |
Server compiled with tsdown. |
3. Architecture Overview
pram/
βββ apps/
β βββ web/ # Frontend (Vite, TanStack Router) β Port 5173
β βββ server/ # Backend (Hono, tRPC) β Port 3000
βββ packages/
β βββ ui/ # Shared shadcn/ui components & styles
β βββ api/ # tRPC routers & business logic
β βββ auth/ # Better-Auth configuration
β βββ db/ # Drizzle schema, queries, migrations
β βββ ai/ # AI generation: prompts, schemas, agentic pipeline
β βββ env/ # Shared environment validation
β βββ config/ # Shared TypeScript configs
- Internal imports use the
@pram/*workspace namespace. - Do NOT create cross-imports between
apps/*directly; route throughpackages/*.
4. Essential Commands
Use these exact commands. Do not substitute with pnpm/npm/npx equivalents.
# Install dependencies
bun install
# Development
bun run dev # Start all apps (web + server)
bun run dev:web # Start web app only
bun run dev:server # Start server only
# Type checking
bun run check-types # Check all packages
# Database (Drizzle)
bun run db:push # Push schema changes to PostgreSQL
bun run db:studio # Open Drizzle Studio UI
bun run db:migrate # Run migrations
bun run db:generate # Generate migration files
bun run db:seed # Seed reference data (exam types, etc.)
bun run db:start # Start local DB (if configured)
bun run db:stop # Stop local DB
# Testing
bun test # Run all tests
bun run turbo test # Run via turbo pipeline
# Build
bun run build # Build all packages
5. Common Pitfalls β DO NOT
- β Do not use
pnpm/npm/yarnfor package management or running scripts. - β Do not assume Docker or Docker Compose exists in this repo. There are no Dockerfiles or compose files.
- β Do not assume Next.js. This project uses Vite + TanStack Router, not Next.js App Router or Pages Router.
- β Do not assume Prisma. Database layer is Drizzle ORM.
- β Do not hardcode API keys in source code. AI keys are user-managed via the Settings UI.
- β Do not add global CSS in
apps/webwithout checkingpackages/ui/src/styles/globals.cssfor existing design tokens and CSS variables. - β Do not skip heading levels. After
<h1>, the next heading must be<h2>, not<h3>(or lower). Never have two<h1>on one page. - β Do not create custom modal/dialog components. Use shadcn
<Dialog>from@pram/ui/components/dialogβ it provides built-in focus trap, escape key, scroll lock, and ARIA. - β Do not use
anyin frontend TypeScript. Enforced by eslint@typescript-eslint/no-explicit-any: "error"inapps/web/eslint.config.mjs.
6. UI / Styling Guidelines
- Design tokens and global styles live in
packages/ui/src/styles/globals.css. - shadcn/ui primitives are shared via
packages/ui. - Import components like this:
import { Button } from "@pram/ui/components/button"; - To add new shadcn primitives, run from the project root:
npx shadcn@latest add accordion dialog -c packages/ui - App-specific blocks (non-shared) should be added directly in
apps/web/src/components/. - DevTools (TanStack Router, React Query) are production-gated via
import.meta.env.DEVβ do not remove the condition. - App-level error boundary is in
__root.tsxusing<ErrorFallback>β route-level error boundaries viaerrorComponentroute option. - Lazy-loaded route components use the
.lazy.tsxpattern: route file is thin, heavy component lives incomponents/routes/*Page.tsxwrapped inSuspense.
Sticky Detail Page Header Pattern
Detail pages with long scrollable content (e.g. package.$id.index.tsx, attempt.$id.tsx) use a sticky top header pattern:
<div className="min-h-screen pb-32 bg-[var(--warm-cream)]">
{/* Sticky header */}
<div className="sticky top-0 z-20 bg-[var(--warm-cream)]/95 backdrop-blur-sm border-b border-[var(--oat-border)] shadow-sm">
<div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-4 pb-4">
{/* Compact breadcrumb β text-xs, truncated title */}
{/* Title row (left) + action buttons (right) */}
{/* Stats row β text-xs */}
</div>
</div>
{/* Scrollable content */}
<div className="px-6 md:px-12 lg:px-16 max-w-4xl mx-auto pt-8">
...
</div>
</div>
Rules:
- Primary CTA ("Mulai Latihan", "Coba Lagi") and secondary actions (Edit, Share) live in the sticky header β never only at the bottom of a long page.
- Breadcrumb inside the header is
text-xsand truncates the current page title tomax-w-[240px]. - On small screens (
sm:breakpoint), button text labels hide (hidden sm:inline) leaving only icons.
Public/Private Toggle Hover Pattern
Any button that toggles visibility between public and private must reveal intent on hover (what will happen, not what it currently is):
<button
onClick={onTogglePublic}
aria-label={isPublic ? "Jadikan privat" : "Jadikan publik"}
className={`group ... ${isPublic
? "bg-[var(--matcha-300)] text-[var(--matcha-800)] hover:bg-[var(--pomegranate-400)]/15 hover:text-[var(--pomegranate-600)]"
: "bg-[var(--slushie-500)]/15 text-[var(--slushie-800)] hover:bg-[var(--matcha-300)] hover:text-[var(--matcha-800)]"
}`}
>
<MaterialIcon name={isPublic ? "public" : "lock"} className="text-xs group-hover:hidden" />
<MaterialIcon name={isPublic ? "lock" : "public"} className="text-xs hidden group-hover:inline" />
<span className="group-hover:hidden">{isPublic ? "Publik" : "Privat"}</span>
<span className="hidden group-hover:inline">{isPublic ? "Jadikan Privat" : "Jadikan Publik"}</span>
</button>
This pattern is used in QuestionCard.tsx and PackageCard.tsx.
Component Folder Conventions
Large "card" components for list items are extracted into their own folder and file, not inlined inside the page component:
| Domain | Card component | Used in |
|---|---|---|
| Questions | apps/web/src/components/bank/QuestionCard.tsx |
BankPage.tsx |
| Packages | apps/web/src/components/packages/PackageCard.tsx |
PackagesPage.tsx |
When a list item card grows beyond ~40 lines of JSX, extract it. Pass all state through props; the card must be stateless (no data fetching).
CalloutCard
apps/web/src/components/bank/CalloutCard.tsx is a reusable banner for "N items are private, publish them all" prompts. Use the label prop to customise the noun:
<CalloutCard count={privateCount} label="soal" onPublishAll={handlePublishAllPrivate} />
<CalloutCard count={privateCount} label="paket" onPublishAll={handlePublishAllPrivate} />
count should come from a dedicated query with limit: 1 (fetches only the total field), not from the locally accumulated infinite-scroll array β those counts are partial and misleading.
5b. Route Shell & Layout Modes
Route Shell System
Every route declares its layout via staticData from apps/web/src/lib/route-shell.ts. The root layout (__root.tsx) reads this to decide which shell to render:
| Shell | When to use | What renders |
|---|---|---|
routeShell.app |
Authenticated app pages | Sidebar + <main id="main-content"> + SkipLink |
routeShell.public |
Landing, login, auth flows, 404 | Bare min-h-screen wrapper, no sidebar |
routeShell.fullscreen |
Test-taking, immersive UIs | h-screen overflow-y-auto, no sidebar |
Usage in a route file:
import { routeShell } from "@/lib/route-shell";
export const Route = createFileRoute("/my-route")({
staticData: routeShell.app, // or .public / .fullscreen
...
});
Rules:
- Always set
staticDataon every route. Routes without it default to"app"shell (sidebar shown). - Auth routes (
/login,/forgot-password,/verify-email,/setup-avatar) userouteShell.public. - Test routes (
/package/$id/take,/package/$id/attempt/$attemptId) userouteShell.fullscreen. - Admin routes (
/admin,/admin/*) userouteShell.fullscreen.
Route Structure Conventions
/β Public landing page (index.tsx+index.lazy.tsx). Redirects logged-in users to/dashboard./dashboardβ Authenticated home page (dashboard.tsx). Redirects unauthenticated users to/login./landingβ Legacy redirect β/(keep for backward compat, do not remove)./$.tsxβ Catch-all 404 route. UsesrouteShell.publicso no sidebar appears. Renders<NotFoundPage>.- Do NOT put a
notFoundComponentin__root.tsx. The 404 is handled exclusively by the splat route$.tsx.
5c. Frontend Accessibility Conventions
- Skip link: Root layout in
__root.tsxhas a hidden<SkipLink>that appears on Tab press, linking to#main-contenton the<main>element. - Icon-only buttons: Every
<button>or<Button>with only an icon (no visible text) must havearia-label. The icon element (<MaterialIcon>,<span>) should NOT havearia-hidden="true"unless decorative. - Heading hierarchy: Never skip levels (
h1 β h2 β h3, neverh1 β h3). Exactly one<h1>per page. Heading level corresponds to semantic nesting, not visual size. - Form labels: Every
<Input>,<select>,<textarea>needs an associated<Label>(withhtmlFor+id) OR anaria-labelattribute. Search inputs, OTP digits, and icon-only filter buttons are common offenders. - Dialogs: All modals use the shadcn
<Dialog>component (from@pram/ui/components/dialog), which provides:- Focus trap inside dialog
- Escape key to close
- Scroll lock on body
- Focus restoration to trigger element on close
- ARIA
role="dialog",aria-modal,aria-labelledby
- Custom tabs/accordions: Must have proper ARIA roles (
role="tablist",role="tab",aria-selected,tabindexfor roving tabindex) and arrow key handlers. Prefer shadcn<Tabs>where possible; use manual ARIA +handleRovingKeyDownhelper only when tabs have close buttons or other non-standard elements.
7. Environment & Secrets
- Server environment variables are loaded from
apps/server/.env. - Required variables typically include:
DATABASE_URL(PostgreSQL connection string)BETTER_AUTH_SECRETBETTER_AUTH_URL
- AI provider API keys are NOT stored in
.env. Users configure their own keys via the Settings page in the UI.
8. Database Schema Conventions
- Schema files live in
packages/db/src/schema/. - Use
pgTablefrom Drizzle. Primary keys are usuallyuuidwith.defaultRandom(). - Add indexes for frequently queried foreign keys.
- When adding new tables or columns, run
bun run db:pushto sync the local database.
9. AI Generation Context
- AI logic is centralized in
packages/ai/. - Key files:
src/schemas.tsβ Zod schemas for questions, generation input, and output.src/prompts.tsβ Prompt builders for quick mode.src/agentic.tsβ Multi-step agentic generation pipeline (passage β validate β questions β self-check).
- Adding a new question format requires updates to:
packages/ai/src/schemas.ts(enum + discriminated union)packages/ai/src/prompts.ts(format description)packages/ai/src/agentic.ts(format schema string)packages/api/src/routers/attempt.ts(answer normalization)apps/web/src/lib/generate-constants.ts(format metadata + allowed exams)apps/web/src/lib/exam-constants.ts(format string list)
10. Backend Conventions (tRPC / Hono)
Error Handling
- Always use English error messages in tRPC routers.
- Use helper functions from
packages/api/src/lib/errors.ts:import { throwNotFound, throwForbidden, throwBadRequest } from "@pram/api/lib/errors"; assertOwnership(row, userId, "Question"); // throws NotFound or Forbidden - Never throw raw
new Error("...")in tRPC routers.
Pagination
- Use shared schema + helper for all list endpoints:
import { paginationSchema, paginateDefaults } from "@pram/api/lib/pagination"; .input(z.object({ search: z.string().optional(), ...paginationSchema?.shape }).optional()) const { limit, offset } = paginateDefaults(input); - Return shape:
{ items: rows, total }(or domain-specific name like{ questions: rows, total }for legacy compatibility).
Authorization (Ownership)
- Use
assertOwnership(row, userId, resourceName)frompackages/api/src/lib/ownership.ts. - Prefer explicit ownership query + assert over embedding
creatorUserIdin.where()for mutations that need a clear 404 vs 403.
Visibility (Public/Private)
- Use
buildVisibilityCondition(table, userId)frompackages/api/src/lib/visibility.tsfor list queries. - Default behavior: guests see only public; authenticated users see public + their own private.
Logging
- Winston logger is available in tRPC context via
ctx.logger. - Use
logger.info(),logger.error(), etc. for business events and errors. - Hono HTTP request logging is handled automatically by
hono/loggerinapps/server/src/index.ts.
Router Structure
- Shared helpers live in
packages/api/src/lib/. - Routers live in
packages/api/src/routers/. - Register new routers in
packages/api/src/routers/index.ts.
11. Admin Routes & DataTable
Admin Route Structure
- Admin routes are flat files:
routes/admin.users.tsx,routes/admin.credits.tsx, etc. - Wrapped by
routes/admin.tsxlayout shell (sidebar + admin role check). - All admin tRPC procedures live in
packages/api/src/routers/admin.tsand useadminProcedure(extendsprotectedProcedurewith role check). - Mutations use
audit()helper for audit logging toadminAuditLogtable.
DataTable Component (Design Reference)
- Admin table routes should eventually use a
<DataTable<T>>component fromcomponents/admin/DataTable.tsx. - API design: generic
<T>, column defs withaccessorKey/accessorFn/cellrender prop,actionsrender prop, opt-in pagination, server-side sorting. - Search bars, filter pills, and status pill buttons live above the DataTable in the route component (not inside it).
- Do NOT use DataTable for card-based layouts (e.g., Featured editor's pick) β keep card layouts as-is.
Current Migration Status
admin.users.tsxβ table-based, good candidate for DataTableadmin.jobs.tsxβ table-based, good candidateadmin.moderation.tsxβ table-based, good candidateadmin.credits.tsxβ transaction table, good candidateadmin.featured.tsxβ card layout, keep as-isadmin.index.tsxβ dashboard stats cards, keep as-is
11b. Git & Workflow
- Do NOT run
git commit,git push,git rebase, or force-push unless the user explicitly asks for it. - Do NOT create
README.mdor documentation files unless explicitly requested. - Keep changes minimal and focused on the task at hand.
- Follow existing code style in the file you are editing.
12. Testing
Test Runner
- Bun test (
bun test) β built-in, Jest/Vitest compatible API (describe,it,expect). - Turbo task
testsudah dikonfigurasi.
Test Location
- Unit tests:
src/__tests__/*.test.tsdi masing-masing package. - Integration tests:
packages/api/src/__tests__/*.integration.test.ts(pakai PGlite in-memory DB).
Integration DB (PGlite)
- PGlite (
@electric-sql/pglite) β PostgreSQL WASM in-memory. - Strategy:
mock.module("@pram/db")intercept DB saat dynamic import untuk inject PGlite-based drizzle instance. - Setup helper:
packages/api/src/__tests__/test-setup.tsβ skema 14 tabel via raw SQL + seed data. - Schema diimport dari
../../../db/src/schema(langsung, bukan via@pram/db) untuk hindari env validation side-effect. - Env vars di-mock via
mock.module("@pram/env/server")sebelum dynamic import.
tRPC Router Testing
- Gunakan
router.createCaller({ session, auth })untuk memanggil procedure. - Protected procedure:
session: { user: { id }, expiresAt: new Date() }. - Public procedure:
session: null.
Known Limitations
- Rate limiter (
checkRateLimitdiattempt.ts) pakai in-memoryMap. Gunakan user ID berbeda per test atauBun.sleep()untuk menghindari rate limit blocker. - Timer validation (
finish) butuh β₯5 detik elapsed sejakstartβ pakaiBun.sleep()+{ timeout: 30000 }padait(). - Env vars Wajib untuk di-mock saat integration test:
DATABASE_URL,BETTER_AUTH_SECRET(β₯32 chars),BETTER_AUTH_URL,CORS_ORIGIN,API_KEY_ENCRYPTION_KEY(β₯32 chars),REDIS_URL,SMTP_HOST,SMTP_USER,SMTP_PASS,SMTP_FROM.
TDD Convention
- TDD: tulis test dulu, lihat fail, baru implementasi.
- Untuk existing code yang belum ada test: tulis test yang verifikasi behavior saat ini.
- Fungsi internal yang perlu di-test secara unit harus di-
export.
Last updated: 2026-05-25