diff --git a/PRD-TRACKER.md b/PRD-TRACKER.md index ed3ee0d3..43452603 100644 --- a/PRD-TRACKER.md +++ b/PRD-TRACKER.md @@ -8,6 +8,7 @@ Use this file to keep a single prioritized view of all PRDs. | --- | --- | --- | --- | | - | - | `prd/super-admin` | draft | | - | - | `prd/embed-link-platforms` | draft | +| - | - | `prd/mobile-app` | draft (blocked on `prd/notification-system` for push) | | 1 | - | `prd/notification-system` | todo | | 2 | 14 | `prd/course-templates` | todo | | 3 | 5 | `prd/course-import` | todo | diff --git a/prd/mobile-app/README.md b/prd/mobile-app/README.md new file mode 100644 index 00000000..7a6b0a7c --- /dev/null +++ b/prd/mobile-app/README.md @@ -0,0 +1,397 @@ +# ClassroomIO Mobile App PRD + +## Status + +- Draft (prototyped, ready for product review) + +## Prototypes — the UX source of truth + +**The UX of this feature must be taken from the prototype folder.** Interactive HTML prototypes for every screen live in [`prototypes/mobile-app/`](../../prototypes/mobile-app/) and are the approved design reference for layout, states (locked / overdue / offline / graded / empty), copy, and navigation flow. When this document and a prototype disagree on a UI detail, the prototype wins. + +**Start here:** + +``` +prototypes/mobile-app/index.html +``` + +Open it in a browser (`open prototypes/mobile-app/index.html`) — it maps both journeys (learner → tutor) and every screen cross-links so you can click through each flow end to end. Each page has a light/dark toggle, renders inside a 390 × 844 device frame, and shows the alternate states of that screen side by side. + +The prototypes are built on the real design tokens from `packages/ui/src/index.css` (OKLCH palette, Geist, `--radius: 0.625rem`) via `app-theme.css`, so the app reads as the same product as the dashboard. + +| Surface | Files | +| --- | --- | +| Entry | `index.html` (screen map), `auth.html` | +| Learner — core | `home.html`, `learn.html`, `course.html`, `lesson.html`, `exercise.html` | +| Learner — compliance & proof | `compliance.html`, `certificates.html` | +| Learner — engagement | `community.html`, `notifications.html` | +| Learner — platform | `downloads.html`, `profile.html` | +| Tutor companion | `tutor-grading.html`, `tutor-attendance.html`, `tutor-course.html` | + +## Purpose + +Ship a native iOS and Android app that makes ClassroomIO usable by the people companies actually train — frontline staff, field technicians, distributed employees, partners, and customers — who often have no work laptop and unreliable connectivity. The app is a **learner-first companion**, not a port of the dashboard: it optimizes for "open the app → see what needs my attention → finish it," with a small tutor mode for grading and attendance on the go. Reference experiences: TalentLMS (offline company training), Canvas Student (learner productivity), Teachable (polished consumption). + +## Problem Statement + +- ClassroomIO is browser-only. A warehouse worker, retail associate, or field technician with no company laptop has no practical way to complete assigned compliance training. +- There is **no push channel**. Deadline, renewal, and assignment nudges exist only as email (`packages/email/src/emails/`), which is the wrong medium for staff who do not check work email daily. Overdue compliance is discovered by the admin, not prevented by the learner. +- There is **no offline path**. Lessons, video (HLS), and exercises all require a live connection, so training cannot happen on a plane, in a basement, on a shop floor, or in a low-coverage region. +- Mobile web is a poor substitute for the parts that matter: background audio, lock-screen video controls, camera/file submissions, biometric re-entry, and a home-screen icon that pulls a learner back in. +- Compliance status — the product's core value — is buried in a dashboard the learner rarely opens. There is no "am I compliant, what expires next, where is my certificate" surface in the learner's pocket. +- Competitors treat mobile as table stakes: Moodle, Canvas, Blackboard, TalentLMS, Thinkific, and Teachable all ship apps, and every one of them offers push notifications; all but Thinkific offer offline content. + +## Confirmed Decisions + +These were locked from the mobile brainstorm that preceded this PRD. Items marked **(product call)** were decided without an explicit answer and are the ones to veto first. + +1. **One role-aware app, not two.** A single binary serves learners and tutors; the tab bar and course screens change by role. Canvas ships separate Student/Teacher apps, but ClassroomIO's tutor surface is far smaller (grade, attend, announce, moderate) and its orgs are smaller, so two store listings would be overhead without benefit. **(product call)** +2. **Learner scope ships first.** Tutor mode is a later release behind the same binary, not a v1 requirement. +3. **Authoring stays on the web.** No course/lesson/exercise creation, media manager, AI course builder, analytics configuration, billing, SSO setup, domains, or API keys on mobile. Admin-only surfaces are intentionally absent. +4. **Expo + React Native**, as a new `apps/mobile` workspace package. It shares `@cio/utils` validation and the Hono RPC types from `@cio/api/rpc-types`, so request/response shapes stay type-checked against the real API. +5. **No new BFF.** Mobile calls the existing API. Where mobile needs something the web does not (a home digest, a device token, a token-authenticated HLS URL), we add narrow endpoints to the existing routers rather than forking business logic. +6. **Auth is Better Auth via `@better-auth/expo`.** The repo already runs `better-auth@^1.4.18` (`packages/db/package.json`). The Expo client plugin stores the session cookie in `expo-secure-store` and handles OAuth deep links; the server adds the matching `expo()` plugin and the `classroomio://` scheme to `trustedOrigins` (`packages/db/src/auth.ts`). Email/password, Google, and enterprise SSO all work through it. +7. **Biometric lock is a re-entry gate, not an auth method.** Face ID / fingerprint unlocks an existing stored session; it never replaces the initial sign-in. +8. **Org context is explicit.** There are no subdomains on mobile, so the user picks an organization after sign-in and the app sends `cio-org-id` on every request, matching how the API authorizes today (`apps/api/src/middlewares/org-team-member.ts`). +9. **No purchasing in the app, in any storefront.** No course checkout, no plan upgrade, no AI token packs. Selling digital content in-app would trigger Apple's Guideline 3.1.1 in-app-purchase requirement outside the US storefront; ClassroomIO's B2B model (training is assigned, not bought by the learner) means we can sidestep IAP entirely. Paid self-serve enrollment stays on the web. +10. **Push notifications are a hard dependency on `prd/notification-system`.** That PRD builds the `notification` table and the `notify()` service; mobile adds a **push transport** (device token registry + Expo Push delivery) and a per-category preference screen on top of it. Mobile must not build a parallel notification system. +11. **Offline v1 is read-only content plus a queued write for completion.** Download lessons, video, audio, PDFs, and transcripts; mark-complete events queue locally and sync on reconnect. **Offline exercises and submissions are v2** — quiz integrity, grading, and conflict resolution need their own design (this is where Canvas and Blackboard also draw the line). +12. **Compliance is the differentiating home surface.** The app opens on what is due, overdue, expiring, or awaiting a retake, and the certificate wallet is a first-class tab-reachable screen — not a page buried under settings. +13. **Branding is dynamic, white-label is later.** v1 themes the app with the org's name, logo, and primary color at runtime (multi-org accounts re-theme on switch). Dedicated per-customer App Store listings are an enterprise follow-up, as Thinkific and TalentLMS both sell them. +14. **The app is cloud-first but self-host compatible.** The sign-in screen accepts a custom server URL so self-hosted deployments can point the same binary at their own API; single-org self-hosted installs skip the org picker. + +## Current-State Audit + +| Capability | Current state | Notes for mobile | +| --- | --- | --- | +| Native app | **None.** `apps/course-app` is a CLI generator for standalone SvelteKit course sites, not a client | Greenfield; no code to migrate | +| API surface | Hono app with ~24 mounted routers (`apps/api/src/app.ts`) | Reuse as-is; mobile is another client | +| Typed client | `hcWithType` + `InferRequestType`/`InferResponseType` (`apps/api/src/rpc-types.ts`) | Mobile imports the same types; no hand-written DTOs | +| Auth | Better Auth 1.4.18, cookie sessions, 30-day expiry, 1-hour cookie cache, `customSession` injecting `orgRoles` (`packages/db/src/auth.ts`) | Add `expo()` server plugin + scheme in `trustedOrigins`; `orgRoles` already rides the session so RBAC needs no extra call | +| Session endpoint | `GET /session` returns `{ session, user }` (`apps/api/src/app.ts`) | Cold-start hydration | +| Org RBAC | `cio-org-id` header → `orgRoles[orgId]`; ADMIN=1, TUTOR=2, STUDENT=3 (`packages/utils/src/constants/roles.ts`) | Drives the role-aware tab bar | +| Course delivery | `/course/:courseId/{content,section,lesson,exercise,submission,mark,attendance,newsfeed,members,compliance,ai-tutor,presign}` | Covers nearly all of the learner and tutor scope | +| Question types | 16 keys incl. `VIDEO_RECORDING`, `FILE_UPLOAD`, `HOTSPOT`, `MATCHING` (`packages/question-types/src/question-type-keys.ts`) | Each needs a touch renderer; camera/mic permissions required | +| Video | HLS proxied from R2, authorized by an **HMAC cookie** `classroomio_hls_` **or** an org-role session (`apps/api/src/routes/hls/hls.ts`) | ⚠️ Native players can't carry per-asset cookies — needs a token-in-URL variant (see Technical Design) | +| Transcripts / media | `/transcripts`, `/media`, presigned uploads (`apps/api/src/routes/course/presign.ts`) | Presign is already the right shape for mobile uploads | +| Compliance | Deadlines, grace, renewals, waivers (`apps/api/src/routes/course/compliance.ts`; `prd/compliance-training-platform [DONE]`) | Data for the home surface exists; needs a learner-scoped aggregate | +| Certificates | `packages/certificates`, issuance + LMS certificates page | Wallet reads the same records | +| Community / newsfeed | `/community`, `/course/:id/newsfeed`, cohort newsfeed | Maps to the Community tab | +| Cohorts | `/cohort` with goals and deadlines | Surfaces as cohort cards + announcements | +| AI lesson tutor | `/course/:courseId/ai-tutor` with fair-use caps (`prd/ai-tutor-fair-use`) | Renders as a bottom sheet in the player | +| **Notifications** | ⚠️ **Not shipped.** `prd/notification-system` is todo (`PRD-TRACKER.md` rank 1); bell icon is a placeholder; only email exists | Hard dependency — see Decision 10 | +| **Offline** | ⚠️ None. No service worker, no sync queue | Built from scratch in the app | +| **Device tokens** | ⚠️ No table | New `device_token` table | +| Deployment modes | `PUBLIC_IS_SELFHOSTED` switches multi-org, licensing, billing (`FEATURE_AUDIT.md` §5) | App must handle both; see Decision 14 | +| Background jobs | BullMQ workers incl. a `notifications` worker (`apps/jobs`) | Push fan-out belongs here, not in the request path | + +## Product Goals + +1. A learner signs in (password, Google, or enterprise SSO), picks an org, and lands on a home screen that answers "what needs my attention today" in one glance. +2. A learner completes a full course on a phone: read lessons, watch video, take exercises, and get a certificate — without touching a browser. +3. A learner receives a push notification for an assignment, a deadline, a renewal, a grade, or a reply, and taps straight into the relevant screen. +4. A learner downloads a course, completes the lesson content on a plane, and sees their progress sync automatically on reconnect. +5. A learner can always answer "am I compliant, what expires next, where is my certificate" in two taps. +6. A tutor clears a grading queue, takes attendance, posts an announcement, and moderates a thread from a phone. +7. The web dashboard, self-hosted deployments, and existing API behavior are unchanged (zero regression). + +## Non-Goals (v1) + +- Course, lesson, or exercise **authoring**; media manager; AI course builder. +- Org administration: settings, team/roles, billing, SSO config, custom domains, API keys, widgets. +- **Any purchase flow** (see Decision 9) — no course checkout, plan upgrade, or AI token packs. +- **Offline exercises, quizzes, and submissions** (v2) — offline is read-only content plus queued completion. +- Live-class video conferencing inside the app (join links open the existing external provider). +- Direct messaging between users (ClassroomIO has no DM system today; community Q&A covers discussion). +- SCORM playback (`prd/scorm-support` is todo; not shipped on web either). +- Per-customer white-label App Store listings (enterprise follow-up, Decision 13). +- Tablet-optimized layouts, watch apps, widgets, offline video for public/unenrolled catalog courses. + +--- + +## Functional Requirements + +**Information architecture.** Five tabs, role-aware: learners get **Home · Learn · Compliance · Community · Profile**; ADMIN and TUTOR get **Teach** in place of Compliance. Notifications live behind the bell in the nav bar, and Certificates and Downloads are reached from Compliance and Profile. + +### 1. Onboarding and authentication — `auth.html` + +- **Sign-in**: email + password, "Continue with Google", and "Sign in with SSO" (email-domain discovery via `/sso`). Google and SSO open the system browser and return through the `classroomio://` deep link. +- **Self-hosted**: a "Use a custom server" affordance accepts an API base URL, validated before the credential step. +- **Org picker**: shown after sign-in when the account belongs to more than one org; each row shows the org logo, name, and role. Single-org accounts (including all self-hosted installs) skip it. +- **Biometric**: after the first successful sign-in the app offers Face ID / fingerprint for subsequent launches; declining falls back to the stored session, and a remote sign-out invalidates it. +- **States to build**: idle, invalid credentials, SSO-required-for-this-domain, offline (no network at launch → "you're offline, showing downloaded content" with a path to Downloads), and session-expired re-auth. +- Invitation and password-reset deep links open the app when installed and the web flow otherwise. + +### 2. Home — `home.html` + +The attention surface. Ordered by urgency, not by recency: + +1. **Compliance alert band** — only when something is overdue or inside its grace period; red for overdue, amber for due soon; tapping opens Compliance. +2. **Continue learning** — the single most recent in-progress lesson with a thumbnail, course name, position ("Lesson 4 of 12"), and a progress bar. +3. **Due soon** — deadline-ordered list of exercises and required courses with relative dates ("Due in 2 days"). +4. **Newly assigned** — courses assigned since the last open, with an enroll/start affordance. +5. **Cohort announcements** — latest newsfeed items from the learner's cohorts and courses. +6. **Recent feedback** — graded submissions with score and tutor comment preview. + +States: fully-caught-up empty state ("Nothing due — you're all caught up"), first-run empty state (no enrollments), and offline state (cached digest with an "Offline — last updated 2h ago" chip). + +### 3. Learn — `learn.html`, `course.html` + +- **My Learning**: segmented control for In progress / Not started / Completed; rows carry a progress ring, course type chip (Compliance / Cohort / Self-paced), and a download indicator. +- **Explore**: the org's public catalog, enroll-in-place for free/assigned courses; no paid checkout (Decision 9). +- **Cohorts**: cohort cards with goal progress and deadline. +- **Course detail** (`course.html`): header with progress, then the section/lesson outline. Every lesson row shows its state — complete, current, locked (with the reason: "Complete Lesson 3 first"), or downloaded. Compliance courses show a deadline banner. Tabs: Content, Newsfeed, Community, Certificate, and — for compliance courses — Compliance. +- Locked content is enforced by the API, not just hidden (`apps/dashboard/src/lib/features/course/utils/content-lock-utils.ts` is the web equivalent). + +### 4. Lesson player — `lesson.html` + +- **Video**: HLS playback with captions, playback speed, AirPlay/Chromecast, picture-in-picture, background audio, and lock-screen controls. Position is remembered per lesson and synced across devices. +- **Text, slides, and documents** rendered natively; multilingual lessons respect the learner's chosen language (`lesson-language.ts`). +- **Transcript** in a bottom sheet, tap-to-seek where timestamps exist. +- **AI Lesson Tutor** in a bottom sheet, scoped to the current lesson, showing the fair-use cap state when exhausted (`prd/ai-tutor-fair-use`). +- **Mark complete** advances to the next item; when offline the action is queued and the row shows a "will sync" indicator. +- States: online, downloaded/offline, locked, video-unavailable-offline, and tutor-cap-reached. + +### 5. Exercises — `exercise.html` + +- Touch renderers for all 16 question types in `packages/question-types`, including `MATCHING` (drag), `ORDERING` (drag), `HOTSPOT` (tap target), `FILE_UPLOAD` (files/photo library/document scan), and `VIDEO_RECORDING` (in-app camera). +- Answers autosave locally as the learner works so a call or app switch never loses progress. +- Submission uses the existing presigned upload path for media (`apps/api/src/routes/course/presign.ts`) with a visible upload progress state and retry. +- Result view: score, per-question correctness where allowed, rubric, tutor feedback, and resubmit when the exercise permits it. +- States: unattempted, in progress, submitted/awaiting grade, graded/passed, graded/failed with retake, and **connection-required** (an explicit blocking state, since exercises are online-only in v1). + +### 6. Compliance — `compliance.html` + +- Status header: Compliant / Due soon / Overdue / In grace, with the count of requirements behind it. +- Per-requirement rows: course, status, deadline or renewal date, grace-period note, waiver note where one applies, and last completion date. +- Renewal countdown for certifications approaching expiry. +- A "Proof" action jumping to the matching certificate. +- States: all-clear, overdue, in-grace, waived, and no-requirements. + +### 7. Certificates — `certificates.html` + +- Wallet list of issued certificates with course, issue date, certificate ID, and expiry when applicable. +- Detail view: rendered certificate, share sheet (PDF), save to device, and a verification QR/link. +- Downloaded certificates remain viewable offline. +- States: populated, empty ("Finish a course to earn your first certificate"), and expiring-soon. + +### 8. Community — `community.html` + +- Org and course Q&A: ask, answer, upvote, and follow threads; course and cohort newsfeed posts with comments. +- A "For you" filter for mentions, replies, and followed threads. +- Compose supports text and image attachments. +- Author-or-team edit/delete rules mirror the API guards (`apps/api/src/routes/community/middlewares/`). +- States: populated, empty, offline (read cached, compose disabled with an explanatory chip). + +### 9. Notifications — `notifications.html` + +- **Center**: grouped Today / Earlier, unread dots, deep-link on tap, mark-all-read. +- **Categories** (each independently toggleable, push and in-app): new assignment, deadline reminder, overdue, renewal due, new lesson/newsfeed post, exercise graded, community mention or reply, certificate issued, cohort goal approaching. +- **Preferences**: per-category switches plus quiet hours; changes write through to the same preference store the web uses (`prd/email-notification-preferences`). +- The first launch asks for the OS notification permission with an explanatory pre-prompt, not a cold system dialog. + +### 10. Downloads and offline — `downloads.html` + +- Download a whole course or an individual lesson; per-item progress and cancel. +- Storage manager: total used, per-course breakdown, delete individual or all. +- Settings: Wi-Fi-only downloads (default on), auto-download newly assigned required training (default off). +- **Sync queue** panel: pending completion events with their state (queued / syncing / synced / failed-retry), and a manual "Sync now". +- A persistent offline banner appears app-wide when the device is offline. +- Content that cannot work offline (exercises, live classes, AI tutor, community compose) is visibly marked rather than silently broken — the failure mode TalentLMS solved with an authoring-side compatibility check. + +### 11. Profile and settings — `profile.html` + +- Profile summary, learning stats, and organization switcher (re-themes the app). +- Links to Certificates, Downloads, Notification preferences. +- App settings: language, theme (system/light/dark), video quality, biometric lock toggle. +- Account: change password (web link), sign out, delete account (web link). + +### 12. Tutor mode — `tutor-grading.html`, `tutor-attendance.html`, `tutor-course.html` + +Visible only when `orgRoles[activeOrg]` is ADMIN or TUTOR. Staff get **Teach** in place of the learner Compliance tab; their own compliance stays reachable from the Home alert band and Profile. + +- **Grading queue**: submissions awaiting grading across courses, oldest first, with course/exercise/learner and waiting time. Review shows the learner's answers (including video/file submissions), the rubric, score entry, written or recorded feedback, and approve / request-resubmission. +- **Attendance**: roster for a live-class session with present/absent/late toggles, bulk actions, and a code or QR check-in. +- **Course snapshot**: enrollment, completion rate, overdue count; post an announcement; moderate community/newsfeed posts (hide, delete, mark answered). +- Explicitly absent: authoring, settings, people management beyond invite, analytics configuration. + +### 13. Cross-cutting requirements + +- **Accessibility**: full screen-reader labels, Dynamic Type, minimum 44 pt touch targets, and no color-only status encoding (every status carries an icon and text). Canvas advertises VPAT 2.2 conformance; we should not ship below that bar. +- **Localization**: the app reads the same translation catalog the dashboard uses (`apps/dashboard/src/lib/utils/translations/`), covering the 10+ supported languages; no hardcoded strings. +- **Frontline mode**: large-target layout, low-bandwidth media defaults, and QR-code course launch, aimed at shared or shop-floor devices. +- **Security**: session in `expo-secure-store`, biometric re-lock, no analytics of lesson content, and remote sign-out honored on next foreground. + +--- + +## Technical Design + +### Workspace + +``` +apps/mobile/ # @cio/mobile — Expo (React Native) + app/ # expo-router routes, grouped by tab + src/features// # mirrors the dashboard's feature folders + api/ # typed calls via @cio/api/rpc-types + components/ + utils/types.ts # request/response types inferred from the API + src/lib/{auth,offline,push,i18n}/ +``` + +`apps/mobile` joins the existing `apps/*` workspace glob (`pnpm-workspace.yaml`). It depends on `@cio/utils` (validation, constants, roles) and `@cio/api` (types only). It must **not** depend on `@cio/ui` (Svelte) or `@cio/db`. + +### Data model additions (`packages/db/src/schema.ts`) + +``` +device_token + id uuid PK · profileId FK(profile, cascade) · token varchar NOT NULL + platform DEVICE_PLATFORM ('ios' | 'android') + provider PUSH_PROVIDER ('expo') -- room for direct FCM/APNs later + appVersion varchar · deviceName varchar · locale varchar + lastSeenAt timestamptz · disabledAt timestamptz nullable + createdAt / updatedAt + unique(token) · index(profileId) · index(profileId, disabledAt) + +notification_preference -- extends prd/notification-system + id uuid PK · profileId FK(profile, cascade) + category NOTIFICATION_CATEGORY -- reuses the notification type enum + push boolean default true · inApp boolean default true · email boolean default true + unique(profileId, category) + +profile_quiet_hours + profileId FK(profile) PK · startMinute smallint · endMinute smallint · timezone varchar +``` + +Notes, per repo rules: no relational IDs inside `jsonb`; nothing computed is stored. Download state, sync queue, and playback position live **on the device** (SQLite), not in Postgres — the server keeps only the authoritative completion records it already has. Last-read playback position reuses existing lesson progress rather than a new column. + +### Video: token-authenticated HLS + +Today `/hls/:assetId/*` authorizes on an HMAC cookie `classroomio_hls_` **or** an org-role session (`apps/api/src/routes/hls/hls.ts`). A native player (AVPlayer / ExoPlayer) fetches manifests and segments outside the app's HTTP client, so neither mechanism is reliable on mobile. + +``` +GET /hls/:assetId/playback-token [authMiddleware + asset org membership] + → { url: "/hls//master.m3u8?t=", expiresIn: 3600 } + +hlsRouter authorization order becomes: + 1. query param `t` → verifyHlsToken (NEW — same HMAC, same claims) + 2. cookie → verifyHlsToken (unchanged, web) + 3. session orgRoles → membership check (unchanged) +``` + +The token is the existing HMAC payload; only the transport is new, so web behavior is untouched. Offline downloads fetch the segment set once and store it locally; expiry is enforced at download time, not playback time, so a downloaded lesson keeps working on a plane. + +### Push delivery + +Push rides on `prd/notification-system` — mobile adds a transport, not a second notification pipeline. + +``` +notify(...) # existing service (notification-system PRD) + ├─ INSERT notification # in-app center + ├─ enqueue email # existing + └─ enqueue push (NEW) + └─ apps/jobs notifications worker: + tokens = active device_token rows for profileId + skip if preference.push = false for category + skip / defer if inside quiet hours + chunk ≤100 → Expo Push API (expo-server-sdk) + on DeviceNotRegistered → set device_token.disabledAt +``` + +Payload carries `{ type, entityId, deepLink }` so the app can route without a fetch. Expo Push abstracts APNs and FCM behind one endpoint; the `provider` column leaves room to move to direct FCM/APNs if enterprise requirements demand it. + +### Offline architecture + +| Concern | Approach | +| --- | --- | +| Content cache | SQLite (`expo-sqlite`) for lesson/section/course metadata; file system for video segments, audio, PDFs, images | +| Download unit | Course or lesson; queued, resumable, cancellable; Wi-Fi-only by default | +| Write queue | Append-only local table of intents (`lesson.complete`, `playback.position`), each with an idempotency key | +| Sync | On reconnect and on foreground: replay queue oldest-first; `409`/duplicate responses are treated as success | +| Conflict rule | Completion is monotonic — once complete, always complete; the server value wins for everything else | +| Eviction | Manual delete only in v1 (no silent eviction of content a learner deliberately downloaded) | + +Because v1 queues only completion and playback position — never grades or submissions — there is no merge conflict class that can corrupt an assessment record. That is exactly why offline exercises are deferred. + +### API additions + +All additions follow the repo layering (validation → queries → services → routes) and mount under existing root segments. + +| Method | Path | Auth | Purpose | +| --- | --- | --- | --- | +| GET | `/account/home-digest` | auth + org | One call for the Home screen: continue-learning, due-soon, newly-assigned, announcements, feedback, compliance summary | +| GET | `/account/compliance` | auth + org | Learner-scoped compliance across courses (today's data is per-course) | +| POST | `/account/device-tokens` | auth | Register/refresh a push token | +| DELETE | `/account/device-tokens/:token` | auth | Deregister on sign-out | +| GET | `/account/notification-preferences` | auth | Category matrix + quiet hours | +| PUT | `/account/notification-preferences` | auth | Update | +| GET | `/course/:courseId/offline-manifest` | course member | Everything needed to download a course in one call: lessons, media URLs, sizes, checksums | +| POST | `/course/:courseId/sync` | course member | Batch-replay queued completion intents idempotently | +| GET | `/hls/:assetId/playback-token` | auth + asset org | Token-authenticated playback (above) | +| GET | `/account/grading-queue` | org team member | Tutor: submissions awaiting grading across courses | + +`/account/home-digest` exists to avoid the 6–8 round trips a cold mobile launch would otherwise need on a slow connection; it is a read-only composition of existing services, not new business logic. + +### Client plan + +- **Auth**: `@better-auth/expo` client with `scheme: 'classroomio'` and `expo-secure-store`; server adds the `expo()` plugin and the scheme to `trustedOrigins` in `packages/db/src/auth.ts`. `orgRoles` already arrives on the session via `customSession`, so the role-aware tab bar needs no extra request. +- **Types**: every request/response type is inferred from `@cio/api/rpc-types` into `features//utils/types.ts` — the same rule the dashboard follows. No hand-maintained mobile DTOs, and no importing from `@cio/db`. +- **Validation**: reuse the Zod schemas in `packages/utils/src/validation/`. +- **State**: TanStack Query with a SQLite-backed persister so cached screens render instantly offline. +- **i18n**: load the dashboard translation catalog; no literal user-facing strings in components. + +### Build verification + +```bash +export PATH="$HOME/.nvm/versions/node/v20.19.3/bin:$PATH" +pnpm --filter @cio/utils build && pnpm --filter @cio/db build && pnpm --filter @cio/api build +pnpm --filter @cio/mobile typecheck && pnpm --filter @cio/mobile test +pnpm --filter @cio/dashboard^... build && pnpm --filter @cio/dashboard build # regression +pnpm format:check +``` + +--- + +## Implementation Order + +1. **Unblock notifications.** Ship `prd/notification-system` (table, `notify()`, in-app center). Mobile push has no foundation without it. +2. **Backend for mobile.** `device_token` + preference tables, push transport in the `apps/jobs` notifications worker, `/account/home-digest`, `/account/compliance`, HLS playback token, offline manifest, sync endpoint. +3. **App skeleton.** `apps/mobile` Expo project, auth (password → Google → SSO), org picker, role-aware tab bar, theming from org branding, i18n. +4. **Consumption path.** My Learning → course detail → lesson player (video, text, transcript) → mark complete → certificate. This is the first genuinely useful build. +5. **Assessment path.** All 16 question types, autosave, presigned media upload, results and resubmission. +6. **Compliance and certificates.** Home compliance band, compliance screen, certificate wallet with share/verify. +7. **Push end to end.** Token registration, categories, quiet hours, deep links from every notification type. +8. **Offline.** Download manager, storage settings, write queue, sync-on-reconnect, offline banners and blocked-state messaging. +9. **Engagement.** Community, newsfeed, cohorts, AI lesson tutor sheet. +10. **Tutor mode.** Grading queue, attendance, announcements, moderation. +11. **Store readiness.** Accessibility audit, App Store / Play listings, privacy nutrition labels, data-safety form, beta via TestFlight and Play internal testing. + +Releases: 3–6 = Release 1 (learner core), 7–9 = Release 2 (push, offline, engagement), 10 = Release 3 (tutor), then white-label and microlearning. + +## Acceptance Criteria + +1. A learner can sign in with email/password, Google, and enterprise SSO; multi-org accounts choose an org and every request carries `cio-org-id`. +2. A self-hosted user can point the app at a custom API base URL and sign in; the single-org install skips the org picker. +3. Home shows compliance alerts, continue-learning, due-soon, newly-assigned, announcements, and feedback in one request, and renders a cached version offline with an explicit staleness chip. +4. A learner can complete an entire course on a phone — lessons, video, all 16 exercise question types, and certificate issuance — with progress matching the web dashboard exactly. +5. Video plays via HLS with captions, background audio, lock-screen controls, and PiP; the playback token authorizes a native player, and the existing cookie and session paths still work on web (regression check). +6. Push notifications arrive for every category in §9, respect per-category preferences and quiet hours, and deep-link to the right screen; disabling a category stops both push and in-app for that category. +7. A learner downloads a course, airplane-mode completes its lesson content, and every completion appears on the web dashboard after reconnect, with no duplicates on retry. +8. Exercises, AI tutor, and community compose show explicit "connection required" states offline rather than failing silently. +9. Compliance status, renewal dates, grace periods, and waivers match the web compliance view for the same learner. +10. A tutor sees the Teach tab only with ADMIN or TUTOR in the active org, and can grade a submission, take attendance, post an announcement, and moderate a post. +11. A student-role user has no path to authoring, org settings, billing, or admin data anywhere in the app; the API rejects such calls independently of the UI. +12. No purchase, upgrade, or checkout affordance exists anywhere in the app on any storefront. +13. Accessibility: screen-reader labels on all interactive elements, Dynamic Type honored, 44 pt minimum targets, no color-only status. +14. All user-facing copy comes from translation keys; the app runs in every language the dashboard supports. +15. Zero regression: dashboard, API, and self-hosted deployments behave exactly as before; all builds and `pnpm format:check` pass. + +## Risks and Mitigations + +- **Notification-system dependency slips.** Mobile's headline retention feature has no foundation. *Mitigation*: sequence it first (Implementation Order 1); if it slips, Release 1 ships without push and the app remains useful for consumption, but do not build a parallel notification path. +- **HLS auth on native players.** Cookie-based gating silently 403s in AVPlayer/ExoPlayer, which is easy to misdiagnose as a media bug. *Mitigation*: the playback-token endpoint is scheduled in Implementation Order 2, before any player work, and the web cookie path is left untouched. +- **Offline scope creep into assessments.** Offline quizzes bring conflict resolution, integrity, and grading-race problems. *Mitigation*: v1 queues only monotonic completion and playback position (Decision 11); revisit with its own PRD. +- **App Store rejection under Guideline 3.1.1.** Any in-app path to buying a course or plan triggers IAP obligations outside the US storefront. *Mitigation*: no purchase affordance at all (Decision 9), enforced by acceptance criterion 12; revisit only with a deliberate IAP design. +- **16 question types is a large surface.** Drag, hotspot, camera, and file types each carry native complexity. *Mitigation*: ship the legacy trio (`RADIO`, `CHECKBOX`, `TEXTAREA`) plus the common objective types first, gate uncommon types behind a "open in browser" fallback until their renderer lands, and never let an unsupported type silently render blank. +- **Storage exhaustion on low-end devices.** Downloaded video can fill a cheap Android phone. *Mitigation*: pre-download size estimate, a visible storage manager, Wi-Fi-only default, and a device-space check before each download. +- **Two clients drifting.** Mobile and web can diverge into different business rules. *Mitigation*: shared Zod validation, types inferred from the live API, and no mobile-only business logic — new capability lands in the API and both clients consume it. +- **Push cost and deliverability at scale.** Expo Push is a third-party hop in the delivery chain. *Mitigation*: the `provider` column and a transport-shaped worker make a move to direct FCM/APNs a swap rather than a rewrite. +- **Self-hosted push.** Self-hosters cannot use our Expo credentials. *Mitigation*: push degrades gracefully to in-app plus email when unconfigured — the same fallback pattern `prd/notification-system` uses for Trigger.dev. diff --git a/prototypes/mobile-app/app-theme.css b/prototypes/mobile-app/app-theme.css new file mode 100644 index 00000000..64586c6e --- /dev/null +++ b/prototypes/mobile-app/app-theme.css @@ -0,0 +1,1610 @@ +/* + * app-theme.css — shared theme for the ClassroomIO mobile-app prototypes. + * + * Tokens mirror packages/ui/src/index.css (shadcn-svelte) exactly: OKLCH + * palette, Geist, --radius: 0.625rem, and the Button / Badge / Item / Progress + * recipes from @cio/ui. Dark mode uses the same `.dark` class as the app. + * + * On top of those tokens this file adds the mobile shell primitives the + * dashboard has no equivalent for: a 390x844 device frame, status bar, + * nav bar, bottom tab bar, list rows, bottom sheets, banners and toasts. + * + * Semantic colour follows @cio/ui, not filled surfaces: + * Badge — rounded-full, 10% tint + coloured text (learners-table) + * Alert — destructive is card + red text; warning/info use amber-50 / sky-50 + * Rows — default card; status lives on a chip, never a painted row + */ + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.488 0.243 264.376); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-soft: oklch(0.577 0.245 27.325 / 0.1); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --radius: 0.625rem; + + /* Semantic states from @cio/ui Badge variants (emerald / amber), 10% tints */ + --success: oklch(0.596 0.145 163.225); + --success-foreground: #fff; + --success-soft: oklch(0.596 0.145 163.225 / 0.1); + --warning: oklch(0.666 0.179 58.318); + --warning-foreground: #fff; + --warning-soft: oklch(0.666 0.179 58.318 / 0.1); + + /* Alert.warning / Alert.information surfaces from @cio/ui base/alert */ + --alert-warning-bg: oklch(0.987 0.022 95.277); + --alert-warning-border: oklch(0.924 0.12 95.746); + --alert-warning-fg: oklch(0.414 0.112 45.904); + --alert-info-bg: oklch(0.977 0.013 236.62); + --alert-info-border: oklch(0.901 0.058 230.902); + --alert-info-fg: oklch(0.391 0.09 240.876); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + + /* device chrome */ + --device-bezel: oklch(0.205 0 0); + --page-bg: oklch(0.97 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.424 0.199 265.638); + --primary-foreground: oklch(0.97 0.014 254.604); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-soft: oklch(0.704 0.191 22.216 / 0.12); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + + --success: oklch(0.696 0.17 162.48); + --success-foreground: oklch(0.262 0.051 172.552); + --success-soft: oklch(0.696 0.17 162.48 / 0.12); + --warning: oklch(0.769 0.188 70.08); + --warning-foreground: oklch(0.279 0.077 45.635); + --warning-soft: oklch(0.769 0.188 70.08 / 0.12); + + --alert-warning-bg: oklch(0.279 0.077 45.635); + --alert-warning-border: oklch(0.414 0.112 45.904); + --alert-warning-fg: oklch(0.987 0.022 95.277); + --alert-info-bg: oklch(0.293 0.066 243.157); + --alert-info-border: oklch(0.391 0.09 240.876); + --alert-info-fg: oklch(0.977 0.013 236.62); + + --device-bezel: oklch(0.269 0 0); + --page-bg: oklch(0.115 0 0); +} + +/* ============ base ============ */ +* { + box-sizing: border-box; + border-color: var(--border); +} +html, +body { + margin: 0; + padding: 0; +} +body { + font-family: + 'Geist', + ui-sans-serif, + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + background: var(--page-bg); + color: var(--foreground); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +a { + color: inherit; + text-decoration: none; +} +button { + font: inherit; +} +svg { + flex-shrink: 0; +} +.tnum { + font-variant-numeric: tabular-nums; +} +.text-muted { + color: var(--muted-foreground); +} +.text-primary { + color: var(--primary); +} +.text-success { + color: var(--success); +} +.text-warning { + color: var(--warning); +} +.text-danger { + color: var(--destructive); +} + +/* ============ prototype page chrome (outside the device) ============ */ +.proto-head { + max-width: 1280px; + margin: 0 auto; + padding: 40px 24px 8px; +} +.proto-head .back { + font-size: 13px; + color: var(--muted-foreground); + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 14px; +} +.proto-head .back:hover { + color: var(--foreground); +} +.proto-head .eyebrow { + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--primary); +} +.proto-head h1 { + font-size: 28px; + font-weight: 600; + letter-spacing: -0.03em; + margin: 6px 0 8px; +} +.proto-head p { + color: var(--muted-foreground); + font-size: 14.5px; + max-width: 76ch; + margin: 0 0 6px; + line-height: 1.6; +} +.proto-head code { + background: var(--muted); + color: var(--foreground); + padding: 1px 6px; + border-radius: 4px; + font-size: 12.5px; +} +.proto-links { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} +.proto-links a { + font-size: 12.5px; + font-weight: 500; + padding: 5px 11px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + color: var(--muted-foreground); +} +.proto-links a:hover { + color: var(--foreground); + border-color: var(--ring); +} + +.stage { + max-width: 1280px; + margin: 0 auto; + padding: 26px 24px 80px; + display: flex; + flex-wrap: wrap; + gap: 34px; + align-items: flex-start; +} +.device { + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} +.device figcaption { + max-width: 390px; + text-align: center; + font-size: 12.5px; + color: var(--muted-foreground); + line-height: 1.55; +} +.device figcaption b { + display: block; + color: var(--foreground); + font-weight: 600; + font-size: 13px; + margin-bottom: 2px; +} + +/* ============ device frame ============ */ +.phone { + width: 390px; + height: 844px; + border-radius: 46px; + background: var(--device-bezel); + padding: 11px; + box-shadow: var(--shadow-lg); + flex-shrink: 0; +} +.phone-inner { + position: relative; + width: 100%; + height: 100%; + border-radius: 36px; + overflow: hidden; + background: var(--background); + color: var(--foreground); + display: flex; + flex-direction: column; +} + +/* status bar */ +.statusbar { + height: 44px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 26px; + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + position: relative; + z-index: 5; + background: var(--background); +} +.statusbar .icons { + display: flex; + align-items: center; + gap: 5px; +} +.statusbar svg { + width: 15px; + height: 15px; + stroke-width: 2.2; +} +.notch { + position: absolute; + top: 9px; + left: 50%; + transform: translateX(-50%); + width: 112px; + height: 27px; + border-radius: 999px; + background: var(--device-bezel); + z-index: 6; +} + +/* nav bar (in-app header) */ +.navbar { + flex-shrink: 0; + min-height: 48px; + display: flex; + align-items: center; + gap: 10px; + padding: 4px 16px 10px; + background: var(--background); + position: relative; + z-index: 4; +} +.navbar.bordered { + border-bottom: 1px solid var(--border); + padding-bottom: 10px; +} +.navbar .title { + font-size: 19px; + font-weight: 600; + letter-spacing: -0.02em; +} +.navbar .title-sm { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.navbar .sub { + font-size: 11.5px; + color: var(--muted-foreground); +} +.navbar .spacer { + margin-left: auto; +} +.navbar .actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; +} + +/* screen scroll area */ +.screen { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 0 16px 24px; + scrollbar-width: none; +} +.screen::-webkit-scrollbar { + display: none; +} +.screen.flush { + padding-left: 0; + padding-right: 0; +} +.screen.centered { + display: flex; + flex-direction: column; + justify-content: center; + padding-bottom: 40px; +} + +/* bottom tab bar */ +.tabbar { + flex-shrink: 0; + height: 78px; + padding: 8px 6px 22px; + border-top: 1px solid var(--border); + background: color-mix(in oklab, var(--background) 92%, transparent); + backdrop-filter: blur(12px); + display: flex; + align-items: flex-start; + justify-content: space-around; +} +.tab { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + color: var(--muted-foreground); + font-size: 10.5px; + font-weight: 500; + padding-top: 4px; + position: relative; +} +.tab svg { + width: 22px; + height: 22px; + stroke-width: 1.9; +} +.tab.on { + color: var(--primary); +} +.tab.on svg { + stroke-width: 2.3; +} +.tab .dot { + position: absolute; + top: 2px; + right: 50%; + margin-right: -16px; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--primary); + border: 2px solid var(--background); +} +.tab .count { + position: absolute; + top: 0; + right: 50%; + margin-right: -22px; + min-width: 17px; + height: 17px; + padding: 0 4px; + border-radius: 999px; + background: var(--primary); + color: var(--primary-foreground); + font-size: 10px; + font-weight: 700; + display: grid; + place-items: center; + border: 2px solid var(--background); +} + +/* home indicator */ +.home-bar { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + width: 134px; + height: 5px; + border-radius: 999px; + background: var(--foreground); + opacity: 0.28; + z-index: 7; + pointer-events: none; +} + +/* ============ typography inside the app ============ */ +.h-screen-title { + font-size: 24px; + font-weight: 600; + letter-spacing: -0.03em; + margin: 2px 0 2px; +} +.sec-head { + display: flex; + align-items: center; + gap: 8px; + margin: 22px 0 10px; +} +.sec-head:first-child { + margin-top: 10px; +} +.sec-head h2 { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.005em; + margin: 0; +} +.sec-head .count { + font-size: 11px; + font-weight: 600; + color: var(--muted-foreground); + background: var(--muted); + padding: 1px 7px; + border-radius: 999px; +} +.sec-head .more { + margin-left: auto; + font-size: 12.5px; + font-weight: 500; + color: var(--primary); +} +.eyebrow { + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted-foreground); +} + +/* ============ buttons (mirrors @cio/ui base/button) ============ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: var(--radius-md); + font-size: 14px; + font-weight: 500; + white-space: nowrap; + height: 38px; + padding: 0 16px; + border: 1px solid transparent; + cursor: pointer; + box-shadow: var(--shadow-2xs); + user-select: none; + transition: + background 0.12s, + border-color 0.12s, + color 0.12s; +} +.btn svg { + width: 16px; + height: 16px; + stroke-width: 2; +} +.btn-primary { + background: var(--primary); + color: var(--primary-foreground); +} +.btn-outline { + background: var(--background); + border-color: var(--input); + color: var(--foreground); +} +.btn-secondary { + background: var(--secondary); + color: var(--secondary-foreground); +} +.btn-ghost { + background: transparent; + box-shadow: none; + color: var(--foreground); +} +.btn-destructive { + background: var(--destructive); + color: #fff; +} +.btn-success { + background: var(--success); + color: var(--success-foreground); +} +.btn-block { + width: 100%; +} +.btn-lg { + height: 46px; + font-size: 15px; + border-radius: var(--radius-lg); +} +.btn-sm { + height: 32px; + padding: 0 11px; + font-size: 12.5px; + gap: 6px; +} +.btn-sm svg { + width: 14px; + height: 14px; +} +.btn[disabled], +.btn.disabled { + opacity: 0.45; + pointer-events: none; +} + +/* icon button — @cio/ui rule: icon-only buttons use the secondary variant */ +.icon-btn { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + border: none; + background: var(--secondary); + color: var(--secondary-foreground); + display: grid; + place-items: center; + cursor: pointer; + position: relative; + flex-shrink: 0; +} +.icon-btn svg { + width: 17px; + height: 17px; + stroke-width: 2; +} +.icon-btn.plain { + background: transparent; + color: var(--foreground); +} +.icon-btn .ping { + position: absolute; + top: 6px; + right: 6px; + width: 8px; + height: 8px; + border-radius: 999px; + background: var(--primary); + border: 2px solid var(--background); +} + +/* ============ badges (mirrors dashboard usage of @cio/ui Badge) ============ + * Filled success/warning/destructive exist in the component, but screens use + * the 10% tint + coloured-text recipe from the compliance learners table. + */ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + border-radius: 999px; + border: 1px solid transparent; + padding: 2px 8px; + font-size: 11.5px; + font-weight: 500; + white-space: nowrap; +} +.badge svg { + width: 11px; + height: 11px; + stroke-width: 2.4; +} +.badge-default { + background: var(--primary); + color: var(--primary-foreground); +} +.badge-secondary { + background: var(--secondary); + color: var(--secondary-foreground); +} +.badge-outline { + border-color: var(--border); + color: var(--muted-foreground); +} +.badge-success, +.badge-success-soft { + background: var(--success-soft); + color: var(--success); +} +.badge-warning, +.badge-warning-soft { + background: var(--warning-soft); + color: var(--warning); +} +.badge-danger, +.badge-danger-soft { + background: var(--destructive-soft); + color: var(--destructive); +} +.badge-primary-soft { + background: color-mix(in oklab, var(--primary) 10%, transparent); + color: var(--primary); +} +.badge-muted { + background: var(--muted); + color: var(--muted-foreground); +} + +/* ============ cards & rows ============ */ +.card { + background: var(--card); + color: var(--card-foreground); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xs); +} +.card-pad { + padding: 14px; +} +.stack { + display: flex; + flex-direction: column; + gap: 8px; +} +.stack-sm { + gap: 6px; +} +.row { + display: flex; + align-items: center; + gap: 10px; +} +.row-top { + align-items: flex-start; +} +.between { + justify-content: space-between; +} +.grow { + flex: 1; + min-width: 0; +} + +/* list row (mirrors @cio/ui base/item, tuned for touch) */ +.lrow { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: var(--radius-lg); + background: var(--card); + border: 1px solid var(--border); + min-height: 56px; +} +.lrow.flat { + border: none; + background: transparent; + padding: 11px 0; + border-radius: 0; + border-bottom: 1px solid var(--border); +} +.lrow.flat:last-child { + border-bottom: none; +} +.lrow.done .ltitle { + color: var(--muted-foreground); +} +.lrow.locked { + opacity: 0.62; +} +.lrow.current { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--primary) 10%, transparent); +} +.ltitle { + font-size: 14px; + font-weight: 500; + letter-spacing: -0.01em; + line-height: 1.35; +} +.lsub { + font-size: 12px; + color: var(--muted-foreground); + line-height: 1.45; + margin-top: 2px; +} +.lmeta { + font-size: 11.5px; + color: var(--muted-foreground); + display: flex; + align-items: center; + gap: 5px; + margin-top: 4px; + flex-wrap: wrap; +} +.lmeta svg { + width: 12px; + height: 12px; + stroke-width: 2; +} +.lmeta .sep { + opacity: 0.5; +} + +/* leading media */ +.media { + width: 38px; + height: 38px; + border-radius: var(--radius-md); + background: var(--muted); + border: 1px solid var(--border); + display: grid; + place-items: center; + color: var(--muted-foreground); + flex-shrink: 0; +} +.media svg { + width: 17px; + height: 17px; + stroke-width: 2; +} +.media.is-done { + background: var(--success-soft); + border-color: transparent; + color: var(--success); +} +.media.is-current { + background: color-mix(in oklab, var(--primary) 10%, transparent); + border-color: transparent; + color: var(--primary); +} +.media.is-danger { + background: var(--destructive-soft); + border-color: transparent; + color: var(--destructive); +} +.media.is-warning { + background: var(--warning-soft); + border-color: transparent; + color: var(--warning); +} +.media.lg { + width: 46px; + height: 46px; + border-radius: var(--radius-lg); +} +.media.sq { + width: 56px; + height: 42px; +} +.thumb { + width: 78px; + height: 56px; + border-radius: var(--radius-md); + flex-shrink: 0; + position: relative; + overflow: hidden; + display: grid; + place-items: center; + color: #fff; +} +.thumb svg { + width: 20px; + height: 20px; + stroke-width: 2; + opacity: 0.95; +} +.avatar { + width: 34px; + height: 34px; + border-radius: 999px; + background: var(--secondary); + color: var(--secondary-foreground); + display: grid; + place-items: center; + font-weight: 600; + font-size: 12.5px; + flex-shrink: 0; +} +.avatar.sm { + width: 26px; + height: 26px; + font-size: 10.5px; +} +.avatar.lg { + width: 56px; + height: 56px; + font-size: 19px; +} +.avatar.brand { + background: var(--primary); + color: var(--primary-foreground); + border-radius: var(--radius-md); +} +.chev { + color: var(--muted-foreground); + flex-shrink: 0; +} +.chev svg { + width: 17px; + height: 17px; + stroke-width: 2; + display: block; +} + +/* ============ progress ============ */ +.progress { + height: 6px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in oklab, var(--primary) 18%, transparent); + flex: 1; +} +.progress > span { + display: block; + height: 100%; + border-radius: 999px; + background: var(--primary); +} +.progress.success > span { + background: var(--primary); +} +.progress.warning > span { + background: var(--primary); +} +.progress.danger > span { + background: var(--primary); +} +.progress.tall { + height: 8px; +} + +.ring { + position: relative; + width: 42px; + height: 42px; + flex-shrink: 0; +} +.ring svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); + display: block; +} +.ring .track { + stroke: var(--border); +} +.ring .arc { + stroke: var(--primary); +} +.ring .arc.primary { + stroke: var(--primary); +} +.ring .arc.warning { + stroke: var(--warning); +} +.ring .lbl { + position: absolute; + inset: 0; + display: grid; + place-items: center; + font-size: 10.5px; + font-weight: 600; +} +.ring.lg { + width: 78px; + height: 78px; +} +.ring.lg .lbl { + font-size: 17px; + letter-spacing: -0.02em; +} + +/* segment bar (multi-step progress) */ +.segs { + display: flex; + gap: 3px; + flex: 1; +} +.seg { + flex: 1; + height: 5px; + border-radius: 999px; + background: color-mix(in oklab, var(--primary) 15%, transparent); +} +.seg.done { + background: var(--primary); +} +.seg.now { + background: var(--primary); +} + +/* ============ banners, alerts, callouts — @cio/ui base/alert ============ */ +.banner { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px 13px; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + font-size: 13px; + line-height: 1.45; + background: var(--card); + color: var(--card-foreground); +} +.banner svg { + width: 17px; + height: 17px; + stroke-width: 2.1; + margin-top: 1px; +} +.banner .bt { + font-weight: 600; + font-size: 13.5px; + letter-spacing: -0.01em; +} +.banner .bd { + font-size: 12.5px; + color: var(--muted-foreground); + margin-top: 2px; +} +.banner-danger { + color: var(--destructive); +} +.banner-danger .bd { + color: color-mix(in oklab, var(--destructive) 80%, transparent); +} +.banner-warning { + background: var(--alert-warning-bg); + border-color: var(--alert-warning-border); + color: var(--alert-warning-fg); +} +.banner-warning .bd { + color: var(--alert-warning-fg); + opacity: 0.82; +} +.banner-success { + background: var(--card); + border-color: var(--border); + color: var(--card-foreground); +} +.banner-info { + background: var(--alert-info-bg); + border-color: var(--alert-info-border); + color: var(--alert-info-fg); +} +.banner-info .bd { + color: var(--alert-info-fg); + opacity: 0.82; +} +.banner-muted { + background: var(--muted); + border-color: var(--border); + color: var(--muted-foreground); +} +.banner-muted .bd { + color: var(--muted-foreground); +} + +/* thin app-wide offline strip — muted, not a filled amber bar */ +.offline-strip { + flex-shrink: 0; + background: var(--muted); + color: var(--muted-foreground); + font-size: 11.5px; + font-weight: 500; + padding: 5px 16px; + display: flex; + align-items: center; + gap: 6px; + justify-content: center; + border-bottom: 1px solid var(--border); +} +.offline-strip svg { + width: 13px; + height: 13px; + stroke-width: 2.4; +} + +/* ============ segmented control & chips ============ */ +.segmented { + display: flex; + padding: 3px; + background: var(--muted); + border-radius: var(--radius-lg); + gap: 2px; +} +.segmented button, +.segmented a { + flex: 1; + border: none; + background: transparent; + border-radius: var(--radius-md); + padding: 6px 8px; + font-size: 12.5px; + font-weight: 500; + color: var(--muted-foreground); + cursor: pointer; + text-align: center; +} +.segmented .on { + background: var(--background); + color: var(--foreground); + box-shadow: var(--shadow-2xs); +} + +.chips { + display: flex; + gap: 7px; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 2px; +} +.chips::-webkit-scrollbar { + display: none; +} +.chip { + flex-shrink: 0; + padding: 5px 12px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + font-size: 12.5px; + font-weight: 500; + color: var(--muted-foreground); + cursor: pointer; +} +.chip.on { + background: var(--foreground); + color: var(--background); + border-color: var(--foreground); +} + +/* in-screen tabs */ +.tabs { + display: flex; + gap: 16px; + border-bottom: 1px solid var(--border); + padding: 0 16px; + overflow-x: auto; + scrollbar-width: none; + flex-shrink: 0; + background: var(--background); +} +.tabs::-webkit-scrollbar { + display: none; +} +.tabs a, +.tabs button { + border: none; + background: none; + padding: 9px 0 8px; + font-size: 13px; + font-weight: 500; + color: var(--muted-foreground); + border-bottom: 2px solid transparent; + white-space: nowrap; + cursor: pointer; +} +.tabs .on { + color: var(--foreground); + border-bottom-color: var(--primary); + font-weight: 600; +} + +/* ============ forms ============ */ +.input, +.textarea, +.select { + width: 100%; + font: inherit; + font-size: 14.5px; + color: var(--foreground); + background: var(--background); + border: 1px solid var(--input); + border-radius: var(--radius-md); + padding: 11px 12px; + outline: none; + box-shadow: var(--shadow-2xs); +} +.input::placeholder, +.textarea::placeholder { + color: var(--muted-foreground); +} +.textarea { + resize: none; + min-height: 76px; + line-height: 1.5; +} +.label { + display: block; + font-size: 12.5px; + font-weight: 500; + margin-bottom: 6px; +} +.field { + margin-bottom: 14px; +} +.hint { + font-size: 11.5px; + color: var(--muted-foreground); + margin-top: 5px; + line-height: 1.45; +} +.search { + display: flex; + align-items: center; + gap: 8px; + background: var(--muted); + border-radius: var(--radius-lg); + padding: 9px 12px; + color: var(--muted-foreground); + font-size: 14px; +} +.search svg { + width: 16px; + height: 16px; + stroke-width: 2; +} + +/* iOS-style switch */ +.switch { + position: relative; + width: 44px; + height: 26px; + flex-shrink: 0; + display: inline-block; + cursor: pointer; +} +.switch input { + position: absolute; + opacity: 0; + inset: 0; + margin: 0; + cursor: pointer; + z-index: 1; +} +.switch .track { + position: absolute; + inset: 0; + background: var(--input); + border-radius: 999px; + transition: background 0.16s ease-out; +} +.switch .knob { + position: absolute; + top: 2px; + left: 2px; + width: 22px; + height: 22px; + border-radius: 999px; + background: #fff; + box-shadow: var(--shadow-sm); + transition: transform 0.16s ease-out; +} +.switch input:checked + .track { + background: var(--primary); +} +.switch input:checked ~ .knob { + transform: translateX(18px); +} + +/* radio / checkbox rows for exercises */ +.opt { + display: flex; + align-items: center; + gap: 11px; + padding: 13px 14px; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + background: var(--card); + font-size: 14px; + cursor: pointer; +} +.opt .mark { + width: 21px; + height: 21px; + border-radius: 999px; + border: 2px solid var(--border); + display: grid; + place-items: center; + flex-shrink: 0; + color: transparent; +} +.opt .mark svg { + width: 12px; + height: 12px; + stroke-width: 3; +} +.opt.box .mark { + border-radius: var(--radius-sm); +} +.opt.on { + border-color: var(--primary); + background: color-mix(in oklab, var(--primary) 7%, transparent); +} +.opt.on .mark { + background: var(--primary); + border-color: var(--primary); + color: var(--primary-foreground); +} +.opt.correct { + border-color: var(--border); + background: var(--success-soft); +} +.opt.correct .mark { + background: var(--success); + border-color: var(--success); + color: var(--success-foreground); +} +.opt.wrong { + border-color: var(--border); + background: var(--destructive-soft); +} +.opt.wrong .mark { + background: var(--destructive); + border-color: var(--destructive); + color: #fff; +} + +/* ============ bottom sheet ============ */ +.sheet { + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: var(--popover); + border-radius: 22px 22px 0 0; + border-top: 1px solid var(--border); + box-shadow: 0 -12px 32px rgb(0 0 0 / 0.18); + z-index: 8; + padding: 10px 16px 30px; + max-height: 74%; + display: flex; + flex-direction: column; +} +.sheet .grabber { + width: 38px; + height: 4px; + border-radius: 999px; + background: var(--border); + margin: 2px auto 12px; + flex-shrink: 0; +} +.sheet .sheet-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + flex-shrink: 0; +} +.sheet .sheet-head .st { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.sheet .sheet-body { + overflow-y: auto; + scrollbar-width: none; +} +.sheet .sheet-body::-webkit-scrollbar { + display: none; +} +.scrim { + position: absolute; + inset: 0; + background: oklch(0.145 0 0 / 0.42); + z-index: 7; +} + +/* toast / push preview */ +.push { + position: absolute; + top: 10px; + left: 10px; + right: 10px; + z-index: 9; + background: color-mix(in oklab, var(--popover) 88%, transparent); + backdrop-filter: blur(18px); + border: 1px solid var(--border); + border-radius: 20px; + padding: 11px 13px; + box-shadow: var(--shadow-lg); + display: flex; + gap: 10px; + align-items: flex-start; +} +.push .pi { + width: 34px; + height: 34px; + border-radius: 9px; + background: var(--primary); + color: var(--primary-foreground); + display: grid; + place-items: center; + font-weight: 700; + font-size: 13px; + flex-shrink: 0; +} +.push .pt { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; +} +.push .pb { + font-size: 12.5px; + color: var(--muted-foreground); + line-height: 1.4; + margin-top: 1px; +} +.push .pw { + font-size: 11px; + color: var(--muted-foreground); + margin-left: auto; + flex-shrink: 0; +} + +/* ============ player ============ */ +.player { + position: relative; + margin: 0 -16px; + aspect-ratio: 16 / 9; + background: oklch(0.145 0 0); + display: grid; + place-items: center; + overflow: hidden; +} +.player .play { + width: 58px; + height: 58px; + border-radius: 999px; + background: rgb(255 255 255 / 0.16); + backdrop-filter: blur(6px); + border: 1.5px solid rgb(255 255 255 / 0.5); + display: grid; + place-items: center; + color: #fff; +} +.player .play svg { + width: 24px; + height: 24px; + margin-left: 3px; +} +.player .ctrl { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 20px 14px 10px; + background: linear-gradient(transparent, rgb(0 0 0 / 0.72)); + color: #fff; +} +.player .ctrl .bar { + height: 3px; + border-radius: 999px; + background: rgb(255 255 255 / 0.3); + margin-bottom: 7px; +} +.player .ctrl .bar > span { + display: block; + height: 100%; + border-radius: 999px; + background: #fff; +} +.player .ctrl .meta { + display: flex; + align-items: center; + gap: 12px; + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.player .ctrl .meta svg { + width: 16px; + height: 16px; + stroke-width: 2; +} +.player .ctrl .meta .right { + margin-left: auto; + display: flex; + gap: 13px; + align-items: center; +} +.player .pill { + position: absolute; + top: 10px; + left: 10px; + background: rgb(0 0 0 / 0.6); + color: #fff; + font-size: 10.5px; + font-weight: 600; + padding: 3px 8px; + border-radius: 999px; + display: flex; + align-items: center; + gap: 4px; +} +.player .pill svg { + width: 11px; + height: 11px; + stroke-width: 2.4; +} + +/* lesson prose */ +.prose { + font-size: 14.5px; + line-height: 1.62; + color: var(--foreground); +} +.prose p { + margin: 0 0 12px; +} +.prose h3 { + font-size: 15.5px; + font-weight: 600; + letter-spacing: -0.01em; + margin: 18px 0 8px; +} +.prose ul { + margin: 0 0 12px; + padding-left: 20px; +} +.prose li { + margin-bottom: 6px; +} +.prose strong { + font-weight: 600; +} + +/* ============ empty state ============ */ +.empty { + text-align: center; + padding: 40px 22px; +} +.empty .ei { + width: 52px; + height: 52px; + border-radius: var(--radius-xl); + background: var(--muted); + color: var(--muted-foreground); + display: grid; + place-items: center; + margin: 0 auto 14px; +} +.empty .ei svg { + width: 23px; + height: 23px; + stroke-width: 1.8; +} +.empty .et { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.empty .ed { + font-size: 13px; + color: var(--muted-foreground); + margin-top: 5px; + line-height: 1.5; +} + +/* ============ misc ============ */ +.divider { + height: 1px; + background: var(--border); + margin: 14px 0; +} +.kv { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 0; + border-bottom: 1px solid var(--border); + font-size: 13.5px; +} +.kv:last-child { + border-bottom: none; +} +.kv .k { + color: var(--muted-foreground); +} +.kv .v { + font-weight: 500; +} +.stat { + flex: 1; + text-align: center; + padding: 11px 6px; +} +.stat .v { + font-size: 19px; + font-weight: 600; + letter-spacing: -0.02em; +} +.stat .k { + font-size: 10.5px; + color: var(--muted-foreground); + margin-top: 1px; +} +.dl-dot { + width: 16px; + height: 16px; + border-radius: 999px; + background: color-mix(in oklab, var(--primary) 10%, transparent); + color: var(--primary); + display: grid; + place-items: center; + flex-shrink: 0; +} +.dl-dot svg { + width: 10px; + height: 10px; + stroke-width: 3.4; +} + +/* theme toggle floating on the prototype page */ +.theme-toggle { + position: fixed; + bottom: 18px; + right: 18px; + width: 38px; + height: 38px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--card); + color: var(--muted-foreground); + display: grid; + place-items: center; + cursor: pointer; + box-shadow: var(--shadow-md); + z-index: 50; +} +.theme-toggle svg { + width: 17px; + height: 17px; + stroke-width: 2; +} +.theme-toggle:hover { + color: var(--foreground); +} + +@media (max-width: 900px) { + .stage { + justify-content: center; + } +} diff --git a/prototypes/mobile-app/auth.html b/prototypes/mobile-app/auth.html new file mode 100644 index 00000000..2ec7a8d2 --- /dev/null +++ b/prototypes/mobile-app/auth.html @@ -0,0 +1,203 @@ + + + + + + Sign in — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Entry +

