* chore: update README with environment setup instructions and demo account credentials fix: replace rm -rf with rimraf in build scripts for cross-platform compatibility fix: add shell option to pnpm command in db-setup script for Windows compatibility chore: update package.json files to use rimraf instead of rm -rf for consistency chore: add rimraf dependency to pnpm-lock.yaml feat: add postinstall script to install lefthook git hooks in a cross-platform manner * docs(qa): add QA test plan, flow checklists, and bug-report workflow Add a repo-native QA execution layer on top of FEATURE_AUDIT.md (the feature map). Test cases live in the repo; bugs live in GitHub Issues, so there is no duplicate tracker to keep in sync. QA system: - FEATURE_AUDIT.md: the feature map - every feature, its user flow, and edge cases with file:line citations. - qa/README.md: the workflow, severity/difficulty rubrics, label setup, and a static feature -> flow coverage map proving all 42 audited features have a test plan. - qa/flows/01..13: per-flow happy-path checklists covering signup & onboarding, course authoring, people/invites/roles, student -> certificate, AI assistant & tutor, programs/attendance, community, org public site, media & import, org settings, public API/MCP, enterprise SSO/licensing, and billing & AI credits. - .github/ISSUE_TEMPLATE/qa-bug.yml: a QA Bug template requiring severity, difficulty, area, repro steps, and a screen recording. Dev tooling: - package.json: add db:studio and docker:build scripts. - packages/db: add db:studio (drizzle-kit studio); fix db:reset on Windows by spawning the pnpm.cmd shim with shell:true (Node's CVE-2024-27980 fix blocks spawning .cmd/.bat without a shell). * chore: update QA bug template and README for clarity and improved workflow * docs: enhance DEV_SETUP_NOTES and README for clarity on prerequisites and email setup chore: update package.json scripts for cross-platform compatibility feat: add cross-platform Prettier check script for pre-commit hook * Remove QA documentation and flow files for ClassroomIO - Deleted the entire QA README.md file which contained the QA process, bug filing guidelines, and feature coverage map. - Removed all flow files (01 to 13) that detailed the QA testing processes for various features including admin signup, course authoring, people management, student learning, AI assistant, programs, community, org public site, media management, org settings, public API, enterprise SSO, billing, and AI credits. * fix: update postinstall script
@cio/embeds
Standalone ES module bundles for third-party sites (no dependency on apps/website). The question-type-picker entry shows a small loading spinner (same style as @cio/ui Spinner) as soon as the bootstrap script runs, then loads the main widget chunk—so slow connections see feedback while the larger bundle downloads.
Question type picker
Build outputs dist/question-type-picker.js (bootstrap) plus additional question-type-picker-*.js chunks. Upload every .js file from dist/ to the same directory on your CDN so dynamic import() resolves correctly.
CSS for the widget is injected when the main chunk runs.
Develop
pnpm --filter @cio/embeds dev
Open the dev harness at http://localhost:5180 (see root index.html).
Build
pnpm --filter @cio/embeds build
prebuild runs @cio/ui Tailwind generation so @cio/ui/output.css exists for src/embed.css.
Deploy to Cloudflare R2
Built files are uploaded with the same S3-compatible credentials and assets bucket used for Storybook. Objects live under a per-widget prefix so future embeds do not clash:
| Widget | R2 key prefix |
|---|---|
| Question type picker | embeds/question-type-picker/ |
After upload, every file from dist/ appears at embeds/question-type-picker/<filename>. The bootstrap script is always question-type-picker.js; lazy-loaded chunks use content hashes in the filename (for example question-type-picker-embed-mount-7a3b2c1d.js) so they can be cached for a long time. The upload script sets a short TTL + must-revalidate on the bootstrap only, so a normal refresh picks up new chunk URLs after you deploy. If you still see an old UI, hard-refresh or purge your CDN cache for question-type-picker.js.
Map your public CDN or custom domain to this bucket (or path) so those URLs are reachable over HTTPS.
Environment variables
Set these locally (e.g. .env in apps/embeds, loaded by the upload script) or in CI:
CLOUDFLARE_ACCESS_KEYCLOUDFLARE_SECRET_ACCESS_KEYCLOUDFLARE_ACCOUNT_ID
CDN cache purge (optional): R2 is only storage; if assets.cdn.clsrio.com (or your public host) is proxied through Cloudflare, edge cache can still serve old bytes after upload. The upload script can call Cloudflare’s Purge Cache API for every file URL it just published.
CLOUDFLARE_API_TOKEN— API token with Zone → Cache Purge → Purge on the zone that serves the asset hostname (not the R2 S3 access key).CLOUDFLARE_ZONE_ID— that zone’s ID (dashboard → site → Overview → API).EMBED_CDN_BASE_URL— public origin for URLs to purge, defaulthttps://assets.cdn.clsrio.com(no trailing slash). Override if your embeds are served under another host.EMBED_SKIP_CDN_PURGE=1— skip purge (e.g. local testing).
If CLOUDFLARE_API_TOKEN or CLOUDFLARE_ZONE_ID is missing, upload still succeeds and purge is skipped.
Manual publish (build + upload)
From the repo root:
pnpm --filter @cio/embeds run embeds:publish
Or from apps/embeds after a build:
pnpm upload:embeds
pnpm upload:embeds only uploads dist/; run pnpm build first if needed.
GitHub Actions
Workflow: .github/workflows/deploy-embeds.yml.
- When it runs: push to
mainthat touchespackages/ui/**,packages/question-types/**, orapps/embeds/**, or workflow_dispatch. - What it does:
pnpm i, thenpnpm --filter @cio/embeds run embeds:publish. - Secrets: the job uses the
productionenvironment; configure the R2CLOUDFLARE_ACCESS_KEY,CLOUDFLARE_SECRET_ACCESS_KEY, andCLOUDFLARE_ACCOUNT_IDthere. Optionally addCLOUDFLARE_API_TOKENandCLOUDFLARE_ZONE_IDso each deploy purges the Cloudflare CDN cache for the published embed URLs. Optional repo variableEMBED_CDN_BASE_URLoverrides the default public base URL used when building purge URLs.
Embed on a site
The bootstrap file question-type-picker.js loads additional chunks via dynamic import(). Browsers resolve those URLs relative to the bootstrap script’s URL, so every file from dist/ must be served from the same directory (same path prefix on your CDN). Do not host only the bootstrap file.
Production (CDN / R2 public URL)
Use your public base URL for the picker folder—for example:
https://<your-public-host>/embeds/question-type-picker/
Then reference the bootstrap script under that path.
Auto-mount (looks for [data-cio-widget="question-type-picker"]):
<div data-cio-widget="question-type-picker"></div>
<script type="module" src="https://<your-public-host>/embeds/question-type-picker/question-type-picker.js"></script>
Manual mount (no data-cio-widget on that node—avoid double-mounting):
<div id="qtp"></div>
<script type="module">
await import('https://<your-public-host>/embeds/question-type-picker/question-type-picker.js');
const el = document.getElementById('qtp');
if (el) window.CIO?.questionTypePicker?.mount(el);
</script>
Local or any static host
If you copy all of dist/ to a single URL prefix, set src / import() to that prefix + question-type-picker.js (same pattern as above, with your host and path).
Use a type="module" script in all cases; the bundle is ES modules with code-splitting.
Local smoke test
From apps/embeds, after pnpm build, serve the folder and open example-embed.html:
pnpm build && npx serve . -p 5190
# open http://localhost:5190/example-embed.html
Adding another widget
- Add
src/widgets/<name>/widget.svelteand a bootstrap entry (same pattern asquestion-type-picker). - Add a dedicated
build.lib.entryor Vite config if you need a separate publishable script.
Course widget host split
The course widget uses three different URL concerns:
- Embed script host
- this is where the browser downloads
course-widget.js - production script URL:
https://embed.classroomio.com/course-widget - production hosted iframe:
https://embed.classroomio.com/course-widget?key=<public-key> - configured on the API side with
PUBLIC_EMBED_BASE_URL
- Payload API host
- this is where the widget fetches
GET /widgets/:publicKey/payload - production: tenant-router injects
window.CIO.apiBaseUrlfrom itsAPI_URLenv when serving embed assets - local dev:
@cio/embedsVite injects the same global fromAPI_URL(defaults tohttp://localhost:3002)
- Course destination host
- this is where end users are sent when they click a course
- it comes from the organization public site or custom domain, not the API host
These are intentionally separate. The widget fetches data from the API host, but links users to the organization’s public course pages.
Course widget local development
Local development should not depend on deployed CDN assets.
Expected local hosts
- embed script host:
PUBLIC_EMBED_BASE_URLon the API (falls back tohttp://localhost:5180in dev) - payload API host:
API_URLwhen running embeds dev or tenant-router (not included in customer copy-paste code)
Generated snippet:
<div data-cio-widget="course-widget" data-widget-key="wgt_xxx"></div>
<script async type="module" src="https://embed.classroomio.com/course-widget"></script>
Dev server path shape
The embeds Vite dev server rewrites these stable production-style paths:
/embeds/question-type-picker/question-type-picker.js/embeds/course-widget/course-widget.js
to the underlying source entry files during development.
That means dashboard-generated widget embed snippets can use the same path shape in local development and production without requiring the CDN to exist first.