Welcome to Truth Social.
This guide covers your internship plan, the technologies we use to build and ship the Truth Social web client, and the workflows you'll follow as a contributor. Start with the internship overview, then work through the technical modules in order.
Internship Plan
This internship is built around genuine participation — not shadowing, not busy work. You'll be in real meetings, shipping real code, and learning the tools that define how modern software gets built. The goal is for you to leave with hands-on experience across the full development lifecycle and a concrete understanding of where the industry is headed.
Team Web Meetings
You'll be included in the web team's regular standups and planning sessions. Pay attention to how work gets scoped, how decisions get made, and how engineers communicate blockers and progress. These meetings are part of the job — not optional context.
Product Meetings (Where It Makes Sense)
You'll be brought into product discussions when the topics are relevant to what you're working on or learning. Seeing how features move from idea to specification to implementation is something you can't learn from documentation — take notes, ask questions, and observe how product thinking translates into engineering decisions.
TypeScript & React Learning
You'll work through structured learning materials covering the core technologies in this codebase — TypeScript, React, and the libraries we've built on top of them. The technical modules in this document are your curriculum. Don't just read them; work through the exercises, follow the file references, and ask questions when something isn't clicking. The goal is fluency, not familiarity.
Contributing to truth-web
You'll make real contributions to the Truth Social web client — starting with the scoped starter tasks in this guide and growing from there. You'll go through the same workflow every engineer on the team uses: branch, code, pre-push checks, MR, code review. Your code will be reviewed seriously, your feedback will be real, and your changes will ship.
Help Center Improvements
Alongside your work on truth-web, you'll contribute to improvements on the Truth Social Help Center. This is a great opportunity to develop a full-stack perspective — understanding not just how the product is built, but how it's documented and supported. It's also a lower-stakes environment to get comfortable shipping independently.
Spark Team — AI-Forward Development
You'll spend time with our Spark team, which is leading the charge on AI-forward development across the company. This isn't a trend we're watching — it's a shift we're actively driving. The future of software development is increasingly about prompting: knowing how to work with AI systems effectively, how to structure problems for them, how to verify their output, and how to integrate them into a real engineering workflow.
You're entering the industry at exactly the right moment to build these skills from the start, rather than having to unlearn old habits later. Lean into it.
Getting Set Up Locally
Before working through the modules below, get the project running on your machine. Do this first — many of the modules ask you to open files and poke around in the running app.
1. Clone the repository
The project lives on GitLab. Make sure your SSH key is added to your GitLab account, then clone it (ask your team lead for the exact repo URL if you don't have it):
# clone truth-web from GitLab
git clone git@gitlab.com:<org>/truth-web.git
cd truth-web
2. Install dependencies
The project uses npm. From the repo root:
npm install
3. Configure your environment variables
Copy the example env file and fill in the values. At minimum you'll need VITE_BACKEND_URL pointing at the API:
cp .env.example .env
You don't need to guess at the values — ask Justin in Slack for the .env file and copy it in. Don't commit your .env; it's gitignored for a reason.
4. Start the dev server
npm run dev
This starts Vite and serves the app at localhost:3000 with hot module reloading — edits show up in the browser without a manual refresh.
Sanity check: open localhost:3000, confirm the app loads and you can log in. If the app loads but API calls fail, your VITE_BACKEND_URL is probably wrong or your .env is incomplete — double-check the file from Slack.
The Browser Environment
The app is a React 18 single-page application. The entire UI runs in the browser — there are no server-rendered pages and no page reloads between views. The Truth Social API is a separate back-end server; this repo is the front end only.
Key constraints
| Constraint | What it means in practice |
|---|---|
| No file system | Persistence happens through localStorage, IndexedDB (we use localforage), or the API. Auth tokens live in IndexedDB — never cookies. |
| Single thread | JavaScript is single-threaded. async/await is cooperative, not parallel. Long synchronous work blocks the entire UI. |
| DOM = the UI | React manages the DOM for you. You rarely touch it directly. |
| Network can fail | Auth expiry, CORS, offline — every fetch needs error handling. Never assume a call will succeed. |
| No runtime build step | What ships is the compiled output of npm run build. Vite handles bundling and tree-shaking at build time. |
- Open DevTools (F12) → Application → IndexedDB and find the stored auth tokens.
- Open the Network tab, filter to Fetch/XHR, then take an action (like/follow) and inspect the request and response.
- Open the Console and run
localStorage.getItem('soapbox')to see stored UI preferences. - Open the React Query DevTools panel (bottom-right corner in dev) and navigate between pages — watch the cache populate.
TypeScript
TypeScript adds a static type system on top of JavaScript. The compiler catches type errors before the code runs, and editors use the types for autocomplete and inline documentation. Strict null checks are enabled — null and undefined are separate and must be handled explicitly.
Syntax primer
// Types describe the shape of data
type Status = "public" | "self" | "group"; // union — one of these values
// Interfaces describe object shapes (used for component props)
interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean; // ? = optional
}
// Generics work like templates
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// async/await — returns Promise<T>
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/v1/accounts/${id}`);
return res.json();
}
Project type conventions
// Use `type` for unions, intersections, and computed shapes
type ReadonlyAccount = Readonly<Account>;
// Use `interface` for prop bags
interface ChatItemProps {
isRead: boolean; // booleans: always is* / has* / can* prefix
hasAttachments: boolean;
canDelete: boolean;
}
// Derive types from Zod schemas — never write both by hand
import z from "zod";
const schema = z.object({ id: z.string(), name: z.string() });
type Account = z.infer<typeof schema>; // type comes from the schema
- Read
src/schemas/account.tsend to end. Notice how the TypeScript type is derived from the Zod schema rather than written separately. - Find a hook in
src/api/chats/hooks/and trace its generic type parameters from the call site back to the definition. - Open
src/types/api.tsand study how API error types are structured as a union.
React Fundamentals
A React component is a function that takes props and returns UI (JSX). React calls it again whenever its props or state change. The framework diffs the result against the previous render and only updates what changed in the DOM.
Component anatomy
interface UserBadgeProps {
username: string;
isVerified: boolean;
}
const UserBadge = ({ username, isVerified }: UserBadgeProps) => (
<span>
@{username}
{isVerified && <VerificationBadge />}
</span>
);
Hooks you'll use most
| Hook | Purpose |
|---|---|
useState | Local component state — triggers a re-render on change |
useEffect | Side effects that run after render (subscriptions, timers) |
useRef | Mutable value that does not trigger a re-render |
useContext | Read a value from a React context (our dependency injection) |
useMemo / useCallback | Memoize values/functions — avoid unless profiling shows a need |
Never mutate state directly. Always return a new object or array. Effects run after paint — don't use them to derive state from props; compute it inline instead.
If data should be shared across many components, it belongs in a context, not passed through props down multiple levels.
- Read
src/components/status.tsx. Map each prop to what it renders on screen. - Find a
useStatecall in any feature component. Trace what triggers the change and what re-renders as a result. - Read
src/contexts/composer-context.tsx. Identify what state it holds and why it lives in context rather than in a single component.
Project Architecture
src/
├── api/ # Data layer: hooks, schemas, React Query integration
│ ├── core/ # useEntity, useEntities, useEntityMutation
│ └── <feature>/ # Feature-specific hooks (e.g. api/chats/, api/groups/)
├── components/ # Shared UI components reusable across features
│ └── ui/ # Primitive UI kit: Button, Modal, Select, Input…
├── contexts/ # React contexts (global shared state)
├── features/ # Full page / screen implementations
│ └── ui/ # App shell, routing, layout
├── hooks/ # Shared custom hooks
├── schemas/ # Zod schemas — single source of truth for API shapes
├── types/ # Global TypeScript types (non-schema)
├── utils/ # Pure utility functions
└── styles/ # Tailwind config and global CSS
The three-layer rule
Every feature follows the same structure. A component should never call fetch or axios directly — always go through the hook layer.
| Layer | Location | Responsibility |
|---|---|---|
| Schema | src/schemas/ | Defines what an API entity looks like; validates at runtime |
| API hook | src/api/<feature>/ | Fetches, caches, and mutates entities via React Query |
| Feature component | src/features/<feature>/ | Renders the entity; calls the API hook |
Always use useApi() for authenticated requests — it handles token injection and refresh automatically.
The API Contract
The Truth Social API is a Mastodon-compatible REST API. Every feature in this repo is ultimately a wrapper around HTTP calls to that API. Understanding how those calls are made, authenticated, paginated, and validated is fundamental to working in this codebase.
Authentication
The app supports two auth states: logged in (bearer token) and logged out (visitor token). The correct headers are applied automatically by the HTTP client — you never attach them manually.
| State | Header sent | Where it comes from |
|---|---|---|
| Logged in | Authorization: Bearer <token> | Token retrieved from IndexedDB via getCachedAccountToken() |
| Logged out | X-Visitor-Token: <token> | Persisted in localStorage under truth:visitor-token, refreshed from response headers |
Always use useApi() to get the authenticated axios instance. It reads the cached token and the correct baseURL at call time — calling baseClient() directly will bypass this.
The HTTP client
The client is built on axios, configured in src/api/index.ts. It sets the baseURL to VITE_BACKEND_URL and attaches the auth header. In development, /api/*, /oauth/*, /pleroma/*, and /socket paths are proxied to the backend via Vite — no CORS issues locally.
// The correct way to make an authenticated request
const api = useApi(); // axios instance with auth header
// GET
const response = await api.get("/api/v1/accounts/verify_credentials");
// POST with body
const response = await api.post("/api/v1/statuses", {
status: "Hello, Truth!",
visibility: "public",
});
// DELETE
const response = await api.delete(`/api/v1/statuses/${id}`);
Pagination via Link headers
List endpoints are paginated using cursor-based pagination — never offset-based. The API returns a Link HTTP response header containing URLs for the next and previous pages. The client parses these with the http-link-header library.
// Response headers from a timeline request:
// Link: <https://truthsocial.com/api/v1/timelines/home?max_id=123>; rel="next",
// <https://truthsocial.com/api/v1/timelines/home?since_id=456>; rel="prev"
import { getNextLink, getPrevLink } from "@/api";
const nextUrl = getNextLink(response); // pass to next page fetch
const prevUrl = getPrevLink(response); // pass to prev page fetch
Never use offset pagination. Always follow the next link from the response header. useEntities handles this automatically — you only need to call fetchNextPage().
Some endpoints use an X-Total-Count response header to communicate total result counts without sending all the data. useEntities exposes a getTotalCount callback for this.
Request → Response lifecycle
Here is what happens end-to-end when a component triggers a data fetch:
| Step | Where | What happens |
|---|---|---|
| 1. Component mounts | Feature component | Calls useEntity or useEntities with an endpoint and schema |
| 2. Cache check | React Query | If a fresh cached result exists for the query key, return it immediately — no network call |
| 3. HTTP request | useApi() → axios | GET to the API with auth header attached |
| 4. Response received | axios interceptor | Visitor token in response headers is persisted if present |
| 5. Zod parse | useEntity / useEntities | Response body validated against the schema; unknown fields stripped, bad fields defaulted via .catch() |
| 6. Cache write | React Query | Parsed entity stored under the query key; all subscribed components re-render |
Error handling
The API returns errors as JSON. Two error shapes exist — standard API errors and Pepe (internal service) errors — both defined in src/types/api.ts.
// Standard API error shape
interface ApiError {
error: string;
error_message: string;
error_code: string;
message: string;
errors: {
error_code: string;
error_field: string | null;
error_message: string;
}[];
}
The mutation layer in useEntityMutation handles two status codes automatically — you don't need to handle these yourself:
| Status | Automatic behaviour |
|---|---|
403 (SMS re-verification required) | Opens the REVERIFY_SMS modal if the current account has sms_reverification_required set |
429 (rate limited) | Shows a toast with the error message from the response body, falling back to the default "Too many requests" message |
For validation errors with field-level details (e.g. form submissions), use buildErrorMessage(errors) from src/utils/errors.ts to convert the errors object into a human-readable string.
The Zod boundary
Zod sits at the exact point where raw API data enters the app. Nothing upstream of useEntity / useEntities ever touches unvalidated data. This means:
- If the API adds a new field, it's silently ignored until you add it to the schema.
- If the API removes or renames a field, the
.catch()default fires and the app keeps working. - If the API returns an entirely wrong shape, the schema fails safe and the component receives the defaulted values rather than crashing.
Relationships as a second request
Account and group list endpoints don't embed relationship data (follow state, membership role, etc.) in the response — that comes from a separate /api/v1/accounts/relationships or /api/v1/groups/relationships call. useEntities handles this automatically when you pass a relationshipConfig option — it batches the IDs from the current page, fetches relationships, and merges them into the result.
- Open
src/api/index.ts. Find where theAuthorizationheader is attached and trace where the token comes from. - Open the Network tab in DevTools and load a timeline. Find the
Linkresponse header and identify themax_idcursor value. - Open
src/api/core/hooks/useEntityMutation.tsand find theonErrorhandler. Identify the two HTTP status codes it handles automatically. - Trigger a 429 in a dev environment (or read the handler) and trace what the user sees.
Data Fetching with React Query
React Query manages all async server data. It caches responses, deduplicates in-flight requests, and re-fetches when data goes stale — you don't manage loading/error state by hand.
Reading data
import { useEntity, QueryKeys } from "@/api/core";
import { accountSchema } from "@/schemas/account";
const { entity: account, isLoading } = useEntity({
id,
queryKey: QueryKeys.accounts.account(id),
endpoint: `/api/v1/accounts/${id}`,
schema: accountSchema, // response is validated + typed automatically
});
Mutating data
const { mutate: followUser } = useEntityMutation({
queryKey: QueryKeys.accounts.account(id),
endpoint: `/api/v1/accounts/${id}/follow`,
method: "POST",
onMutate: async () => { /* snapshot cache for optimistic update */ },
onError: (_, __, context) => { /* roll back from snapshot */ },
});
Optimistic updates
When a user takes an action (follow, like, etc.), the UI should feel instant:
| Step | What happens |
|---|---|
onMutate | Snapshot the current cache; write the expected new value immediately |
| Server succeeds | Do nothing — the cache is already correct |
onError | Restore the snapshot |
All query keys come from QueryKeys in src/api/core/query-keys.ts. If a key is invalidated from more than one folder, it belongs in core, not in a feature folder.
- Open a mutation hook in
src/api/statuses/. Identify the optimistic update pattern. - In the React Query DevTools (dev mode), watch the cache as you navigate between pages.
- Trace a query key from a component → hook →
QueryKeysdefinition.
Zod — Runtime Validation
The API can return unexpected shapes — missing fields, wrong types, extra nulls. Zod validates the response at the boundary so the app never crashes on malformed data, and gives us a strongly-typed object to work with.
import z from "zod";
const statusSchema = z.object({
id: z.string(),
content: z.string().catch(""), // safe default on failure
favourited: z.boolean().catch(false),
visibility: z.enum(["public", "self", "group"]).catch("public"),
created_at: z.string().datetime().optional(),
});
type Status = z.infer<typeof statusSchema>; // type derived from schema
Never hand-write a type for an API entity. Always derive it with z.infer<>. Add new schemas to src/schemas/ before writing any hook or component that consumes the entity. Prefer .catch() over .optional() for API fields.
Styling with Tailwind CSS
Tailwind is a utility-first CSS framework. Instead of writing a CSS class with properties, you apply small, single-purpose utility classes directly on the element.
import { cn } from "@/utils/cn";
<div className={cn(
"rounded-lg border border-border px-4 py-2",
isActive && "bg-primary text-primary-foreground",
isDisabled && "opacity-50 cursor-not-allowed",
)} />
Project rules
| Rule | Detail |
|---|---|
| Semantic tokens | Use text-muted-foreground, bg-primary, border-border — never text-gray-700 dark:text-gray-600 |
| Class composition | Use cn() from project utilities, not clsx. The migration is done; new clsx imports are regressions. |
| Dark mode | Use the dark: Tailwind variant. Never use CSS prefers-color-scheme media queries. |
- Toggle dark mode in the app (Settings → Appearance) and observe which
dark:classes activate in DevTools. - Find a component using raw
text-gray-*classes — note this is legacy; new code should not follow this pattern.
Routing
All routes are declared in src/features/ui/index.tsx using WrappedRoute. Lazy-loaded components are registered in src/features/ui/util/async-components.ts.
<WrappedRoute
path="/notifications"
page={BundleContainer}
component={Notifications}
requiresAuth
/>
requiresAuth redirects unauthenticated users to login automatically — you don't need to handle that in the feature component. Auth routes (login, register, OTP) live in src/features/auth-layout/.
Internationalization (i18n)
Every string visible to users must go through React Intl. No hardcoded string literals in JSX.
import { defineMessages, useIntl } from "react-intl";
const messages = defineMessages({
follow: { id: "account.follow", defaultMessage: "Follow" },
});
const FollowButton = () => {
const intl = useIntl();
return <button>{intl.formatMessage(messages.follow)}</button>;
};
After adding any message, run npm run i18n — it extracts all messages into src/locales/en.json. Never hand-edit en.json.
Terminology rule: User-facing copy says "Truth" / "Truths" — not "post" / "posts". The API uses status internally — keep that in code, but not in anything the user sees.
Testing
We use Vitest — Jest-compatible and Vite-native. Tests live alongside their subjects (src/features/compose/__tests__/) or in src/__tests__/. Mock modules are in src/__mocks__/.
| What to test | How |
|---|---|
| Utility functions | Pure input → output unit tests |
| Custom hooks | renderHook from @testing-library/react |
| Complex component interactions | Component tests — forms, modals, multi-step flows |
Test observable behavior, not implementation details. If a refactor doesn't change what the user sees, the tests shouldn't break.
Git Workflow & CI
Branching
We work on GitLab. The main branch is main. For any piece of work, create a feature branch off main with a descriptive name (e.g. fix/chat-unread-count, feat/group-join-button). Make focused, atomic commits — each commit should represent one logical change.
Opening a Merge Request
When your branch is ready, push it and open an MR against main on GitLab. Fill out the MR template completely:
| Field | What to write |
|---|---|
| Summary | What changed and why — be specific |
| Screenshots | Required for any visual change, using the Before/After table format |
| Jira Issue | Link to the relevant ticket |
Code review
Reviews are formal but not adversarial — the goal is shared understanding and code quality. Expect comments on correctness, convention adherence, and whether an existing utility could be reused. Respond to every comment, either with a change or a brief explanation. Mark threads resolved once addressed. Don't merge your own MR without approval.
CI pipeline
Every push triggers the pipeline automatically. All three test-stage jobs must pass before your MR can merge.
Jobs only run when relevant files change — a docs-only commit won't trigger the full test suite. If CI fails, click the failing job in GitLab to see the full log, fix locally with the relevant command, then push.
Pre-push checklist
npm run typecheckNo type errorsnpm run lintNo lint violationsnpm testAll tests passConventions & Gotchas
| Area | Rule |
|---|---|
| API calls | Always use useApi() — never call fetch or axios directly in a component |
| API errors | Extend the error union in src/types/api.ts before introducing a new error shape |
| Blocking actions | Use openModal("CONFIRM", { … }) — not a toast — for any action the user must explicitly resolve |
| Transient feedback | Use toast.success() / toast.error() for completed actions and network failures only |
| Disabled vs hidden | Use the disabled prop for unavailable actions — don't conditionally remove the element. Disable while isPending. |
| New UI primitives | Check src/components/ui/ first — Button, Modal, Select, Input and more already exist |
| Performance | Default to no useMemo / useCallback / React.memo. Add only when a profiler shows a real problem. |
| Real-time data | New Truths and notifications arrive via WebSocket through useUserStream. They patch the React Query cache directly — never manually manage unread counts. |
| Chat state | Shared chat state belongs in ChatContext (src/contexts/chat-context.tsx), not feature-local component state |
| Groups | src/features/groups/ (plural) = discovery/list. src/features/group/ (singular) = single group. Always check the user's role before rendering moderation controls. |
| Analytics | Always use useAnalytics() from src/contexts/analytics-context.tsx — never call mixpanel directly |
How to Read a Feature End-to-End
When you encounter an unfamiliar screen or need to understand how something works, always start at the route and work inward. Here's the pattern to follow — use the notifications screen as a concrete example.
Step 1 — Find the route
Open src/features/ui/index.tsx and search for the URL path. Every screen in the app is registered here with WrappedRoute.
// Search for the path string, e.g. "/notifications"
<WrappedRoute
path="/notifications"
component={Notifications}
page={BundleContainer}
requiresAuth
/>
Step 2 — Find the component
The component prop is a lazy-loaded reference. Open src/features/ui/util/async-components.ts and search for the component name to find the actual file path.
// In async-components.ts
export const Notifications = lazy(() =>
import("@/features/alerts/components/alerts-index")
);
Step 3 — Open the feature component
Navigate to the resolved path (src/features/alerts/components/alerts-index.tsx in this case). Read the component top-down:
| Look for | What it tells you |
|---|---|
| Hook imports | Which API hooks supply the data for this screen |
useEntities / useEntity calls | The endpoint and query key being fetched |
| Mutation calls | What actions the user can take and how they hit the API |
Context usage (useContext) | What shared state this screen depends on |
| Child components | Where the rendering is delegated — follow these if needed |
Step 4 — Find the API hook
The hook imported by the component lives in src/api/<feature>/hooks/. Open it and note:
- The
endpointstring — this is the API path being called - The
schema— find it insrc/schemas/to see what fields the app expects - The
queryKey— tells you how this data is cached and what else might share or invalidate it
Step 5 — Verify in the Network tab
Load the screen in the browser with DevTools open. Filter the Network tab to Fetch/XHR and confirm the request matches the endpoint you found in code. Check the response shape against the schema. This closes the loop between what the code expects and what the API actually returns.
The same method works in reverse — if you see an API call in the Network tab and want to find the code driving it, search the codebase for the endpoint path string. It will appear in exactly one hook.
- Walk through all five steps for the Home Timeline screen (
/home). - Walk through all five steps for the Chats screen (
/messages). - Pick any network request you see in the Network tab while browsing the app and trace it back to the hook and schema that own it.
Starter Tasks
These tasks are designed to be approachable first contributions — each one touches a real problem in the codebase and follows the full workflow: branch → code → pre-push checklist → MR → review. Ask your team lead to point you to a specific file for each one if you're not sure where to start.
Fix a hardcoded user-facing string
- Find a string literal rendered directly in JSX that isn't wrapped in
<FormattedMessage>orintl.formatMessage() - Wrap it correctly using
defineMessagesand the appropriate hook - Run
npm run i18nto extract it intosrc/locales/en.json - Verify the string still renders correctly in the UI
Replace every clsx import with cn()
- First, read up on the project's
cnutility and how it wrapstailwind-merge— unlikeclsx,cnde-dupes conflicting Tailwind classes (e.g.px-2 px-4resolves topx-4) - Search the codebase for
import.*clsxto find all legacy usages, not just one - Replace each import with
cnand update the call sites - Be careful: because
cncollapses conflicting utilities, swapping it in can change the rendered output where call sites were (knowingly or not) relying onclsxkeeping both classes. Review each conversion for visual changes - Confirm every touched component still renders and behaves correctly
- Run
npm run lintandnpm testbefore opening the MR
Convert a conditionally hidden menu item to disabled
- Find a dropdown or menu where an action is conditionally rendered (
&&or ternary returningnull) based on some state - Replace conditional rendering with the
disabledprop so the item is always visible but non-interactive when unavailable - Check the component renders correctly in both the enabled and disabled states
Write a unit test for an untested utility function
- Browse
src/utils/and find a function without a corresponding test file - Create a
__tests__/file next to it and write tests covering the main cases and at least one edge case - Run
npm run test:coverageand confirm coverage for that file increased - Make sure the test file follows the existing naming and structure conventions in the codebase
Suggested Progression
Environment & Language
- Read
AGENTS.mdin full — the single most important document in the repo - Get the app running locally:
cp .env.example .env→ setVITE_BACKEND_URL→npm install→npm run dev - Complete Modules 1 and 2. Spend real time in DevTools.
React & Architecture
- Complete Modules 3 and 4
- Read
src/components/status.tsx,src/features/home-timeline/, andsrc/features/compose/end to end - Map where state lives, where data is fetched, and where it's rendered for a single user action (e.g. liking a Truth)
Data Layer
- Complete Modules 5 and 6
- Trace a full data flow: user action → mutation → API call → cache update → re-render
- Write a small read-only hook using
useEntityagainst a simple endpoint
Styling, i18n, Routing, Testing
- Complete Modules 7–10
- Make a small UI change (copy, spacing, color) and verify it in both light and dark mode
- Run the full test suite and ensure it passes cleanly
Git Workflow & First MR
- Complete Modules 11 and 12
- Pick a well-scoped bug or small feature. Follow the three-layer rule: schema → hook → component.
- Run the pre-push checklist, open an MR with a completed template, and go through a full review cycle
Quick Reference: Commands
npm run devStart local dev server at localhost:3000npm run typecheckTypeScript check (no build output)npm run lintLinting with oxlintnpm testRun full test suitenpm run i18nExtract / update translation stringsnpm run buildProduction build → static/