Sign in & organization

+

+ Email/password, Google, and enterprise SSO all run through Better Auth's Expo client — the session cookie lives in + expo-secure-store and OAuth returns through the classroomio:// deep link. Multi-org accounts + pick an organization, which sets the cio-org-id header for every later request and re-themes the app. + Self-hosted installs point the same binary at their own API and skip the picker. +

+ +
+ +
+ +
+
+
+
+
+ +
+
+
C
+
Welcome back
+
Sign in to continue your training
+
+ +
+ + +
+
+ + +
+ + + +
+
+ OR +
+
+ +
+ + +
+ + +
+ Use a custom server +
Self-hosted ClassroomIO
+
+
+
+
+
+
Sign inThree auth paths in one screen. "Use a custom server" is how a self-hosted deployment points the same app at its own API.
+
+ + +
+
+
+
+
+ + + + +
+
+
+
Organization pickerOnly for multi-org accounts. The choice sets cio-org-id and re-themes the app; single-org and self-hosted users never see it.
+
+ + +
+
+
+
+
+ +
+ + You're offline — showing downloaded content +
+ +
+
+
+ + + + + +
+
Unlock ClassroomIO
+
+ Use Face ID to open your saved session. Your training records stay on this device until you reconnect. +
+ + + +
+ + +
+ +
+
+
3 courses available offline
+
Forklift Safety, Fire Safety, GDPR Essentials
+
+ +
+
+
+
+
+
Biometric re-entry, offline launchFace ID unlocks an existing stored session — it never replaces sign-in. Launching with no network goes straight to downloaded content instead of an error.
+
+
+ + + + + diff --git a/prototypes/mobile-app/certificates.html b/prototypes/mobile-app/certificates.html new file mode 100644 index 00000000..11a55428 --- /dev/null +++ b/prototypes/mobile-app/certificates.html @@ -0,0 +1,199 @@ + + + + + + Certificates — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Proof +

