Ship an organization-scoped widget system so admins can embed selected courses on external websites from `/org/[slug]/widgets`, with a visual editor and lightweight runtime.
## Confirmed Decisions (2026-02-17)
1. Editor route is in dashboard org context: `/org/[slug]/widgets` (user-facing equivalent: `/org/id/widgets`).
2. Embed runtime must be plain Svelte (no SvelteKit in the embed runtime path) for minimal JS payload.
3. Widget rendering supports multiple layouts:
- cards
- table
- primary single course
- carousel
- tag-filtered list (tags as top badges)
4. Widget authoring UI opens an editor with left panel sections:
-`Select Courses`
-`Widgets`
-`Design`
5. Published widgets deploy to Cloudflare-backed static hosting/edge delivery (R2 + Workers).
8. Public embed fetches a dedicated published widget payload from server endpoints.
## Current State Alignment (2026-04-13)
1. The existing third-party embed runtime already lives in `apps/embeds` and is published as `@cio/embeds`; this feature should extend that app rather than introduce a new `@cio/widgets` package.
2. The existing embed publish path already lives in `apps/embeds/scripts/upload-embeds.ts` and `.github/workflows/deploy-embeds.yml`; the course widget should plug into that release flow.
3. The closest current public course route is `/organization/courses/public`, but it is not suitable as the widget first-render path because it does multiple DB round-trips and has no dedicated public cache strategy.
4. Public widget rendering therefore needs a dedicated published payload fast path:
- compute canonical payload on publish
- persist it with the published widget version
- serve it from a compact public endpoint with CDN/browser cache headers
5. If first-render latency later becomes the top priority, a single published JS endpoint with payload embedded is a valid follow-on optimization, but v1 should start with cached published JSON payload + static `@cio/embeds` assets.
## Implementation Status (2026-04-13)
### Completed
1. Shared widget validation and canonical payload contracts were added in `packages/utils/src/validation/widget/widget.ts`.
2. Widget persistence was added in the DB layer for `widget`, `widget_course`, and `widget_version`.
3. Widget query helpers were added in `packages/db/src/queries/widget/widget.ts`.
4. Organization widget API routes are implemented for list, create, detail, update, archive, publish, and rollback.
5. Public payload delivery is implemented at `GET /widgets/:publicKey/payload`.
6. Widget payload computation, public key generation, embed code generation, and CSS sanitization were added in `apps/api/src/services/widget-payload.ts`.
7. Dashboard widgets UI is implemented at `/org/[slug]/widgets` and `/org/[slug]/widgets/[widgetId]`.
8. The dashboard includes a widget list page, editor page, local live preview, save/discard flow, publish and rollback actions, embed code copy flow, and an org sidebar navigation entry.
9. A course widget runtime was added to `apps/embeds/src/widgets/course-widget/*`.
10. Embed publishing/build plumbing was extended so `question-type-picker` and `course-widget` build and upload independently.
11. The course widget bundle was reduced back into the intended budget range after splitting embed builds per target:
- loader: `6.41 kB` (`2.20 kB` gzip)
- runtime chunk: `80.03 kB` (`22.35 kB` gzip)
12. Shadow DOM isolation was implemented for the public embed runtime:
- Widget CSS extracted to `widget.css` and injected into shadow root
- Custom element `<cio-course-widget>` with `attachShadow({ mode: 'open' })`
- Host page CSS cannot break widget visuals
13. Dashboard preview now uses an iframe + postMessage pipeline:
- Preview route at `/widget-preview` renders the widget component
-`widget-iframe-preview.svelte` handles the parent-side protocol
1. Bundle-size enforcement in CI is not implemented yet; the runtime is small enough now, but there is no automated budget gate.
2. Analytics ingestion is still deferred.
3. Accessibility work is partial:
- focus-visible states and basic labels exist in the runtime
- there is no dedicated axe-core or accessibility verification pass yet
4. Translation coverage is only updated in `en.json`; other locales were not expanded.
5. Manual end-to-end publish smoke testing on a real embedded host page is still pending.
6. Plan-gating UX polish (disabled state tooltips) not yet done.
7. Cloudflare delivery policy automation not yet configured.
8. Feature flag for controlled rollout not yet implemented.
### Important Note
The code path is implemented, but the database portion still depends on the corresponding SQL migration being applied anywhere this feature is expected to run against a real database. Updating `schema.ts` alone is not enough for runtime availability.
Organizations currently show courses mainly on their own landing pages. There is no first-class way to publish reusable, brandable, copy-paste course widgets for external websites.
This creates gaps:
- No reusable embed snippet per organization.
- No dashboard-managed widget lifecycle (draft, publish, version rollback).
- No lightweight runtime optimized for third-party pages.
- No structured design controls for layout/theme/branding.
## Product Goals
1. Let org admins create and publish embeddable course widgets.
2. Keep embed runtime lightweight and isolated from host-site CSS/JS conflicts.
3. Reuse existing course and tag data models for content selection/filtering.
4. Provide clear plan-gated design controls (free vs paid capabilities).
5. Support versioned publishing with safe rollbacks.
- Public embed fetches a published payload snapshot from a dedicated widget endpoint; it must not recompute from generic live course queries on every request.
- **Free users**: Show paid features as disabled with upgrade tooltip
- Theme selector shows 1 option with "Upgrade to unlock more themes" on hover
- Custom color picker disabled with "Upgrade to customize colors" tooltip
- Custom CSS textarea shows lock icon with upgrade CTA
- "Powered by Classroomio" toggle is locked ON with tooltip
- **Paid users**: Full access to all controls, branding toggle available
## Open Questions (Resolved)
1. ✅ **Free plan color tweaks** – **1 preset theme only** (default). Paid users get all preset themes (3-5) plus custom color picker.
2. ✅ **Events endpoint** – Deferred to Phase 2; schema reserved but not implemented in v1.
3. ✅ **Locale detection** – Deferred to Phase 2; widgets use single-language labels from org default locale in v1.
4. ✅ **CSS isolation** – **Shadow DOM custom element** preferred for strongest isolation. Fallback to scoped classes only if Shadow DOM causes critical theming issues during Phase C testing.
## Additional Architecture Decisions
### Widget Key Format
- Format: `wgt_<base58(16-bytes-random)>`
- Example: `wgt_3J98t1WpEZ73CNmYv`
- Includes implicit typo detection via base58 checksum behavior
- Length: 23 characters (prefix + 16 chars)
### Rollback Mechanics
- Rollback is **pointer-swap**, not append-only
- `latest_published_version_id` points to any previous version
- No new version created on rollback
- Analytics remain tied to version ID (history preserved)
- UI shows version history (read-only) with "Restore to this version" action
### Caching Strategy
- Public payload endpoint: `Cache-Control: public, max-age=60, stale-while-revalidate=300`
1. Admin can publish a widget and embed it externally in under 5 minutes.
2. Embed snippet works across static HTML, React, and WordPress hosts.
3. Runtime stays within performance budget and has no host CSS collisions.
4. Support tickets for “embed broken by host CSS” remain near zero.
5. Dashboard live preview matches public embed rendering for the same payload and version.
6.**Accessibility**: Widgets are keyboard-navigable (Tab order, Enter/Space to activate CTAs) and screen-reader friendly (ARIA labels on course cards, alt text on images).
7.**Security**: Zero XSS vectors through custom CSS or malformed payloads in penetration testing.