18 KiB
18 KiB
Course Widget Embed Implementation Plan (v1)
Current State Alignment (2026-04-13)
- Existing third-party embed runtime already lives in
apps/embedsand is published as@cio/embeds; do not create a new@cio/widgetspackage. - Existing CDN publish flow already exists in
apps/embeds/scripts/upload-embeds.tsand.github/workflows/deploy-embeds.yml; extend that flow for course widgets instead of building a second publish pipeline from scratch. - Shared widget payload contracts should live in the existing validation layer (
packages/utils/src/validation/widget/widget.ts) so API, dashboard preview, and@cio/embedsall consume one schema/type source. - The closest current public data route is
GET /organization/courses/public, but it is not a dedicated fast embed payload path today:
- it does multiple DB round-trips (
getOrgIdBySiteName, published course count, published course list, tag hydration) - it selects full course rows rather than an embed-specific compact payload
- it does not currently emit cache headers or use a Redis-backed/public payload cache
- Because of that, public embeds should not rely on
/organization/courses/publicdirectly for first paint; v1 needs a dedicated published widget payload endpoint with an aggressive cache/precompute strategy.
Senja Reference Notes (2026-04-13)
- Senja separates widget management/data from widget assets:
- engine public route returns a compact widget bundle payload plus the current release-specific builder URL
- widget assets are versioned static files on CDN
- publish flips a release pointer instead of regenerating a public URL per widget
- Senja’s standard widget embed path is still a multi-step client flow:
platform.jsloads first- it fetches widget config JSON from
/api/widget-config/:id - it then loads the release-specific builder script from CDN
- it finally mounts the widget once both data and script are available
- That means Senja’s normal widget path is operationally clean, but not the absolute minimum-latency model for first render.
- Senja does use a more latency-efficient pattern for rich snippets: a single JS endpoint returns executable script with the data already embedded.
- For ClassroomIO, the useful parts to copy are:
- release-based static asset publishing
- a stable public widget key/URL that does not change on each publish
- a compact public bundle/payload contract
- The parts not worth copying directly for our first render path are:
- uncached
no-storewidget-config responses - always requiring a separate JSON fetch before widget render
- Preferred ClassroomIO direction:
- keep versioned CDN assets plus a release/manifest pointer
- make public widget payload responses cacheable at CDN/browser level
- if first-render performance becomes the top priority, consider a published widget JS endpoint with payload embedded for fully static widgets
Scope Lock
- Widget embed runtime extends the existing plain Svelte
@cio/embedsapp output (no SvelteKit runtime dependency in embedded widget). - Admin + Tutor can edit drafts; publish/rollback/delete remain admin-only.
- Org-scoped widgets only; no cross-org sharing.
- Required layout types in v1:
- cards
- table
- primary course
- carousel
- tag filter badges
- Editor route is
/org/[slug]/widgetswith create/edit flows. - Publish target is Cloudflare-hosted static assets + edge delivery.
- Dashboard live preview is local state -> iframe (no preview API endpoint).
- Public embed fetches server-computed payload from widget public endpoint.
- Both paths must use the same shared payload schema.
- Draft editing is manual-save only (no autosave in v1).
- Shadow DOM is the default isolation strategy; fallback to scoped classes only if critical issues arise.
- CSS sanitizer uses
css-treewith 5KB limit and whitelist validation. - Public embed payload fetch must use a dedicated fast path (published snapshot and/or cache), not the current generic org public courses route.
Delivery Phases
- Phase A: Data model + validation + DB query layer.
- Phase B: API services/routes (org + public payload).
- Phase C:
@cio/embedsextension (plain Svelte runtime + loader contract). - Phase D: Dashboard widgets feature (list, editor, preview, publish).
- Phase E:
@cio/embedspublish extension + observability + hardening.
Status Snapshot (2026-04-13)
Done
CW-A1DB widget schemasCW-A2widget query moduleCW-A3widget validation schemasCW-A4DB type exportsCW-B1widget service layerCW-B2org widget routesCW-B3public widget payload routeCW-B4route registrationCW-B6canonical payload builderCW-B7published snapshot fast path with cache headersCW-C1course widget runtime in@cio/embedsCW-C2required layout rendering in the current runtimeCW-C4loader/bootstrap interfaceCW-C6shared typed payload contractCW-D1dashboard widget request/response typesCW-D2dashboard widget API classCW-D3widgets list route/pageCW-D4editor route/pageCW-D5org sidebar navigation entryCW-D6English translation keysCW-D9manual save/discard flowCW-E1embed publish/build flow extension for a dedicated course-widget targetCW-E3base hardening for public key generation and CSS sanitization
Done (Session 2 — 2026-04-13)
CW-C3Shadow DOM isolation (widget.css extracted, shadow root created in embed-mount.ts with<cio-course-widget>custom element)CW-D8iframe preview pipeline (widget-iframe-preview.svelte component + /widget-preview dashboard route)CW-D10iframe protocol with READY/RENDER/ERROR/RESIZE, strict origin validation, 3-retry exponential backoffCW-D11data freshness controls (last synced timestamp, manual refresh button in editor)CW-E3rate limiting on public payload endpoint (100 req/min per IP, 1000 req/min per widget key)CW-E4widget validation + payload tests (47 tests passing via vitest)
Partial
CW-D12accessibilityCW-E2Cloudflare delivery policyCW-E5rollout hardening
Remaining
CW-B5analytics endpointCW-C5bundle size CI checksCW-D7richer plan-gating UX polish
Current Deviation From Plan
- The runtime bundle is within budget range after the build split, but there is no automated CI budget check yet.
Ticket Breakdown
| ID | Area | Task | Key Files | Dependencies | Done When |
|---|---|---|---|---|---|
| CW-A1 | DB | Add widget schemas (widget, widget_course, widget_version) |
packages/db/src/schema.ts |
None | Tables/migrations compile with org-scoped FKs and indexes |
| CW-A2 | DB Queries | Implement widget query module | packages/db/src/queries/widget/widget.ts |
CW-A1 | CRUD + version + selection queries implemented with error logging pattern |
| CW-A3 | Utils Validation | Add widget validation schemas | packages/utils/src/validation/widget/widget.ts + exports |
CW-A1 | Zod schemas cover create/update/publish/public payload |
| CW-A4 | DB Types | Export new widget types | packages/db/src/types.ts |
CW-A1 | Type generation/build includes widget entities |
| CW-B1 | API Service | Add widget service layer | apps/api/src/services/widget.ts |
CW-A2,CW-A3 | Role/org checks + business rules in service layer |
| CW-B2 | API Routes | Add org widget routes | apps/api/src/routes/organization/widgets.ts |
CW-B1 | CRUD/publish/rollback endpoints working |
| CW-B3 | API Routes | Add public widget payload route | apps/api/src/routes/widgets/widgets.ts |
CW-B1 | Public payload returns publish-safe data only |
| CW-B4 | API Register | Register routes in app tree | apps/api/src/routes/organization/index.ts, apps/api/src/app.ts |
CW-B2,CW-B3 | RPC + public routes reachable |
| CW-B5 | API Analytics | Track view/click events (optional gate) | apps/api/src/services/widget.ts + route |
CW-B3 | Event path available or clearly deferred behind feature flag |
| CW-B6 | API Compute Service | Add canonical payload builder for public embed | apps/api/src/services/widget-payload.ts, apps/api/src/routes/widgets/widgets.ts |
CW-B1 | Public payload route uses one compute function for courses + design config |
| CW-B7 | API Fast Path | Add fast published widget payload path with cache/precompute strategy | apps/api/src/services/widget-payload.ts, apps/api/src/routes/widgets/widgets.ts, apps/api/src/utils/redis/* |
CW-B6 | Public payload resolves from published snapshot and/or Redis cache, emits public cache headers, and avoids recomputing every request |
| CW-C1 | Embeds App | Add course widget runtime to existing @cio/embeds app |
apps/embeds/src/widgets/course-widget/*, apps/embeds/vite.config.ts, apps/embeds/README.md |
CW-A3 | @cio/embeds builds a publishable course-widget entry without SvelteKit |
| CW-C2 | Layouts | Implement 5 layout renderers in @cio/embeds |
apps/embeds/src/widgets/course-widget/layouts/* |
CW-C1 | All required layouts render from same config contract |
| CW-C3 | Design Tokens | Add config-to-style mapper + Shadow DOM isolation | apps/embeds/src/widgets/course-widget/styles/*, apps/embeds/src/embed.css |
CW-C1 | Host CSS does not break widget visuals; Shadow DOM encapsulation verified in tests |
| CW-C4 | Loader Contract | Add loader/bootstrap interface inside @cio/embeds |
apps/embeds/src/widgets/course-widget/bootstrap.ts, apps/embeds/src/shared/mount.ts |
CW-C1 | Loader can mount widget by payload id/key |
| CW-C5 | Size Budget | Add bundle size CI checks (loader ≤3KB, runtime ≤35KB gzip) | apps/embeds/package.json, CI workflow |
CW-C1 | CI fails when runtime exceeds target budgets; budget file in apps/embeds |
| CW-C6 | Renderer Contract | Add strict typed payload contract shared by dashboard, API, and embeds app | packages/utils/src/validation/widget/widget.ts |
CW-A3,CW-B6,CW-C1 | Renderer accepts canonical payload schema only |
| CW-D1 | Dashboard Types | Add request/response types for widget API | apps/dashboard/src/lib/features/widget/utils/types.ts |
CW-B4 | Types follow typeof classroomio... pattern |
| CW-D2 | Dashboard API | Add widget API class | apps/dashboard/src/lib/features/widget/api/widget.svelte.ts |
CW-D1 | All widget requests use this.execute<RequestType>() |
| CW-D3 | Widgets List Page | Add /org/[slug]/widgets list route/page |
apps/dashboard/src/routes/org/[slug]/widgets/+page.svelte, +page.server.ts |
CW-D2 | Admin can see/create/open widgets |
| CW-D4 | Editor Page | Add /org/[slug]/widgets/[widgetId] editor route/page |
apps/dashboard/src/routes/org/[slug]/widgets/[widgetId]/+page.svelte, +page.server.ts |
CW-D3,CW-D2 | Left panel tabs + live preview + save/publish works |
| CW-D5 | Sidebar Nav | Add widgets entry to org sidebar | apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts |
CW-D3 | Sidebar highlights widgets route correctly |
| CW-D6 | Translations | Add all widget UI copy keys | apps/dashboard/src/lib/utils/translations/en.json |
CW-D4 | No hardcoded user-facing strings |
| CW-D12 | Accessibility | Add ARIA labels, keyboard navigation, focus indicators | widget layouts + preview | CW-C2 | Widget passes axe-core automated checks |
| CW-D7 | Plan Gating UI | Add paid/free UI gate: 1 theme for free, all themes + custom colors for paid | widget feature pages + store | CW-D4 | Free users see 1 theme option with upgrade prompt; paid users see all themes + custom colors |
| CW-D8 | Live Preview Pipeline | Build local state -> iframe preview pipeline | apps/dashboard/src/routes/org/[slug]/widgets/[widgetId]/+page.server.ts, editor feature files, preview iframe files |
CW-C6 | Editing left-panel settings re-renders iframe preview without API roundtrip |
| CW-D9 | Manual Save UX | Add explicit save/discard workflow and dirty-state handling | editor feature files + stores | CW-D8 | No autosave; unsaved changes are preserved locally until user saves/discards |
| CW-D10 | Iframe Protocol | Implement iframe message protocol with origin/schema validation per spec in README | preview parent + iframe runtime files | CW-D8 | READY/RENDER/ERROR/RESIZE flow works with 3 retry attempts and strict origin checks |
| CW-D11 | Freshness Controls | Add data freshness states + manual refresh button in editor | editor route/feature files | CW-D8 | Editor shows Last synced, supports manual refresh, and handles stale warnings |
| CW-E1 | Publish Pipeline | Build widget artifact + upload through existing embeds pipeline | apps/embeds/scripts/upload-embeds.ts, .github/workflows/deploy-embeds.yml |
CW-C4,CW-B2 | Publish produces versioned URLs + manifest persisted |
| CW-E2 | Cloudflare Delivery | Configure edge/static delivery and cache policy | deployment config/docs | CW-E1 | Loader/assets served with immutable cache headers |
| CW-E3 | Hardening | Add CSS sanitizer (css-tree), key generation (base58), rate limiting | API service/routes | CW-B3 | Public endpoints pass security checks; CSS validator tests pass |
| CW-E4 | Tests | Unit/integration tests for config + publish + payload | API/db/embeds tests | CW-B4,CW-C2 | Core flows covered and passing in CI |
| CW-E5 | Rollout | Add feature flag + staged rollout checklist | env/config docs | CW-D4 | Safe enable/disable by environment |
API Contract (Target)
Org Protected
GET /organization/widgetsPOST /organization/widgetsGET /organization/widgets/:widgetIdPUT /organization/widgets/:widgetIdDELETE /organization/widgets/:widgetIdPOST /organization/widgets/:widgetId/publishPOST /organization/widgets/:widgetId/rollback
Public
GET /widgets/:publicKey/payloadPOST /widgets/:publicKey/events(if enabled in v1)
Widget Runtime Notes (Strict)
- Use plain Svelte components compiled via Vite library mode.
- Do not ship SvelteKit runtime artifacts in embed bundle.
- Keep dependency graph minimal (avoid heavy UI libraries in the embed runtime).
- Isolate styles (prefer Shadow DOM custom element or strict prefixed scoping).
- Renderer input type is the canonical payload contract shared across dashboard preview and public embed.
- Reuse the existing
apps/embedsbootstrap + dynamic import pattern instead of introducing a second embed runtime surface. - Public embeds should fetch a compact published widget payload; they should not call
/organization/courses/publicdirectly because that route is not tuned for low-latency embed rendering.
Dashboard UI Notes
- Follow existing route pattern:
- route file contains
Page.Root/Page.Header - feature page contains business logic
- Keep form controls using
@cio/uicomponents. - Store all user-facing strings in translations.
- Keep preview component using the same renderer used in the published
@cio/embedsentry. - Editor
+page.server.tsprovides initial data for local preview state (courses/tags/draft config). - Left-panel changes update local payload and send it to iframe via
postMessage(debounced/throttled). - Preview iframe should not fetch widget data; it only renders payload received from parent state.
- No autosave; user must click
Saveto persist draft changes. - Provide explicit
Discard changesaction for local draft reset.
Acceptance Criteria by Phase
Phase A
- Migration runs cleanly.
- Org isolation enforced at query/service level.
- Validation schemas compile in
@cio/utils. - CSS sanitizer config defined and testable.
Phase B
- Admin can CRUD and publish widget via API.
- Public payload endpoint returns only published widget versions.
- Unauthorized org access returns proper 403/404 behavior.
- Public payload endpoint uses canonical compute service for courses + design config.
- Public payload endpoint uses a dedicated fast path (published snapshot/cache) rather than recomputing from generic public course queries on every request.
- Public payload endpoint emits public cache headers suitable for CDN/browser reuse.
- CSS sanitization rejects malicious input in tests.
- Rate limiting enforced on public endpoints.
Phase C
@cio/embedsbuilds the course-widget entry independently.- Runtime meets bundle budget and renders all required layouts.
- Host-site CSS does not break widget UI in smoke tests (Shadow DOM verified).
- Renderer consumes only canonical computed payload type.
- Accessibility: keyboard navigation and ARIA labels implemented.
- Bundle size CI check fails builds exceeding budget.
Phase D
- Admin can create/edit/publish from
/org/[slug]/widgets. - Tutors can edit drafts from
/org/[slug]/widgetseditor. - Editor left panel has
Select Courses,Widgets,Design. - Live preview updates as settings change using local state passed to iframe.
- Embed snippet is copyable from dashboard.
- Plan gates visibly enforce free vs paid restrictions (disabled with tooltips).
- Editing is manual-save only; no background persistence happens.
- Iframe protocol is stable (
READY/RENDER/ERROR/RESIZE) with strict origin validation and retry logic. - Data freshness UI supports manual refresh and stale-state warnings.
- Accessibility: widgets pass axe-core automated checks.
Phase E
- Publish creates versioned immutable artifact URLs in Cloudflare R2.
- Rollback switches manifest pointer to previous version without data loss.
- Monitoring/logs show publish failures and public payload errors.
- Artifact cleanup policy (10 versions kept) verified.
- Cache headers correctly configured for all asset types and public widget payload responses.
Verification Commands
pnpm --filter @cio/utils buildpnpm --filter @cio/db buildpnpm --filter @cio/api buildpnpm --filter @cio/embeds buildpnpm --filter @cio/dashboard build- Run widget API and embeds tests (unit/integration).
Suggested First Sprint Slice
- CW-A1 to CW-A4 (DB + validation foundation)
- CW-B1 to CW-B6 (API services + routes + payload compute)
- CW-C1 + CW-C6 (
@cio/embedsscaffold + payload contract) - CW-D3 + CW-D8 + CW-D9 (widgets list + preview pipeline + manual save skeleton)
This slice creates the core contracts and route surface so editor/publish can iterate quickly afterward.
Definition of Ready per Ticket
Each ticket must include:
- Data flow diagram (if applicable)
- Interface/type definitions
- Error scenarios and handling
- Test plan (unit + integration)
Definition of Done per Phase
- All builds pass (
pnpm --filter @cio/* build) - Bundle size check passes (Phase C+)
- Security review passed (Phase B+)
- Accessibility smoke test passed (Phase D+)
IMPORTANT
- FOLLOW TO THE LETTER AGENTS.md
- Structure UI components similar to how we build other page UIs and don't use too much borders