Certificate wallet

+

+ Proof of training in the learner's pocket — the thing a supervisor, an auditor, or a client site actually asks for. + Reads the existing packages/certificates records, stays viewable offline once downloaded, and shares as + a PDF with a verification link and QR. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+ + + + +
+ +
+
+
+
+
+
+
Hazardous Materials Handling
+
Issued 8 Sep 2025 · Northwind Logistics
+
Expires in 21 days
+
+
+
+
+ + + +
+
+ + +
+
+
Data Protection & GDPR Essentials
+
Issued 16 Aug 2026·CIO-GDPR-4F91
+
Valid until 16 Aug 2027
+
+ +
+ + +
+
+
Manual Handling & Ergonomics
+
Issued 2 Jun 2026·CIO-MHE-8C22
+
No expirySaved offline
+
+ +
+ + +
+
+
Site Induction — Meridian Depot
+
Issued 4 Mar 2026·CIO-SI-1A70
+
+ +
+ +
+
+
+
Forklift Safety Recertification 2025
+
Expired 17 Aug 2026
+
+
+
+
+ + +
+
+
+
WalletSorted by what needs attention. Expiring certificates get a Renew action that jumps straight into the refresher course.
+
+ + +
+
+
+
+
+ + + +
+ +
+
NL
+
Northwind Logistics Academy
+
This certifies that
+
Amara Okafor
+
has successfully completed
+
+ Data Protection & GDPR Essentials +
+
16 August 2026 · Final score 92%
+ +
+ +
+
+ + + + +
+
+
Verify
+
CIO-GDPR-4F91-2026
+
verify.classroomio.com
+
+
+
+ +
+ + +
+ +
+
Issued toAmara Okafor
+
Issued byNorthwind Logistics
+
Issue date16 Aug 2026
+
Valid until16 Aug 2027
+
Certificate IDCIO-GDPR-4F91-2026
+
Available offlineYes
+
+ +
+ Anyone with the ID can confirm this certificate is genuine without you sending a file. +
+
+
+
+
+
Detail & verificationThe rendered certificate, share sheet, and a verification ID/QR — so a site supervisor can check it on the spot.
+
+
+ + + + + diff --git a/prototypes/mobile-app/community.html b/prototypes/mobile-app/community.html new file mode 100644 index 00000000..3f1ca081 --- /dev/null +++ b/prototypes/mobile-app/community.html @@ -0,0 +1,321 @@ + + + + + + Community — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Tab 4 +

