chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+758
View File
@@ -0,0 +1,758 @@
# Course Widget Embed PRD (Org Widgets)
## Status
- Done (shipped)
## Purpose
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).
6. Dashboard editing reuses the existing `@cio/embeds` renderer in dev mode for real preview parity.
7. Dashboard preview does not call a preview endpoint; it uses already-fetched dashboard state and passes payload into an iframe.
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
- `preview-protocol.ts` defines typed message contract (`READY/RENDER/ERROR/RESIZE`)
- Strict origin validation and exponential backoff retry (3 attempts)
14. Data freshness controls were added to the widget editor:
- "Last synced" timestamp display
- Manual "Refresh" button to reload widget data from server
- `onSynced` callback from iframe when render completes
15. Rate limiting was added to the public widget payload endpoint:
- 100 req/min per IP address
- 1000 req/min per widget key
- Uses existing `createRateLimiter` middleware with Redis-backed limiter
16. Widget validation and payload tests were added (47 tests passing):
- Zod schema tests for create/update/config/public-key
- Plan gating tests (free vs paid theme/colors/CSS/branding)
- Base58 encoding and key generation tests
- Embed code generation tests
### Verified
1. `pnpm --filter @cio/utils build`
2. `pnpm --filter @cio/api build`
3. `pnpm --filter @cio/dashboard build`
4. `pnpm --filter @cio/embeds build`
5. `node --import tsx apps/embeds/scripts/upload-embeds.ts --help`
### Remaining / Not Yet Implemented
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.
## Problem Statement
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.
## Non-Goals (v1)
- Full website/page builder replacement.
- Server-rendered widget runtime.
- Cross-org shared widget templates marketplace.
- Arbitrary third-party JS execution inside widgets.
## Data Sources Checked
- `apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts`
- `apps/dashboard/src/routes/org/[slug]/courses/+page.server.ts`
- `apps/dashboard/src/routes/org/[slug]/tags/+page.server.ts`
- `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
- `apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte`
- `apps/dashboard/src/lib/features/course/components/card.svelte`
- `apps/api/src/routes/organization/organization.ts`
- `apps/api/src/services/organization.ts`
- `packages/db/src/schema.ts`
- `packages/db/src/queries/course/course.ts`
- `packages/db/src/queries/tag/tag.ts`
- `packages/utils/src/plans/constants.ts`
- `packages/course-app/README.md`
## User Roles
### Admin (`ROLE.ADMIN`)
- Create/edit/delete/archive widgets.
- Select courses and widget layout.
- Configure design controls and plan-gated options.
- Publish widget versions and rollback.
- Copy embed snippet.
### Tutor (`ROLE.TUTOR`)
- Create/edit widget drafts and use live preview.
- No publish, rollback, or destructive lifecycle actions in v1.
### Student/Public
- No dashboard access.
- Consume published embed on external sites.
## Functional Requirements
### 1. Widget Lifecycle
- Create widget with:
- `name`
- `layoutType`
- selection mode (`manual` course selection or `published courses`)
- initial design config
- Status model:
- `DRAFT`
- `PUBLISHED`
- `ARCHIVED`
- Save model:
- manual save only (`Save` button)
- no autosave in v1
- Publish creates immutable version snapshot.
- Rollback can point latest published to a previous version.
### 2. Widget Editor UX (`/org/[slug]/widgets/:widgetId`)
- Left panel tabs:
- `Select Courses`
- `Widgets`
- `Design`
- Main panel:
- live preview using the same `@cio/embeds` renderer contract as public embed
- desktop/mobile preview toggle
- embed code tab
- Live preview behavior:
- every design/content change updates a local draft state
- dashboard uses already-fetched state (courses/tags/widget draft) to build preview payload
- preview payload is passed down through iframe (`postMessage`) to preview runtime
- iframe mounts the same widget component contract used by public embed
- changes are preview-only until user clicks `Save`
### 3. Select Courses
- Source modes:
- manual selected courses
- all published org courses
- Filters:
- tag include/exclude using existing org tags
- search by course title
- Ordering:
- manual order
- newest
- title A-Z
### 4. Widget Layout Types
- `cards`
- `table`
- `primary_course`
- `carousel`
- `tag_filter`
Each layout supports required shared props:
- `showCourseImage`
- `showCourseTypeBadge`
- `showLessonsCount`
- `showPrice`
- `showDescriptionExcerpt`
- `ctaLabel`
### 5. Design Controls
Design panel is grouped as:
#### Content
- limit courses toggle + max count
- load more label
- hide load more toggle
- show border toggle
- border width
- shadow size + style + color
- border radius scale
- show metadata toggles (lessons/type/price)
- shorten long description toggle
- show highlighted/featured style toggle
#### Colors
- primary color
- background color
- text color
- badge/rating color
- border color
- highlight color
#### Typography
- font family selector
- font size scale
#### Filters
- add/remove tag filter rows
- top badge style for tag filter layout
#### Branding
- `Show Classroomio Powered By` toggle
#### Advanced
- custom CSS textarea (sanitized + size-limited)
### 6. Plan Gating
- Free (`PLAN.BASIC`):
- **1 preset theme only** (default Classroomio theme)
- branding forced on
- custom CSS disabled
- Paid (`PLAN.EARLY_ADOPTER`, `PLAN.ENTERPRISE`):
- **All preset themes available** (3-5 curated themes)
- full color controls (custom colors)
- branding toggle available
- custom CSS enabled
### 7. Public Embed Contract
- Copy-paste embed pattern:
- `<div data-cio-widget="course-widget" data-widget-key="wgt_xxx"></div>`
- `<script async type="module" src="https://<public-embed-host>/embeds/course-widget/course-widget.js"></script>`
- Bootstrap script loads the existing `@cio/embeds` course-widget entry.
- Runtime fetches the compact published widget payload via public key and mounts the widget.
- Host page should not require framework dependencies.
- Payload shape must match dashboard preview payload contract.
### 8. Runtime Constraints (Lightweight Requirement)
- Plain Svelte runtime delivered from the existing `apps/embeds` app output.
- No SvelteKit runtime in embed bundle.
- Use style isolation: **Shadow DOM custom element** (`<widget-root>`) with `:host` selector support.
- Performance budgets:
- loader <= 3KB gzip
- base runtime <= 35KB gzip (excluding fetched data)
- first render <= 1.2s on mid-tier 4G for <= 6 courses
- Accessibility:
- All interactive elements keyboard accessible
- ARIA labels on course cards (`aria-label="Course: ${title}"`)
- Alt text on course images
- Focus visible indicators
### 9. Security and Safety
- Public widget key must be non-guessable (format: `wgt_<base58>`).
- Custom CSS sanitized with `css-tree` parser (whitelist approach):
- Max size: 5KB
- Reject: `@import`, `javascript:`, expression(), behavior:, binding:, `-moz-binding`
- Reject properties: `position: fixed` (can overlay host page maliciously)
- Allowed: standard visual styles (color, spacing, typography, flex/grid layout)
- Public endpoint returns only publish-approved fields (no internal org data).
- Rate limit public widget payload endpoint: 100 req/min per IP, 1000 req/min per widget key.
- iframe origin validation: strict `targetOrigin` matching dashboard domain.
### 10. Analytics (Schema Reserved for Phase 2)
**Deferred to Phase 2**, but schema defined for forward compatibility:
```typescript
// Table: widget_analytics_event
{
id: string;
widget_id: string;
widget_version_id: string;
event_type: 'impression' | 'cta_click' | 'filter_interaction';
course_id?: string; // for cta_click events
filter_tag_id?: string; // for filter_interaction events
session_id: string; // anonymous session fingerprint
timestamp: Date;
user_agent_hash: string; // privacy-preserving hash
country_code?: string; // from CF-IPCountry header
}
```
- Ingestion: Batch insert every 30 seconds or 1000 events
- Retention: 90 days raw, aggregated forever
- v1: No events endpoint; schema created but not populated
### 11. Shared Payload Contract (Hard Requirement)
- One canonical payload schema defines widget data:
- selected courses
- resolved ordering/filters
- normalized design tokens and defaults
- plan-gated fields
- Dashboard preview builds payload client-side from already-fetched state and passes it to iframe.
- Public embed fetches a published payload snapshot from a dedicated widget endpoint; it must not recompute from generic live course queries on every request.
- The embed renderer accepts only this payload schema + minimal runtime options.
- No separate dashboard-only renderer data shape is allowed.
### 11. Cloudflare Publish Pipeline (Phase E)
#### Artifact Generation
1. **Build Trigger**: API call `POST /organization/widgets/:widgetId/publish`
2. **Build Process**:
```
a. API receives publish request
b. Compute final payload with resolved courses + design config
c. Persist payload snapshot to `widget_version`
d. Build or reuse the current `@cio/embeds` course-widget runtime release
e. Upload immutable static assets to R2 under `embeds/course-widget/`
f. Purge/bootstrap refresh via existing embeds deployment flow
g. API saves the published payload snapshot and runtime release metadata to `widget_version`
```
#### Artifact Manifest Format
```json
{
"version": "v1",
"widgetId": "wgt_xxx",
"orgId": "org_yyy",
"createdAt": "2026-02-19T12:00:00Z",
"runtime": {
"entryUrl": "https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js",
"release": "2026-02-19-1"
},
"payload": {
"schemaVersion": "v1",
"cacheKey": "widget:payload:wgt_xxx:v3"
},
"designHash": "sha256:abc..."
}
```
#### Cleanup Policy
- Keep last 10 versions per widget
- Delete older artifacts after 30 days (R2 lifecycle rule)
- Soft-delete in DB (set `deleted_at`), hard-delete after 90 days
#### Delivery Architecture
```
User's Website Cloudflare
| |
|-- script src="course-widget.js"->|-- Serve static `@cio/embeds` asset
|<-- course-widget.js ------------|
| |
|-- runtime fetches payload ----->|-- Validate widget key
| |-- Check CDN/cache
| |-- Cache miss: read published payload snapshot
|<-- payload JSON ----------------|
| |
|-- runtime mounts widget ------->|-- Static assets already cached/immutable
```
---
## Proposed Architecture
### Package and App Boundaries
1. `apps/embeds`
- plain Svelte runtime entrypoints for third-party sites
- layout implementations for course widget
- shared mount/bootstrap logic
- build output: browser-ready ESM bundle + hashed chunks
2. `apps/dashboard`
- widget editor UI and preview
- imports or mirrors the same renderer contract used by `@cio/embeds` in dev/prod preview
3. `apps/api`
- org-protected widget CRUD/publish routes
- public widget payload route
- shared `computeWidgetPayload(...)` service used during publish and for controlled cache rebuilds
4. Cloudflare publish target
- stores versioned embed assets
- serves bootstrap/chunks with immutable cache headers
- serves compact public payload with shorter CDN/browser cache headers
### Render Data Flow
1. Dashboard editor loads widget draft and related data into state (courses/tags/config).
2. Left panel changes update local draft JSON in state.
3. Dashboard builds preview payload from state and sends it to preview iframe via `postMessage`.
4. Preview iframe mounts the same renderer contract used by `@cio/embeds`.
5. Publish stores a payload snapshot; public runtime requests the published payload for the current version and mounts the same renderer.
### Data Model (Proposed)
1. `widget`
- `id`, `organization_id`, `name`, `status`, `layout_type`
- `selection_mode`, `public_key`, `latest_published_version_id`
- `created_by`, `updated_by`, timestamps
2. `widget_course`
- `widget_id`, `course_id`, `order`
3. `widget_version`
- `id`, `widget_id`, `version`
- `config_snapshot` (jsonb)
- `payload_snapshot` (jsonb: canonical public payload)
- `runtime_manifest` (jsonb: embed entry URL + release metadata)
- `published_at`, `published_by`
## API Contract Additions (Target)
### Organization-Scoped (auth + org membership)
1. `GET /organization/widgets`
2. `POST /organization/widgets`
3. `GET /organization/widgets/:widgetId`
4. `PUT /organization/widgets/:widgetId`
5. `DELETE /organization/widgets/:widgetId`
6. `POST /organization/widgets/:widgetId/publish`
7. `POST /organization/widgets/:widgetId/rollback`
### Public
1. `GET /widgets/:publicKey/payload`
2. `POST /widgets/:publicKey/events` (optional in v1, can be deferred)
## UX Flow
1. Admin opens `/org/[slug]/widgets`.
2. Clicks `Create Widget`.
3. Editor opens with left tabs (`Select Courses`, `Widgets`, `Design`).
4. Admin previews and adjusts design.
5. Admin publishes, receives embed snippet.
6. External website adds script tag; widget renders courses.
### Plan Gating UX Pattern
- **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`
- Widget JS/chunk artifacts: `Cache-Control: public, max-age=31536000, immutable`
- Bootstrap script: short TTL + revalidation so new chunk references propagate quickly after publish
## Expanded TODOs (Priority)
### TODO-2: Iframe Protocol Spec (Detailed)
#### Message Types
```typescript
// Parent → Iframe
type ParentMessage =
| { type: 'WIDGET_PREVIEW_RENDER'; payload: WidgetPayload; requestId: string }
| { type: 'WIDGET_PREVIEW_PING' }; // health check
// Iframe → Parent
type IframeMessage =
| { type: 'WIDGET_PREVIEW_READY'; iframeId: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_RENDERED'; requestId: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_ERROR'; requestId: string; error: string; timestamp: number }
| { type: 'WIDGET_PREVIEW_RESIZE'; height: number; width?: number }
| { type: 'WIDGET_PREVIEW_CTA_CLICK'; courseId: string }; // for preview interactivity
```
#### Payload Envelope
```typescript
interface WidgetPayload {
version: 'v1';
widgetId: string;
layoutType: 'cards' | 'table' | 'primary_course' | 'carousel' | 'tag_filter';
courses: Course[];
design: DesignConfig;
planGatedFields: PlanGatedFields;
timestamp: number; // epoch ms for cache-busting
}
```
#### Security Rules
1. **Origin Validation**:
- Production: `targetOrigin` must match `VITE_DASHBOARD_URL` (e.g., `https://app.classroomio.com`)
- Development: allow `http://localhost:*` with port wildcard
- Never use `*` in production
2. **Message Validation**:
- Reject messages without `type` field
- Reject unknown message types
- Validate payload schema with Zod before processing
3. **iframe Sandboxing**:
- Use `sandbox="allow-scripts"` attribute
- No `allow-same-origin` (prevents cookie access)
#### Reliability Rules
1. **Handshake Flow**:
```
Parent: mount iframe → wait for READY (timeout: 5s)
Iframe: mount complete → send READY
Parent: receive READY → send RENDER with payload
Iframe: render complete → send RENDERED or ERROR
```
2. **Retry Logic**:
- READY timeout: 3 retry attempts, exponential backoff (1s, 2s, 4s)
- RENDERED timeout: 2 retry attempts, show error state in parent
3. **Error Handling**:
- Render errors: Show fallback UI in parent ("Preview unavailable")
- Log error details to console with `[WidgetPreview]` prefix
- Surface user-friendly error in editor UI
#### Sequence Diagram
```
Parent (Dashboard) Iframe (Widget Runtime)
| |
|--- mount iframe ------------>|
| |
|<-- WIDGET_PREVIEW_READY -----|
| (iframe ready) |
| |
|--- WIDGET_PREVIEW_RENDER --->|
| (payload + config) |
| |
|<-- WIDGET_PREVIEW_RESIZE ----|
| (dynamic height) |
| |
|<-- WIDGET_PREVIEW_RENDERED --|
| OR WIDGET_PREVIEW_ERROR |
```
#### Documentation Output
- Spec file: `prd/course-widget-embed [DONE]/iframe-protocol.md` (this section)
- Implementation: `apps/embeds/src/widgets/course-widget/preview/iframe-bridge.ts` + `apps/dashboard/src/lib/features/widget/preview/iframe-parent.ts`
### TODO-3: Preview Data Freshness (Implementation Details)
#### Freshness States
| State | Indicator | User Action | Behavior |
|-------|-----------|-------------|----------|
| `fresh` | Green dot + "Live" | None | Data synced with server within last 5 minutes |
| `stale` | Yellow dot + "Refresh suggested" | Manual refresh button | Data older than 5 minutes or tab was backgrounded |
| `refreshing` | Spinner + "Syncing..." | Disabled | Fetch in progress |
| `error` | Red dot + "Sync failed" | Retry button | Last fetch failed, using cached data |
#### Implementation Rules
1. **Initial Load**: `+page.server.ts` provides hydrated data with `serverTimestamp`
2. **Stale Detection**:
- Check `Date.now() - lastSyncTime > 5 * 60 * 1000` on mount
- Listen to `visibilitychange` event: when tab becomes visible, check staleness
3. **Background Sync**:
- Use `navigator.sendBeacon` or `fetch` with `keepalive` for refresh
- Debounce refresh calls (minimum 30s between requests)
4. **Warning UX**:
- Non-blocking toast when stale data detected
- Course/tag removed warning appears in preview area (not blocking)
5. **Consistency**:
- Draft state is never auto-refreshed; only source data (courses/tags list)
- User must explicitly refresh to see new courses/tags
#### State Machine
```
initial load
[empty] ----------> [fresh]
|
5min timeout | visibility change
v
[stale] <------.
| |
manual or | | error
auto refresh |
v |
[refreshing] ----'
|
success | | failure
| |
v v
[fresh] [error]
^ |
'-------'
retry
```
---
### TODO-4: CSS Sanitizer Implementation
#### Library Choice
- **Primary**: `css-tree` for parsing + whitelist validation
- **Backup**: `reworkcss` if `css-tree` bundle size exceeds 15KB
#### Validation Rules
```typescript
const CSS_SANITIZER_CONFIG = {
maxSizeBytes: 5120, // 5KB
// Allowed at-rules
allowedAtRules: ['media', 'supports', 'keyframes', '-webkit-keyframes'],
// Blocked at-rules (security)
blockedAtRules: ['import', 'charset', 'namespace', 'font-face', 'page', 'document'],
// Allowed properties (whitelist approach)
allowedProperties: [
// Layout
'display', 'position', 'top', 'right', 'bottom', 'left',
'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
'justify-content', 'align-items', 'align-content', 'gap', 'row-gap', 'column-gap',
'grid', 'grid-template', 'grid-column', 'grid-row', 'grid-area',
// Box model
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'border', 'border-radius', 'box-sizing', 'overflow', 'overflow-x', 'overflow-y',
// Visual
'color', 'background', 'background-color', 'background-image', 'background-size',
'background-position', 'background-repeat', 'opacity', 'box-shadow',
'border-color', 'border-width', 'border-style', 'border-top', 'border-right',
// Typography
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
'text-align', 'text-decoration', 'text-transform', 'letter-spacing', 'word-spacing',
'white-space', 'word-break', 'overflow-wrap',
// Effects
'transform', 'transition', 'animation', 'filter', 'backdrop-filter'
],
// Blocked values (regex patterns)
blockedValuePatterns: [
/javascript:/i,
/expression\s*\(/i,
/behavior\s*:/i,
/binding\s*:/i,
/-moz-binding/i,
/url\s*\(\s*["']?\s*data:/i, // data URIs (potential XSS)
],
// Special property validations
propertyValidators: {
'position': (value: string) => value !== 'fixed', // No fixed positioning
'background-image': (value: string) => !value.includes('url') || value.startsWith('url(https://')
}
};
```
#### Error Handling
- Invalid CSS is stripped silently (not rejected entirely)
- User sees warning: "Some CSS rules were removed for security"
- Log sanitized rules to console in dev mode only
---
### TODO-5: Accessibility Requirements (v1)
#### Keyboard Navigation
- Tab order: logical flow through course cards
- Enter/Space: activate CTA buttons
- Escape: close any open modals/filter dropdowns
#### Screen Reader Support
- Course cards: `role="article"` with `aria-labelledby` pointing to title
- CTA buttons: descriptive `aria-label` (e.g., "View Introduction to Python course")
- Filter badges: `aria-pressed` state for active filters
- Live regions: announce filter results changes (`aria-live="polite"`)
#### Visual Requirements
- Focus indicators: 2px solid outline with 2px offset (using `:focus-visible`)
- Color contrast: minimum 4.5:1 for text (validated in design tokens)
- Reduced motion: respect `prefers-reduced-motion` for carousel animations
---
### TODO-6: Bundle Size Enforcement
#### CI Configuration
```yaml
# apps/embeds/bundle-size.json
{
"limits": {
"course-widget.js": "3kb",
"course-widget-runtime.js": "35kb"
},
"failOnViolation": true
}
```
#### Build Script
```bash
#!/bin/bash
# apps/embeds/scripts/check-bundle-size.sh
max_size_loader=$((3 * 1024)) # 3KB
max_size_runtime=$((35 * 1024)) # 35KB
loader_size=$(gzip -c dist/course-widget.js | wc -c)
runtime_size=$(gzip -c dist/course-widget-runtime.js | wc -c)
if [ $loader_size -gt $max_size_loader ]; then
echo "❌ Loader exceeds budget: ${loader_size}B > ${max_size_loader}B"
exit 1
fi
if [ $runtime_size -gt $max_size_runtime ]; then
echo "❌ Runtime exceeds budget: ${runtime_size}B > ${max_size_runtime}B"
exit 1
fi
echo "✅ Bundle sizes within budget"
```
## Success Criteria
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.
@@ -0,0 +1,265 @@
# Course Widget Embed Implementation Plan (v1)
## Current State Alignment (2026-04-13)
1. Existing third-party embed runtime already lives in `apps/embeds` and is published as `@cio/embeds`; do not create a new `@cio/widgets` package.
2. Existing CDN publish flow already exists in `apps/embeds/scripts/upload-embeds.ts` and `.github/workflows/deploy-embeds.yml`; extend that flow for course widgets instead of building a second publish pipeline from scratch.
3. Shared widget payload contracts should live in the existing validation layer (`packages/utils/src/validation/widget/widget.ts`) so API, dashboard preview, and `@cio/embeds` all consume one schema/type source.
4. 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
5. Because of that, public embeds should not rely on `/organization/courses/public` directly for first paint; v1 needs a dedicated published widget payload endpoint with an aggressive cache/precompute strategy.
## Senja Reference Notes (2026-04-13)
1. 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
2. Senja’s standard widget embed path is still a multi-step client flow:
- `platform.js` loads 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
3. That means Senja’s normal widget path is operationally clean, but not the absolute minimum-latency model for first render.
4. Senja does use a more latency-efficient pattern for rich snippets: a single JS endpoint returns executable script with the data already embedded.
5. 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
6. The parts not worth copying directly for our first render path are:
- uncached `no-store` widget-config responses
- always requiring a separate JSON fetch before widget render
7. 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
1. Widget embed runtime extends the existing plain Svelte `@cio/embeds` app output (no SvelteKit runtime dependency in embedded widget).
2. Admin + Tutor can edit drafts; publish/rollback/delete remain admin-only.
3. Org-scoped widgets only; no cross-org sharing.
4. Required layout types in v1:
- cards
- table
- primary course
- carousel
- tag filter badges
5. Editor route is `/org/[slug]/widgets` with create/edit flows.
6. Publish target is Cloudflare-hosted static assets + edge delivery.
7. Dashboard live preview is local state -> iframe (no preview API endpoint).
8. Public embed fetches server-computed payload from widget public endpoint.
9. Both paths must use the same shared payload schema.
10. Draft editing is manual-save only (no autosave in v1).
11. Shadow DOM is the default isolation strategy; fallback to scoped classes only if critical issues arise.
12. CSS sanitizer uses `css-tree` with 5KB limit and whitelist validation.
13. 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
1. Phase A: Data model + validation + DB query layer.
2. Phase B: API services/routes (org + public payload).
3. Phase C: `@cio/embeds` extension (plain Svelte runtime + loader contract).
4. Phase D: Dashboard widgets feature (list, editor, preview, publish).
5. Phase E: `@cio/embeds` publish extension + observability + hardening.
## Status Snapshot (2026-04-13)
### Done
1. `CW-A1` DB widget schemas
2. `CW-A2` widget query module
3. `CW-A3` widget validation schemas
4. `CW-A4` DB type exports
5. `CW-B1` widget service layer
6. `CW-B2` org widget routes
7. `CW-B3` public widget payload route
8. `CW-B4` route registration
9. `CW-B6` canonical payload builder
10. `CW-B7` published snapshot fast path with cache headers
11. `CW-C1` course widget runtime in `@cio/embeds`
12. `CW-C2` required layout rendering in the current runtime
13. `CW-C4` loader/bootstrap interface
14. `CW-C6` shared typed payload contract
15. `CW-D1` dashboard widget request/response types
16. `CW-D2` dashboard widget API class
17. `CW-D3` widgets list route/page
18. `CW-D4` editor route/page
19. `CW-D5` org sidebar navigation entry
20. `CW-D6` English translation keys
21. `CW-D9` manual save/discard flow
22. `CW-E1` embed publish/build flow extension for a dedicated course-widget target
23. `CW-E3` base hardening for public key generation and CSS sanitization
### Done (Session 2 — 2026-04-13)
24. `CW-C3` Shadow DOM isolation (widget.css extracted, shadow root created in embed-mount.ts with `<cio-course-widget>` custom element)
25. `CW-D8` iframe preview pipeline (widget-iframe-preview.svelte component + /widget-preview dashboard route)
26. `CW-D10` iframe protocol with READY/RENDER/ERROR/RESIZE, strict origin validation, 3-retry exponential backoff
27. `CW-D11` data freshness controls (last synced timestamp, manual refresh button in editor)
28. `CW-E3` rate limiting on public payload endpoint (100 req/min per IP, 1000 req/min per widget key)
29. `CW-E4` widget validation + payload tests (47 tests passing via vitest)
### Partial
1. `CW-D12` accessibility
2. `CW-E2` Cloudflare delivery policy
3. `CW-E5` rollout hardening
### Remaining
1. `CW-B5` analytics endpoint
2. `CW-C5` bundle size CI checks
3. `CW-D7` richer plan-gating UX polish
### Current Deviation From Plan
1. 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
1. `GET /organization/widgets`
2. `POST /organization/widgets`
3. `GET /organization/widgets/:widgetId`
4. `PUT /organization/widgets/:widgetId`
5. `DELETE /organization/widgets/:widgetId`
6. `POST /organization/widgets/:widgetId/publish`
7. `POST /organization/widgets/:widgetId/rollback`
### Public
1. `GET /widgets/:publicKey/payload`
2. `POST /widgets/:publicKey/events` (if enabled in v1)
## Widget Runtime Notes (Strict)
1. Use plain Svelte components compiled via Vite library mode.
2. Do not ship SvelteKit runtime artifacts in embed bundle.
3. Keep dependency graph minimal (avoid heavy UI libraries in the embed runtime).
4. Isolate styles (prefer Shadow DOM custom element or strict prefixed scoping).
5. Renderer input type is the canonical payload contract shared across dashboard preview and public embed.
6. Reuse the existing `apps/embeds` bootstrap + dynamic import pattern instead of introducing a second embed runtime surface.
7. Public embeds should fetch a compact published widget payload; they should not call `/organization/courses/public` directly because that route is not tuned for low-latency embed rendering.
## Dashboard UI Notes
1. Follow existing route pattern:
- route file contains `Page.Root/Page.Header`
- feature page contains business logic
2. Keep form controls using `@cio/ui` components.
3. Store all user-facing strings in translations.
4. Keep preview component using the same renderer used in the published `@cio/embeds` entry.
5. Editor `+page.server.ts` provides initial data for local preview state (courses/tags/draft config).
6. Left-panel changes update local payload and send it to iframe via `postMessage` (debounced/throttled).
7. Preview iframe should not fetch widget data; it only renders payload received from parent state.
8. No autosave; user must click `Save` to persist draft changes.
9. Provide explicit `Discard changes` action for local draft reset.
## Acceptance Criteria by Phase
### Phase A
1. Migration runs cleanly.
2. Org isolation enforced at query/service level.
3. Validation schemas compile in `@cio/utils`.
4. CSS sanitizer config defined and testable.
### Phase B
1. Admin can CRUD and publish widget via API.
2. Public payload endpoint returns only published widget versions.
3. Unauthorized org access returns proper 403/404 behavior.
4. Public payload endpoint uses canonical compute service for courses + design config.
5. Public payload endpoint uses a dedicated fast path (published snapshot/cache) rather than recomputing from generic public course queries on every request.
6. Public payload endpoint emits public cache headers suitable for CDN/browser reuse.
7. CSS sanitization rejects malicious input in tests.
8. Rate limiting enforced on public endpoints.
### Phase C
1. `@cio/embeds` builds the course-widget entry independently.
2. Runtime meets bundle budget and renders all required layouts.
3. Host-site CSS does not break widget UI in smoke tests (Shadow DOM verified).
4. Renderer consumes only canonical computed payload type.
5. Accessibility: keyboard navigation and ARIA labels implemented.
6. Bundle size CI check fails builds exceeding budget.
### Phase D
1. Admin can create/edit/publish from `/org/[slug]/widgets`.
2. Tutors can edit drafts from `/org/[slug]/widgets` editor.
3. Editor left panel has `Select Courses`, `Widgets`, `Design`.
4. Live preview updates as settings change using local state passed to iframe.
5. Embed snippet is copyable from dashboard.
6. Plan gates visibly enforce free vs paid restrictions (disabled with tooltips).
7. Editing is manual-save only; no background persistence happens.
8. Iframe protocol is stable (`READY/RENDER/ERROR/RESIZE`) with strict origin validation and retry logic.
9. Data freshness UI supports manual refresh and stale-state warnings.
10. Accessibility: widgets pass axe-core automated checks.
### Phase E
1. Publish creates versioned immutable artifact URLs in Cloudflare R2.
2. Rollback switches manifest pointer to previous version without data loss.
3. Monitoring/logs show publish failures and public payload errors.
4. Artifact cleanup policy (10 versions kept) verified.
5. Cache headers correctly configured for all asset types and public widget payload responses.
## Verification Commands
1. `pnpm --filter @cio/utils build`
2. `pnpm --filter @cio/db build`
3. `pnpm --filter @cio/api build`
4. `pnpm --filter @cio/embeds build`
5. `pnpm --filter @cio/dashboard build`
6. Run widget API and embeds tests (unit/integration).
## Suggested First Sprint Slice
1. CW-A1 to CW-A4 (DB + validation foundation)
2. CW-B1 to CW-B6 (API services + routes + payload compute)
3. CW-C1 + CW-C6 (`@cio/embeds` scaffold + payload contract)
4. 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