A real-time classroom. One teacher controls what every anonymous student sees: a welcome screen, a live Markdown lesson (with KaTeX math and highlighted Python), or a poll.
Live: https://class.pknspace.com
- Students open
/. They don't log in and there's no navigation. They follow the teacher's screen live. - The teacher opens
/teacher, signs in with email and password (allowlisted), and runs the Command Center. - Polls written ahead: inline in a lesson (they start when their slide is shown), in a lesson's Polls tab, or in a reusable poll bank. Quiz or opinion polls with 2–6 options. Votes are never kept: each class starts from zero.
- Presenter notes only the teacher sees:
%% notefor one line, or lines between two%%lines for a longer note. - Links stay in the class page. A link a student clicks opens in a panel over the board,
never a new tab, and the student can close it. Show (Classroom tab) opens a link on every
screen that only the teacher can close. Back to board, the next slide, a push or a poll
closes every panel. Sites that refuse to be shown inside another page (Google, Gemini,
GitHub, Quick Draw…) open in a new tab instead, and the Links card marks them ⚠ New tab;
YouTube links play in the embed player. The backend checks each link's
X-Frame-Options/frame-ancestorsheaders when a lesson is saved, pushed or edited live (backend/convex/links.ts; live edits wait 10 s so half-typed links are skipped). Results stay inlinkChecksfor good; ↻ next to ⚠ New tab checks a link again. - Class Remote (
mobile/, Android): the teacher runs the class from a phone. Tabs Lessons (push a lesson or Blank Blackboard), Board (the live slide, your%%notes, big Prev / Next), Polls (launch, live counts, Reveal, Close / Run again) and Links (Show, Back to board). It only runs the class: lessons and polls are written on the web. The screen stays on while it's open.
React 19, Vite, TypeScript (strict), Tailwind v4, shadcn/ui, Convex (reactive
queries only), @convex-dev/auth (Password provider), react-markdown, KaTeX,
highlight.js, and @uiw/react-md-editor. The mobile remote: Expo, expo-router, NativeWind.
Development uses a Convex local deployment: the open-source backend binary runs on
your machine with SQLite. You don't need a Convex account. Requires Node 22.18+ (.nvmrc pins 22).
cd backend && npm install
npx convex dev # choose "start without an account" (local). Keep it running.
npm run setup:auth && npx convex env set SITE_URL http://localhost:5173 && npx convex env set ALLOWED_EMAILS [email protected] && npm run seed
cd ../web && npm install && npm run dev # second terminal → http://localhost:5173For non-interactive runs (CI or agents), use CONVEX_AGENT_MODE=anonymous npx convex dev
to skip the prompt. npx convex dev writes backend/.env.local (CONVEX_DEPLOYMENT,
VITE_CONVEX_URL=http://127.0.0.1:3210, VITE_CONVEX_SITE_URL). See backend/.env.example.
Once npm install has run, everything works offline.
cd mobile && npm install
adb reverse tcp:3210 tcp:3210 # a USB phone reaches the local backend (use the port in backend/.env.local)
npm run android # builds the dev app onto the phone, then serves the JSscripts/expo.cjs passes the backend's VITE_CONVEX_URL to Expo as EXPO_PUBLIC_CONVEX_URL,
so backend/.env.local stays the only dev env file. Sign in with the teacher account.
- Make sure your email is in the allowlist:
npx convex env set ALLOWED_EMAILS [email protected](comma-separate multiple teachers). - Open http://localhost:5173/teacher and choose "First time? Create the teacher account". Enter that email and a password of at least 8 characters.
Sign-up is rejected for any email that isn't in ALLOWED_EMAILS. If you remove an email
from the list later, that user sees Access denied, and every teacher function throws
for them.
Run these, and the other npx convex commands, in backend/.
npm run seed # = npx convex run seed:defaultThis is idempotent. It creates lessons 1–40 (1–5 have full content; 6–40 are locked placeholders) and the singleton classroom document. It skips lessons that already exist.
npx convex run seed:reset && npm run seed # wipe lessons, polls, votes and the room; keep accountsTo wipe everything, including teacher accounts:
- Stop
npx convex dev. - Delete
backend/.convex/local/. - Run
npx convex devagain. - Repeat the env and seed commands above.
Lesson numbers are text and can nest to any depth: 1, 1.1, 1.2.3. The drawer shows
sub-lessons indented under their parent and sorts each segment as a number, so
1.2 < 1.10.
- Add: + New Lesson adds the next top-level lesson. The list-plus icon on a row adds a sub-lesson under it.
- Renumber: edit the number in the editor header. Sub-lessons move with their parent
(
1→3turns1.2into3.2). The parent must already exist, and numbers must be unique. - Delete: a lesson that has sub-lessons can't be deleted until they are deleted or moved.
Upgrading a database from before nesting existed (numeric lessonNumber) needs a one-off
migration: npx convex run migrations:lessonNumbersToString. Run it while the schema still
accepts both types.
A poll has a question (Markdown, so math and code work) and 2–6 plain-text options. Mark a correct answer for a quiz; leave it unmarked for an opinion poll, which shows only the bars. Results are always revealed by hand, and students can't change their vote.
Inline polls live in the lesson's Markdown (the toolbar's poll button inserts one):
Quick check:
```poll
What is $F$ if $m = 2$ and $a = 3$?
- 5
- [x] 6
- 9
```- It starts when its slide is shown and appears under the slide's text (in build-up mode, under the earlier slides too).
- Moving to another slide closes voting and reveals the results; in build-up mode they stay on the board. Going back shows them again; Run again (Polls tab) clears the votes and reopens voting.
- The Polls tab shows the current slide's poll with live counts and Reveal.
- Editing a poll on the live board starts it again.
Saved polls live in the Polls tab:
- Lesson polls belong to one lesson and are deleted with it.
- Bank polls are reusable. Add one to any number of lessons; it's shared, not copied, so an edit changes it everywhere. Deleting a lesson only removes the link.
- Launch shows a saved poll full screen. Close ends it, deletes its votes and returns to the lesson. A poll can't be edited while it's on screen.
Nothing is kept between classes. Push to Room and Blank Blackboard delete every poll's votes, so teaching the same lesson to the next section is Blank Blackboard, then Push to Room.
| Command | What it does |
|---|---|
npm run dev (web/) |
Vite dev server (http://localhost:5173) |
npm run build (web/) |
Typecheck and production build (web/dist/) |
npm run check (web/) |
Typecheck, lint and tests (incl. error-message tests) |
npm run dev (backend/) |
Local Convex backend and function hot-reload |
npm run seed (backend/) |
Idempotent seed |
npm run setup:auth (backend/) |
Generate and set JWT_PRIVATE_KEY + JWKS |
npm test (backend/) |
Backend tests (Vitest, convex-test): lesson numbers, slides, notes, polls |
npm run check (backend/) |
Typecheck, lint and tests |
npm run android (mobile/) |
Build the dev app onto a USB phone and serve the JS |
npm run check (mobile/) |
Typecheck, lint and tests |
npm run icons (mobile/) |
Redraw the app icons from the blackboard mark |
scripts/build-android.sh preview local |
Preview APK on this Mac (cloud builds on EAS) |
scripts/check.sh |
Every part's check; run before committing |
scripts/deploy.sh |
Deploys the backend, builds and uploads the site |
DRY_RUN=1 scripts/deploy.sh |
Same checks, but only previews the deploy and upload |
Production runs on a Convex cloud deployment (backend) plus a VPS where Caddy serves the
static web/dist/ (no restart needed). scripts/deploy.sh does both.
One-time setup:
- Create a Convex cloud project and generate a production deploy key in the dashboard.
- Create the two git-ignored files from the
.env.examplenext to each:CONVEX_DEPLOY_KEYinbackend/.env.prod.local, andDEPLOY_HOST,DEPLOY_PORTandDEPLOY_DIRinweb/.env.prod.local. You also need SSH key access to the server. - Set the Convex-side env vars on the production deployment (in
backend/):node scripts/setup-auth-keys.ts --apply --prod npx convex env set --prod SITE_URL https://class.pknspace.com npx convex env set --prod ALLOWED_EMAILS [email protected]
- Seed production once:
npx convex run --prod seed:default. Then create the teacher account at/teacher.
Deploy:
DRY_RUN=1 scripts/deploy.sh # checks, then preview the backend push and the upload
scripts/deploy.sh # checks, deploy backend/convex/, build with the production URL, uploadThe preview APK is built on GitHub Actions (gh workflow run release.yml) and attached as
class.apk to a GitHub Release, so
https://github.com/mepkn/class/releases/latest/download/class.apk is the download link. One-time setup: scripts/eas.sh init (adds the EAS project id to
mobile/app.json), scripts/eas.sh env:create --environment preview --name EXPO_PUBLIC_CONVEX_URL --value https://<prod>.convex.cloud --visibility plaintext,
and the EXPO_TOKEN repository secret (the token in mobile/.eas-token).
- One subscription. Students and the teacher's Live Student Monitor both render
<StudentStage>, which subscribes toclassroom:getActiveSession. The monitor never reads editor state. - Polls and runs.
pollsholds saved polls (ownerLessonIdset for lesson polls, unset for the bank) andlessonPollseach lesson's ordered list. Inline polls are parsed from the Markdown (backend/convex/lib/inlinePolls.ts), with the same rules (lib/pollRules.ts). Showing a poll creates apollRunsrow (a copy of the poll plus open/revealed); votes belong to a run, and runs are deleted on close, Push to Room and Blank Blackboard.lib/inlineRuns.tsstarts, reveals and restarts inline runs as slides change. - No answer leaks.
getActiveSessionincludes a poll'scorrectIndexand vote counts only after it's revealed. - Voting. Each browser gets a
nanoidtoken stored inlocalStorage, so new tabs share it. The server enforces one vote per(runId, voterToken)and rejects votes once results are revealed. - Security. Every teacher query and mutation calls
requireTeacher(ctx). It checks that the caller is signed in and that their email is inALLOWED_EMAILS. User-facing errors are thrown asConvexError, so their messages also reach the client in production. - UI. shadcn/ui components live in
web/src/components/ui/(vendored, added withnpx shadcn add, not linted). - Tests. The pure helpers in
backend/convex/lib/(lesson numbers, slides, presenter notes, poll parsing, links) have Vitest tests next to them, andbackend/convex/polls.test.tsandlinks.test.tsrun the poll and link functions withconvex-test. Convex skips*.test.tsfiles when deploying. - Rendering safety. Raw HTML is skipped. KaTeX runs with
throwOnError: false, and an ErrorBoundary falls back to the raw Markdown, resetting when the content changes.