Community & newsfeed

+

+ Org and course Q&A plus cohort announcements, backed by the existing /community and + /course/:id/newsfeed routes. There is no direct messaging in ClassroomIO, so threaded Q&A is the + discussion surface — and offline it degrades to read-only with the compose box explicitly disabled. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+ + + + +
+ +
+
+
+
DM
+
+
+
Daniel Mensah Tutor
+ 2h +
+
Announcement · Q3 Frontline Onboarding
+
+ The practical forklift assessment moves to Bay 4 this Thursday. Bring your completion code + from Lesson 12 — we can't sign anyone off without it. +
+
+ 12 + 4 replies + Mentions you +
+
+
+
+ +

Questions you follow

+
+
+
+
TA
+
+
+
Tomás Alvarez
+ 5h +
+
Forklift Safety Recertification · Lesson 4
+
+ Does a hairline crack at the fork heel always mean tag-out? +
+
+
DM
+
+
+ Yes — any crack at the heel is a tag-out. Photograph it, log it, and tell your supervisor before the + next shift. +
+
Answered by tutor
+
+
+
+ 8 + 3 + Following +
+
+
+
+ +
+
+
PK
+
+
+
Priya Kaur
+ 1d +
+
GDPR Essentials · General
+
+ If a customer asks for their data verbally at the counter, does the 30-day clock start? +
+
+ 5 + 2 + Unanswered +
+
+
+
+
+ +

Ask your own

+
+
+
AO
+
Ask a question about a lesson…
+ +
+
+
+ + +
+
+
+
For youMentions, followed threads, and announcements in one stream. Tutor answers are marked so learners can trust them at a glance.
+
+ + +
+
+
+
+
+ + + +
+
+
+
TA
+
+
Tomás Alvarez
+
5h ago
+
+ +
+
+ Does a hairline crack at the fork heel always mean tag-out? +
+
+

+ The lesson says cracks are a fault but doesn't distinguish severity. On our older trucks there are surface + marks that have been there for years. Do I tag out every time? +

+
+
+ + + +
+
+ +
+ +
+
+
DM
+
+
+
Daniel Mensah Tutor answer
+ 4h +
+
+ Yes — any crack at the heel is a tag-out, no severity judgement. Surface scoring from wear is different: + it's smooth and doesn't catch a fingernail. If you can feel an edge, tag it, photograph it, and log it. +
+
+ 14 + Reply +
+
+
+
+ +
+
+
PK
+
+
+
Priya Kaur
+ 3h +
+
+ The fingernail test is a good rule of thumb — that's what our depot uses too. +
+
+
+
+
AO
+
+
+
Amara Okafor
+ 2h +
+
+ Thanks — I tagged out FLT-07 this morning on exactly this. +
+
+
+
+ +
+ + +
+
+
+
+
+
ThreadThe tutor answer is visually promoted above learner replies — the same author/team distinction the API already enforces.
+
+ + +
+
+
+
+
+ +
+ + Offline — showing cached posts +
+ + + +
+ + +
+
+
+
DM
+
+
Daniel Mensah
+
Cached 2h ago
+
+ The practical forklift assessment moves to Bay 4 this Thursday. +
+
+
+
+
+
+
TA
+
+
Tomás Alvarez
+
Cached 5h ago
+
+ Does a hairline crack at the fork heel always mean tag-out? +
+
+
+
+
+ +
+
+
+
Reply box disabled
+
Reconnect to post — nothing you type would be saved
+
+
+
+ + +
+
+
+
Offline read-onlyThe compose affordance is disabled and explained rather than accepting text that would be lost — no fake optimistic posting.
+
+
+ + + + + diff --git a/prototypes/mobile-app/compliance.html b/prototypes/mobile-app/compliance.html new file mode 100644 index 00000000..2bde1f1a --- /dev/null +++ b/prototypes/mobile-app/compliance.html @@ -0,0 +1,206 @@ + + + + + + Compliance — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Tab 3 +

Compliance — the differentiator

+

+ ClassroomIO's core value is knowing where you stand, so it gets a tab rather than a settings sub-page. Deadlines, + grace periods, renewals, and waivers all already exist server-side (/course/:courseId/compliance); + mobile needs one learner-scoped aggregate, GET /account/compliance, to render them together. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+
+
+ +
+
+
Action required
+
+ 1 overdue + 1 expiring + 4 compliant +
+
+
+
+ +

Overdue

+ +
+
+
+
Forklift Safety Recertification 2026
+
Overdue 3 daysGrace ends 24 Aug
+
+
+
+
Deadline17 Aug 2026
+
Progress4 of 12 lessons
+
If missedAuthorization suspended
+ +
+ +

Expiring soon

+
+
+
+
+
Hazardous Materials Handling
+
Expires in 21 days
+
+
+
+
+
+ 21d +
+
Certified8 Sep 2025
+
Renewal opens8 Sep 2026
+ View current certificate +
+ +

Compliant

4
+ +
+ + +
+
+
+
At riskOverdue first, with the consequence spelled out ("Authorization suspended") and grace-period dates visible. Waivers are shown, not hidden.
+
+ + +
+
+
+
+
+ + + +
+
+
+
+ +
+
+
Fully compliant
+
All 6 requirements up to date
+
+
+
+ +

Renewal timeline

+
+
+
+ +
+
Hazardous Materials Handling
Renews 15 Feb 2027
+ 183d +
+
+
+
Forklift Safety Recertification
Renews 17 Aug 2027
+ 366d +
+
+
+
Data Protection & GDPR
Renews 16 Aug 2027
+ 365d +
+
+ +

Your record

+
+
Requirements met6 of 6
+
On-time completion rate83%
+
Certificates held6
+
Last audit export1 Aug 2026
+
+ + + + Open certificate wallet + +
+ Your organization can verify any certificate from its ID without you sending a file. +
+
+ + +
+
+
+
All clearThe same screen when nothing is wrong: status, what renews next, and the record an auditor would ask for.
+
+
+ + + + + diff --git a/prototypes/mobile-app/course.html b/prototypes/mobile-app/course.html new file mode 100644 index 00000000..8b8a638e --- /dev/null +++ b/prototypes/mobile-app/course.html @@ -0,0 +1,284 @@ + + + + + + Course detail — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Course +

Course detail & outline

+

+ Sections and lessons with an explicit state on every row: complete, current, locked (with the reason spelled out), + or downloaded. Compliance courses carry a deadline banner. Download is a whole-course action from the header, + sized before it starts and Wi-Fi-only by default. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+ + + + + +
+ +
+ + +
+
+
+ + 33% +
+
+
4 of 12 lessons
+
~28 min remaining
+
Downloaded · 84 MB
+
+
+ + + Continue lesson 4 + +
+ +

Section 1 · Before you drive

3/3
+ + +

Section 2 · Inspection

1/4
+
+ +
+
+
Pre-operation inspection checklist
+
Video · 9 min·6 min left
+
+ +
+ +
+
Load stability and centre of gravity
Video · 7 min
+ +
+
+
+
+
Hydraulics walkthrough
+
Complete "Load stability" to unlock
+
+
+
+
+
+
Section 2 check
+
Complete all lessons in this section
+
+
+
+ +

Section 3 · Assessment

0/5
+
+
+
+
5 items locked
+
Finish Section 2 to unlock the assessment
+
+
+
+
+
+
+
OutlineEvery row states its own condition. Locked items name the prerequisite instead of just greying out — and the API enforces the same rule.
+
+ + +
+
+
+
+
+ + + +
+ + + + +
+ +
+ +
+
Fire triangle basics
+
Extinguisher classes
+
Evacuation routes
+
+
+ +
+
+
+
+
Download this course
+ +
+
+
+
8 lessons · video, PDFs, transcripts112 MB
+
Free space on this device3.4 GB
+
ConnectionWi-Fi
+
+ +
+
+
Download over Wi-Fi only
+
Avoids using your mobile data
+
+ +
+ + + + + +
+
+
+
+
+
Download sheetSize, free space, and connection shown before committing, with the offline limits stated up front rather than discovered on a plane.
+
+ + +
+
+
+
+
+ + + +
+
+
+
+ +
+
+
Course complete
+
Finished 16 Aug 2026Final score 92%
+
+
+ + + View your certificate + +
+ +
+
Lessons completed10 / 10
+
Exercises passed3 / 3
+
Time spent1h 12m
+
Certificate IDCIO-GDPR-4F91-2026
+
+ +

Keep the content

+
+
+
+
Downloaded · 96 MB
+
Review offline any time, or free the space
+
+ +
+ +

Recommended next

+ +
+
+
Handling a Data Subject Request
+
4 lessons·~18 min
+
+ +
+
+
+
+
+
CompletedCertificate issuance is the payoff, and the download stays until the learner removes it.
+
+
+ + + + + diff --git a/prototypes/mobile-app/downloads.html b/prototypes/mobile-app/downloads.html new file mode 100644 index 00000000..3d72222f --- /dev/null +++ b/prototypes/mobile-app/downloads.html @@ -0,0 +1,212 @@ + + + + + + Downloads & sync — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Platform +

Downloads, storage & sync queue

+

+ Content is cached in SQLite plus the file system from one GET /course/:courseId/offline-manifest call. + Writes go the other way: completion and playback position queue locally with an idempotency key and replay through + POST /course/:courseId/sync. Completion is monotonic, so a retry can never corrupt a record — which is + exactly why offline exercises are deferred to v2. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+
+ Storage on this device + 284 MB of 3.7 GB free +
+
+ + + +
+
+ Forklift 84 MB + Fire Safety 112 MB + GDPR 88 MB +
+
+ +

Downloading

+
+
+
+
+
Hazardous Materials Handling
+
Lesson 5 of 8·48 MB of 96 MB
+
+
+ 50% +
+
+ +
+
+ +

On this device

3
+
+
+
+
+
Forklift Safety Recertification
+
12 lessons·84 MB·Synced 2h ago
+
+ +
+
+
+
+
Warehouse Fire Safety
+
8 lessons·112 MB·Synced 2h ago
+
+ +
+
+
+
+
Data Protection & GDPR Essentials
+
Completed·88 MB
+
+ +
+
+ +

Settings

+
+
+
Download over Wi-Fi only
Protects your mobile data
+ +
+
+
Auto-download required training
When new compliance courses are assigned
+ +
+
Video quality offlineStandard (480p)
+
+ + +
+
+
+
+
Downloads & storagePer-course breakdown, resumable progress, and Wi-Fi-only by default. Nothing is silently evicted — the learner chose to download it.
+
+ + +
+
+
+
+
+ + + +
+ + +
+
+
+
+
Lesson 4 completed
+
Forklift Safety·Queued 18:31
+
+ Synced +
+
+
+
+
Lesson 5 completed
+
Forklift Safety·Queued 18:40
+
+ Synced +
+
+
+
+
Playback position 7:02
+
Lesson 5·Queued 18:47
+
+ Syncing +
+
+
+
+
Lesson 6 completed
+
Retrying in 8s · attempt 2 of 5
+
+ +
+
+ +

What syncs offline

+
+
Lesson completionQueued offline
+
Playback positionQueued offline
+
Exercise answersOnline only
+
Community postsOnline only
+
+ +
+ Completion only ever moves forward, so replaying a queued change twice is harmless. Grading and submissions + stay online to keep assessment records unambiguous. +
+
+
+
+
+
Sync queueEvery queued intent is visible with its state and retry count — the learner can see their work is safe rather than hoping.
+
+
+ + + + + diff --git a/prototypes/mobile-app/exercise.html b/prototypes/mobile-app/exercise.html new file mode 100644 index 00000000..7e60dca3 --- /dev/null +++ b/prototypes/mobile-app/exercise.html @@ -0,0 +1,254 @@ + + + + + + Exercise — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Assessment +

Exercises

+

+ Touch renderers for all 16 question types in packages/question-types — including the ones the web + takes for granted: MATCHING and ORDERING as drag, HOTSPOT as tap, + FILE_UPLOAD through the camera, and VIDEO_RECORDING in-app. Answers autosave locally so a + phone call never costs an attempt; media goes up through the existing presigned-upload route. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+
+ + +
+
+ +
Single choice · 2 points
+
+ You find a wet, shiny film on a hydraulic ram that reappears after wiping. What do you do? +
+ +
+ + + + +
+ + + +
+ + +
+
+
+
+
+
In progressOne question per screen with a segment bar and a persistent "Saved" chip — the autosave promise made visible.
+
+ + +
+
+
+
+
+ + + +
+
+
+ + +
+
+ +
Photo evidence · 5 points
+
+ Photograph the data plate on the truck you're authorized to drive. +
+
Your tutor checks that the rated capacity matches your authorization.
+ +
+
+
+ +
+
+
IMG_4192.jpg
+
2.4 MB·Uploading…
+
62%
+
+ +
+
+ +
+ + +
+ +
+ +
Also in this exercise
+
+
+
+
Match the hazard to its control
Drag to match · answered
+
+
+
+
Tap the blind spot on the diagram
Hotspot · answered
+
+
+
+
Record your 60-second handover
Video · not started
+
+
+ + +
+
+
+
+
Media & rich question typesCamera capture with visible upload progress and retry, plus drag, hotspot, and in-app video recording — the types a phone does better than a laptop.
+
+ + +
+
+
+
+
+ + + +
+
+
+ + 87% +
+
Passed
+
26 of 30 points · pass mark 70%
+
+ Graded by Daniel Mensah + 2h ago +
+
+ +
+
Tutor feedback
+
+ Solid inspection sequence and the tag-out call was right. Your handover recording skipped the battery state — + mention it next time, it's the one item the incoming driver can't check quickly. +
+
+ +

Question breakdown

+
+
+
+
Q3 · Hydraulic fault response
2 / 2
+
+
+
+
Q5 · Match hazard to control
4 / 4
+
+
+
+
+
Q8 · Handover recording
+
4 / 8·Missing battery state
+
+
+
+ +
+ + Continue to Section 3 + +
1 retake remaining · your highest score counts
+
+
+
+
+
GradedScore, tutor comment, and a per-question breakdown that names what was missing — feedback the learner can act on without opening a laptop.
+
+
+ + + + + diff --git a/prototypes/mobile-app/home.html b/prototypes/mobile-app/home.html new file mode 100644 index 00000000..79a35bf7 --- /dev/null +++ b/prototypes/mobile-app/home.html @@ -0,0 +1,353 @@ + + + + + + Home — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Tab 1 +

Home — the attention surface

+

+ Ordered by urgency, not recency: compliance alerts, then continue-learning, due-soon, newly assigned, announcements, + and feedback. The whole screen is one GET /account/home-digest call so a cold launch on a weak + connection costs a single round trip — and the cached copy still renders offline with an explicit staleness chip. +

+ +
+ +
+ +
+ +
Default — work to doOverdue compliance outranks everything. Every card is a deep-link target for a push notification.
+
+ + +
+
+
+
+
+ + + +
+ + +
+
+ +
+
Nothing due — you're all caught up
+
No overdue training, no deadlines this week. We'll notify you when something new is assigned.
+
+ +
+
+
9
Completed
+
+
6
Certificates
+
+
14
Day streak
+
+
+ +

Keep going

Explore
+ +
+ + +
+
+
+
Caught upThe reward state. A muted status line replaces the overdue alert, the urgency stack collapses to an empty state, and the screen shifts to optional learning.
+
+ + +
+
+
+
+
+ + +
+
C
+
+
+
Deadline reminder
+
now
+
+
Forklift Safety Recertification is due tomorrow. 8 lessons left — about 20 minutes.
+
+
+ +
+ + Offline — showing your last sync +
+ + + +
+
+ + + Last updated 2h ago + + 2 changes waiting to sync +
+ +

Available offline

+ + +

Needs a connection

+
+
+
+
+
Module 3 knowledge check
+
Exercises submit online only — we'll remind you when you reconnect
+
+
+
+
+
+
Community & announcements
+
Reading cached posts · posting disabled offline
+
+
+
+
+ + +
+
+
+
Offline + incoming pushThe cached digest with an explicit staleness chip. Anything that can't work offline is listed and explained rather than silently failing.
+
+
+ + + + + diff --git a/prototypes/mobile-app/index.html b/prototypes/mobile-app/index.html new file mode 100644 index 00000000..23511fa1 --- /dev/null +++ b/prototypes/mobile-app/index.html @@ -0,0 +1,345 @@ + + + + + + ClassroomIO Mobile App — Prototype Map + + + + + + +
+ ClassroomIO · Prototype + PRD handoff +

Mobile App

+

+ A learner-first iOS and Android app for the people companies actually train — frontline staff, field + technicians, and distributed employees who often have no work laptop and unreliable connectivity. It opens on + what needs your attention, works offline, and finally gives ClassroomIO a push channel for + deadlines and renewals. A small tutor mode handles grading and attendance on the go; authoring and org + administration stay on the web. +

+

+ Built on the real packages/ui tokens, rendered in a 390 × 844 device frame. Every page has a theme + toggle and cross-links so you can click through each journey end to end. The engineering PRD lives at + prd/mobile-app/README.md. +

+ + +
1 · Entry — sign in & organization
+ + + +
2 · Learner — the consumption path
+ + + +
3 · Learner — engagement & platform
+ + + +
4 · Tutor companion — role-aware, same binary
+ + +
+

+ Explicitly not in the app. Course, lesson, and exercise authoring; the media manager and AI course builder; + org settings, team management, billing, SSO configuration, custom domains, and API keys; live-class video hosting; + SCORM playback; and any purchase flow at all — no checkout, no plan upgrade, no AI token packs, on any + storefront. +

+

+ Hard dependency. Push notifications ride on prd/notification-system. Mobile contributes a + device-token registry and an Expo Push transport in the existing apps/jobs worker — never a second + notification pipeline. +

+

+ Offline scope. v1 caches content and queues two monotonic writes (lesson completion, playback position). + Exercises, submissions, and grading stay online so no assessment record can be corrupted by a replay — the same + line Canvas and Blackboard draw. +

+
+
+ + + + + diff --git a/prototypes/mobile-app/learn.html b/prototypes/mobile-app/learn.html new file mode 100644 index 00000000..af54eaa3 --- /dev/null +++ b/prototypes/mobile-app/learn.html @@ -0,0 +1,329 @@ + + + + + + Learn — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Tab 2 +

Learn — my learning, explore, cohorts

+

+ Everything the learner is enrolled in, everything the organization has published, and the cohorts they belong to. + Course rows carry a progress ring, a type chip (Compliance / Cohort / Self-paced), and a download marker. + Explore enrolls in place for assigned and free courses — there is deliberately no checkout anywhere in the app. +

+ +
+ +
+ +
+ +
My learningProgress ring, urgency badge, course type, and download state on every row — the four things a learner scans for.
+
+ + +
+
+
+
+
+ + + +
+ + + +
+ +
+ + +
+ + + + + +
+ +
+
+
+ Safety +
+
+
Hazardous Materials Handling
+
Segregation, spill response, and documentation for warehouse teams.
+
8 lessons·~45 min·Certificate
+
+ Assigned to you + +
+
+
+ + +
+ +
+
+
Manual Handling & Ergonomics
+
6 lessons·~25 min
+
+ +
+ + +
+ +
+
+
Working at Height
+
5 lessons·~20 min
+
+ +
+ + +
+ +
+
+
Difficult Conversations
+
4 lessons·~18 min
+
+ +
+
+ + +
+ + +
+
+
+
ExploreThe org catalog with enroll-in-place. No checkout, no upgrade prompt, no external purchase link — Decision 9 in the PRD.
+
+ + +
+
+
+
+
+ + + +
+ + + +
+ +
+
+
+
+
Q3 Frontline Onboarding
+
18 members · Daniel Mensah
+
+ Active +
+ +
+ +
Cohort goal
+
+
+ 13/18 +
+
Everyone completes GDPR Essentials by 30 Aug · 9 days left
+ +
+ +
Your progress
+
+
+ + Welcome & expectations +
+
+ + Site induction +
+
+ + GDPR Essentials — knowledge check +
+
+ + Open cohort course +
+ +

Upcoming sessions

+
+
+
+
+
Practical forklift assessment
+
Thu 21 Aug · 14:00·Bay 4
+
+ In person +
+
+
+
+
GDPR Q&A with Daniel
+
Fri 22 Aug · 10:00·Live class
+
+ +
+
+ +
+ Live classes open in your organization's meeting provider — the app doesn't host video conferencing. +
+
+ + +
+
+
+
CohortsGoal progress, the learner's own checklist, and upcoming sessions. Live classes hand off to the org's existing provider.
+
+
+ + + + + diff --git a/prototypes/mobile-app/lesson.html b/prototypes/mobile-app/lesson.html new file mode 100644 index 00000000..284dffb3 --- /dev/null +++ b/prototypes/mobile-app/lesson.html @@ -0,0 +1,324 @@ + + + + + + Lesson player — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Player +

Lesson player

+

+ HLS video with captions, speed, PiP, background audio, and lock-screen controls, authorized by the new + GET /hls/:assetId/playback-token because a native player can't carry the web's per-asset cookie. + Transcript and the AI Lesson Tutor are bottom sheets over the same screen; mark-complete queues locally when offline. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+ Downloaded +
+
+
+
+ 3:04 + 9:12 + + 1.25x + + + + +
+
+
+ +
+
+ + + +
+ 4/12 +
+ +

+ Pre-operation inspection checklist +

+
+ Video · 9 min + English + Available offline +
+ +
+

+ A pre-operation inspection takes about four minutes and catches most of the faults that cause workplace + incidents. Work through it in the same order every time so nothing gets skipped when you're in a hurry. +

+

The seven checks

+
    +
  • Tyres and wheels — cuts, embedded debris, correct pressure.
  • +
  • Forks — cracks at the heel, bent tips, locking pins seated.
  • +
  • Hydraulic hoses — chafing, weeping seals, pooled fluid underneath.
  • +
  • Mast and chains — even tension, no kinked links.
  • +
  • Horn, lights, and alarm — audible and visible from 10 metres.
  • +
  • Data plate — legible, and the rated capacity matches today's load.
  • +
  • Seatbelt — retracts cleanly and latches.
  • +
+

Log the result before you move the truck. An unlogged inspection counts as no inspection.

+
+ +
+ + +
+ +
+ + + Mark complete & continue + + +
Next: Load stability and centre of gravity · 7 min
+
+ + + + + + +
+
+
+
Online playerVideo, native prose, and two sheets (transcript, AI tutor) — tap either row in the live prototype to open it.
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
+
6:117:02
+
+
+
+

+ The load centre is the horizontal distance from the fork face to the load's centre of gravity. Every truck's + data plate is rated at a specific load centre — usually 500 mm. +

+
+
+ +
+
+
+
+
+
AI lesson tutor
Scoped to this lesson
+ +
+
+
+
+ +
+
You've used today's 20 questions
+
Your allowance resets at midnight. The transcript and lesson notes are still available.
+
+ + +
+ Fair-use caps are set by your organization. +
+
+
+
+
+
+
AI tutor, cap reachedThe fair-use limit is stated plainly with two real alternatives, not a paywall — the app never sells token packs.
+
+ + +
+
+
+
+
+ +
+ + Offline — progress will sync automatically +
+ + + +
+
+ Playing from device +
+
+
+
7:027:02
+
+
+ + + +

Waiting to sync

2
+
+
+
+
+
Lesson 5 completed
+
Queued 18:40
+
+ Queued +
+
+
+
+
Lesson 4 position 9:12
+
Queued 18:31
+
+ Queued +
+
+ +
+ + + Next lesson + + + +
+
+
+
Section 2 check
+
Exercises need a connection — we'll nudge you when you're back online
+
+
+
+
+
+
+
Offline & queuedCompletion is a monotonic, idempotent intent held on-device — the reason v1 offline covers content but not assessments.
+
+
+ + + + + diff --git a/prototypes/mobile-app/notifications.html b/prototypes/mobile-app/notifications.html new file mode 100644 index 00000000..89ffd2d5 --- /dev/null +++ b/prototypes/mobile-app/notifications.html @@ -0,0 +1,263 @@ + + + + + + Notifications — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Retention +

Notifications, permission & preferences

+

+ The single biggest reason to ship an app: ClassroomIO has no push channel today, only email. This rides on + prd/notification-system — mobile adds a device-token registry and an Expo Push transport in the existing + apps/jobs worker, never a second notification pipeline. Every category is independently switchable and + quiet hours defer rather than drop. +

+ +
+ +
+ +
+
+
+
+
+ +
+
+
+ +
+
+ Never miss a deadline +
+
+ Northwind Logistics assigns required training with real deadlines. Turn on notifications and we'll remind you + before something goes overdue — not after. +
+
+ +
+
+
+
Deadline & renewal reminders
+
+
+
+
New assignments and grades
+
+
+
+
Replies to your questions
+
+
+ + + +
+ You choose exactly which categories reach you — nothing is marketing. +
+
+
+
+
+
Permission pre-promptAsks in context with the value stated, so a "no" from the cold OS dialog doesn't permanently kill the channel.
+
+ + +
+ +
CenterGrouped Today / Earlier with unread dots. Every row deep-links to the exact screen its push notification would open.
+
+ + +
+
+
+
+
+ + + +
+

Training & deadlines

+
+
+
New assignment
When a course is assigned to you
+ +
+
+
Deadline reminder
7 days, 1 day, and morning-of
+ +
+
+
Overdue alert
Always on for required training
+ +
+
+
Certification renewal
30 and 7 days before expiry
+ +
+
+ +

Progress & feedback

+
+
+
Exercise graded
+ +
+
+
Certificate issued
+ +
+
+
Cohort goal approaching
+ +
+
+ +

Community

+
+
+
Mentions & replies
+ +
+
+
New lesson or newsfeed post
+ +
+
+ +

Quiet hours

+
+
+
Pause notifications
Deferred, not dropped — they arrive after
+ +
+
From21:00
+
Until07:00
+
Time zoneEurope/London
+
+ + +
+
+
+
+
PreferencesPer-category switches writing to the same preference store the web uses. Overdue compliance alerts are deliberately not switchable.
+
+
+ + + + + diff --git a/prototypes/mobile-app/profile.html b/prototypes/mobile-app/profile.html new file mode 100644 index 00000000..82b5955b --- /dev/null +++ b/prototypes/mobile-app/profile.html @@ -0,0 +1,242 @@ + + + + + + Profile — ClassroomIO Mobile + + + + + +
+ All screens + Learner · Tab 5 +

Profile & settings

+

+ The learner's own record plus the app's controls. Switching organization re-themes the app and swaps every list + behind it. Anything that belongs to org administration — billing, team management, SSO, domains — is deliberately a + link to the web, not a screen here. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+
+
AO
+
+
Amara Okafor
+
amara.okafor@northwind.co
+
LearnerWarehouse Operations
+
+
+
+
+
9
Completed
+
+
6
Certificates
+
+
83%
On time
+
+
+ + + +

Your learning

+ + +

App

+
+ +
+
Notifications
+ +
+
LanguageEnglish (UK)
+
ThemeMatch system
+
Video qualityAuto
+
+
Unlock with Face ID
Re-lock the app after 5 minutes
+ +
+
+ +

Account

+
+
+
Change password
Opens the web dashboard
+ +
+
+
Privacy & data
+ +
+ +
Sign out
+
+
+ +
+ ClassroomIO 1.0.0 (build 118) · Connected to app.classroomio.com +
+
+ + + + + +
+
+
+
ProfileLearner record, app settings, and the org switcher (tap the header row in the live prototype). Admin concerns link out to the web on purpose.
+
+ + +
+
+
+
+
+ + + +
+ + +
+
+
Large touch targets
Gloves-friendly rows and buttons
+ +
+
+
Low-bandwidth media
Audio-first playback on cellular
+ +
+
+
Shared device
Sign out automatically after each session
+ +
+
+ +
+
Scan to start a course
+
+ + + + +
+
Point the camera at the poster in the break room, or a QR on the machine itself.
+ +
+ +

Accessibility

+
+
Text sizeFollows system (Dynamic Type)
+
Screen readerFull labels on every control
+
Status coloursAlways paired with an icon
+
Minimum target44 pt
+
+
+
+
+
+
Frontline mode & accessibilityThe settings that make the app usable in a warehouse: large targets, low-bandwidth media, shared-device sign-out, and QR launch.
+
+
+ + + + + diff --git a/prototypes/mobile-app/proto.js b/prototypes/mobile-app/proto.js new file mode 100644 index 00000000..49b2de91 --- /dev/null +++ b/prototypes/mobile-app/proto.js @@ -0,0 +1,118 @@ +/* + * proto.js — shared behaviour for the mobile-app prototypes. + * + * Deliberately tiny: a theme toggle, plus opt-in interactions (segmented + * controls, in-screen tabs, chips, bottom sheets, switches) that pages get + * for free by using the right data attribute. No framework, no build step. + */ + +(function () { + // ---- device chrome ------------------------------------------------------ + //
+ const SIGNAL = + ''; + const WIFI = + ''; + const WIFI_OFF = + ''; + const BATTERY = + ''; + + document.querySelectorAll('[data-status]').forEach((bar) => { + const offline = bar.hasAttribute('data-offline'); + bar.innerHTML = `${bar.dataset.status}${offline ? '' : SIGNAL}${ + offline ? WIFI_OFF : WIFI + }${BATTERY}`; + }); + + // + const TAB_ICONS = { + home: '', + learn: + '', + compliance: + '', + teach: + '', + community: '', + profile: '' + }; + const LEARNER_TABS = [ + { id: 'home', label: 'Home', href: 'home.html' }, + { id: 'learn', label: 'Learn', href: 'learn.html' }, + { id: 'compliance', label: 'Compliance', href: 'compliance.html' }, + { id: 'community', label: 'Community', href: 'community.html' }, + { id: 'profile', label: 'Profile', href: 'profile.html' } + ]; + const TUTOR_TABS = [ + { id: 'home', label: 'Home', href: 'home.html' }, + { id: 'learn', label: 'Learn', href: 'learn.html' }, + { id: 'teach', label: 'Teach', href: 'tutor-grading.html' }, + { id: 'community', label: 'Community', href: 'community.html' }, + { id: 'profile', label: 'Profile', href: 'profile.html' } + ]; + + document.querySelectorAll('[data-tabbar]').forEach((bar) => { + const active = bar.dataset.tabbar; + const badges = (bar.dataset.badges || '').split(',').filter(Boolean); + const tabs = bar.dataset.role === 'tutor' ? TUTOR_TABS : LEARNER_TABS; + + bar.innerHTML = tabs + .map((tab) => { + const badge = badges.find((entry) => entry.startsWith(`${tab.id}:`)); + const value = badge ? badge.split(':')[1] : ''; + const marker = + value === 'dot' ? '' : value ? `${value}` : ''; + return `${ + TAB_ICONS[tab.id] + }${tab.label}${marker}`; + }) + .join(''); + }); + + // ---- theme toggle ------------------------------------------------------- + const toggle = document.querySelector('.theme-toggle'); + if (toggle) { + toggle.addEventListener('click', () => { + const dark = document.documentElement.classList.toggle('dark'); + localStorage.cioTheme = dark ? 'dark' : 'light'; + }); + } + + // ---- exclusive selection groups ---------------------------------------- + // Any container with [data-select] makes its direct children mutually + // exclusive on click: .segmented, .tabs, .chips, option lists. + document.querySelectorAll('[data-select]').forEach((group) => { + const activeClass = group.dataset.select || 'on'; + group.addEventListener('click', (event) => { + const target = event.target.closest('[data-option]'); + if (!target || !group.contains(target)) return; + + group.querySelectorAll('[data-option]').forEach((option) => option.classList.remove(activeClass)); + target.classList.add(activeClass); + }); + }); + + // ---- bottom sheets ------------------------------------------------------ + // [data-sheet-open="id"] shows #id and its scrim; [data-sheet-close] hides. + function setSheet(id, open) { + const sheet = document.getElementById(id); + if (!sheet) return; + + const scrim = sheet.parentElement.querySelector(`[data-scrim-for="${id}"]`); + sheet.style.display = open ? 'flex' : 'none'; + if (scrim) scrim.style.display = open ? 'block' : 'none'; + } + + document.querySelectorAll('[data-sheet-open]').forEach((trigger) => { + trigger.addEventListener('click', () => setSheet(trigger.dataset.sheetOpen, true)); + }); + + document.querySelectorAll('[data-sheet-close]').forEach((trigger) => { + trigger.addEventListener('click', () => setSheet(trigger.dataset.sheetClose, false)); + }); + + document.querySelectorAll('[data-scrim-for]').forEach((scrim) => { + scrim.addEventListener('click', () => setSheet(scrim.dataset.scrimFor, false)); + }); +})(); diff --git a/prototypes/mobile-app/tutor-attendance.html b/prototypes/mobile-app/tutor-attendance.html new file mode 100644 index 00000000..f41be9d8 --- /dev/null +++ b/prototypes/mobile-app/tutor-attendance.html @@ -0,0 +1,192 @@ + + + + + + Tutor attendance — ClassroomIO Mobile + + + + + +
+ All screens + Tutor · Release 3 +

Attendance

+

+ The clearest case for a phone over a laptop: marking a roster while standing in the room. Backed by the existing + /course/:courseId/attendance route, with a QR/code check-in so learners mark themselves and the tutor + only handles exceptions. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+
+
14
Present
+
+
2
Late
+
+
2
Absent
+
+
+
+ + +
+
+ + + +
+
+
AO
+
Amara Okafor
Checked in 13:52 via QR
+
+ + + +
+
+
+
TA
+
Tomás Alvarez
Checked in 13:58 via QR
+
+ + + +
+
+
+
PK
+
Priya Kaur
Arrived 14:09Late
+
+ + + +
+
+
+
MB
+
Marcus Bell
No check-inAbsent
+
+ + + +
+
+
+
EN
+
Esther Nwosu
Checked in 13:47 via QR
+
+ + + +
+
+
+
LC
+
Liam Chen
Checked in 13:55 via QR
+
+ + + +
+
+
+ + +
+
+
+
+
RosterPresent / Late / Absent as one-tap segments, with the QR check-in time shown so the tutor only corrects exceptions.
+
+ + +
+
+
+
+
+ + + +
+
+
Learners scan or type this
+
+ + + + +
+
4Q7 812
+
Expires in 14:52 · rotates automatically
+
+ +
+
+ Checked in so far + 14 of 18 +
+
+
+
AO
+
TA
+
EN
+
LC
+
RS
+
+9
+ Updating live +
+
+ + + Back to roster +
+
+
+
+
QR check-inA rotating code the room can scan. The roster fills itself and the tutor's job shrinks to the four people it missed.
+
+
+ + + + + diff --git a/prototypes/mobile-app/tutor-course.html b/prototypes/mobile-app/tutor-course.html new file mode 100644 index 00000000..5e4dd021 --- /dev/null +++ b/prototypes/mobile-app/tutor-course.html @@ -0,0 +1,248 @@ + + + + + + Tutor course snapshot — ClassroomIO Mobile + + + + + +
+ All screens + Tutor · Release 3 +

Course snapshot, announcements & moderation

+

+ Read-only insight plus the two write actions a tutor genuinely needs away from a desk: post an announcement and + moderate a thread. Authoring, analytics configuration, enrollment management, and org settings are deliberately + absent — they stay on the web. +

+ +
+ +
+ +
+
+
+
+
+ + + +
+
+
+
64
Enrolled
+
+
71%
Completed
+
+
7
Overdue
+
+
+
+
+ 45/64 +
+
Deadline 30 Sep 2026 · 45 days left
+
+ +

Needs a nudge

7
+
+
+
MB
+
Marcus Bell
No activity in 21 days·1 of 9 lessons
+ +
+
+
RS
+
Rina Sato
Stalled at Lesson 4·9 days
+ +
+
+
JW
+
Joseph Wanjiru
Waiting on your grading
+ Grade +
+
+ + + +

Where learners get stuck

+
+
+
Lesson 4 · Minimum necessary rule
11 learners stalled here
+ 17% +
+
+
Scenario response
Average score 6.8 / 10
+ Low +
+
+
Lesson 2 · Covered entities
4 unanswered questions
+ Q&A +
+
+ +
+ Full analytics, exports, and course editing live in the web dashboard. +
+
+ + + +
+
+
+
Snapshot & announcementEnough insight to act — who's stalled and where — plus a one-tap nudge. Open the announcement sheet from the header in the live prototype.
+
+ + +
+
+
+
+
+ + + +
+ + + + +
+ +
+

Unanswered questions

4
+
+
+
+
FA
+
+
+
Fatima Ali
+ 2d +
+
HIPAA Awareness · Lesson 2
+
+ Is a contracted cleaning company a business associate if they're never shown records? +
+
+ + +
+
+
+
+ +
+
+
LC
+
+
+
Liam Chen
+ 3d +
+
HIPAA Awareness · General
+
+ Does the training reset if I change department within the same hospital? +
+
+ + +
+
+
+
+
+ +

Reported

1
+
+
+
?
+
+
+
Anonymous learner
+ Reported ×2 +
+
SOC 2 Security Basics · Q&A
+
+ "Here's the answer key from last year's exam, DM me…" +
+
+ + + +
+
+
+
+ +

Recently handled

+
+
+
+
Marked "audit trail" answer as accepted
Yesterday
+
+
+
+
Removed duplicate thread
Mon 18 Aug
+
+
+
+ + +
+
+
+
ModerationUnanswered questions and reported posts in one queue, using the same author/team permission rules the API enforces.
+
+
+ + + + + diff --git a/prototypes/mobile-app/tutor-grading.html b/prototypes/mobile-app/tutor-grading.html new file mode 100644 index 00000000..d5905b58 --- /dev/null +++ b/prototypes/mobile-app/tutor-grading.html @@ -0,0 +1,226 @@ + + + + + + Tutor grading — ClassroomIO Mobile + + + + + +
+ All screens + Tutor · Release 3 +

Grading queue

+

+ The Teach tab appears only when orgRoles[activeOrg] is ADMIN or TUTOR — one binary, a role-aware tab bar. + A single new endpoint, GET /account/grading-queue, gathers submissions awaiting grading across every + course the tutor owns, oldest first, so a backlog can be cleared between meetings. +

+ +
+ +
+ +
+ +
QueueOldest first, with waiting time as the primary signal and the submission type visible before you open it.
+
+ + +
+
+
+
+
+ + + +
+ + +
Question · 10 points
+
+ A colleague asks you to look up a patient record for a friend. Describe your response and the escalation path. +
+ +
+
Written answer
+
+ I would decline and explain that access has to be tied to a care relationship. I'd remind them that the audit + log records every lookup by user, so an access without a clinical reason is traceable. If they pressed, I'd + report it to the privacy officer the same day using the internal form. +
+
+ +
+
Video response · 0:48
+
+
+
0:000:48
+
+
+ +

Rubric

+
+
+
Declines the request
+ 3 / 3 +
+
+
Cites the audit trail
+ 3 / 3 +
+
+
Names the escalation path
+ 2 / 4 +
+
+ +
+ +
+ + / 10 · pass mark 7 +
+
+ +
+ + +
+ + Faster than typing on a phone +
+
+ + + +
+
+
+
+
ReviewAnswer, video, and rubric on one screen, with recorded audio feedback because typing a paragraph on a phone is the real blocker.
+
+
+ + + + +