30 KiB
Course Widget Embed PRD (Org Widgets)
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)
- Editor route is in dashboard org context:
/org/[slug]/widgets(user-facing equivalent:/org/id/widgets). - Embed runtime must be plain Svelte (no SvelteKit in the embed runtime path) for minimal JS payload.
- Widget rendering supports multiple layouts:
- cards
- table
- primary single course
- carousel
- tag-filtered list (tags as top badges)
- Widget authoring UI opens an editor with left panel sections:
Select CoursesWidgetsDesign
- Published widgets deploy to Cloudflare-backed static hosting/edge delivery (R2 + Workers).
- Dashboard editing reuses the existing
@cio/embedsrenderer in dev mode for real preview parity. - Dashboard preview does not call a preview endpoint; it uses already-fetched dashboard state and passes payload into an iframe.
- Public embed fetches a dedicated published widget payload from server endpoints.
Current State Alignment (2026-04-13)
- The existing third-party embed runtime already lives in
apps/embedsand is published as@cio/embeds; this feature should extend that app rather than introduce a new@cio/widgetspackage. - The existing embed publish path already lives in
apps/embeds/scripts/upload-embeds.tsand.github/workflows/deploy-embeds.yml; the course widget should plug into that release flow. - 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. - 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
- 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/embedsassets.
Implementation Status (2026-04-13)
Completed
- Shared widget validation and canonical payload contracts were added in
packages/utils/src/validation/widget/widget.ts. - Widget persistence was added in the DB layer for
widget,widget_course, andwidget_version. - Widget query helpers were added in
packages/db/src/queries/widget/widget.ts. - Organization widget API routes are implemented for list, create, detail, update, archive, publish, and rollback.
- Public payload delivery is implemented at
GET /widgets/:publicKey/payload. - Widget payload computation, public key generation, embed code generation, and CSS sanitization were added in
apps/api/src/services/widget-payload.ts. - Dashboard widgets UI is implemented at
/org/[slug]/widgetsand/org/[slug]/widgets/[widgetId]. - 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.
- A course widget runtime was added to
apps/embeds/src/widgets/course-widget/*. - Embed publishing/build plumbing was extended so
question-type-pickerandcourse-widgetbuild and upload independently. - The course widget bundle was reduced back into the intended budget range after splitting embed builds per target:
- loader:
6.41 kB(2.20 kBgzip) - runtime chunk:
80.03 kB(22.35 kBgzip)
- Shadow DOM isolation was implemented for the public embed runtime:
- Widget CSS extracted to
widget.cssand injected into shadow root - Custom element
<cio-course-widget>withattachShadow({ mode: 'open' }) - Host page CSS cannot break widget visuals
- Dashboard preview now uses an iframe + postMessage pipeline:
- Preview route at
/widget-previewrenders the widget component widget-iframe-preview.sveltehandles the parent-side protocolpreview-protocol.tsdefines typed message contract (READY/RENDER/ERROR/RESIZE)- Strict origin validation and exponential backoff retry (3 attempts)
- Data freshness controls were added to the widget editor:
- "Last synced" timestamp display
- Manual "Refresh" button to reload widget data from server
onSyncedcallback from iframe when render completes
- Rate limiting was added to the public widget payload endpoint:
- 100 req/min per IP address
- 1000 req/min per widget key
- Uses existing
createRateLimitermiddleware with Redis-backed limiter
- 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
pnpm --filter @cio/utils buildpnpm --filter @cio/api buildpnpm --filter @cio/dashboard buildpnpm --filter @cio/embeds buildnode --import tsx apps/embeds/scripts/upload-embeds.ts --help
Remaining / Not Yet Implemented
- Bundle-size enforcement in CI is not implemented yet; the runtime is small enough now, but there is no automated budget gate.
- Analytics ingestion is still deferred.
- 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
- Translation coverage is only updated in
en.json; other locales were not expanded. - Manual end-to-end publish smoke testing on a real embedded host page is still pending.
- Plan-gating UX polish (disabled state tooltips) not yet done.
- Cloudflare delivery policy automation not yet configured.
- 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
- Let org admins create and publish embeddable course widgets.
- Keep embed runtime lightweight and isolated from host-site CSS/JS conflicts.
- Reuse existing course and tag data models for content selection/filtering.
- Provide clear plan-gated design controls (free vs paid capabilities).
- 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.tsapps/dashboard/src/routes/org/[slug]/courses/+page.server.tsapps/dashboard/src/routes/org/[slug]/tags/+page.server.tsapps/dashboard/src/lib/features/org/api/org.svelte.tsapps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelteapps/dashboard/src/lib/features/course/components/card.svelteapps/api/src/routes/organization/organization.tsapps/api/src/services/organization.tspackages/db/src/schema.tspackages/db/src/queries/course/course.tspackages/db/src/queries/tag/tag.tspackages/utils/src/plans/constants.tspackages/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:
namelayoutType- selection mode (
manualcourse selection orpublished courses) - initial design config
- Status model:
DRAFTPUBLISHEDARCHIVED
- Save model:
- manual save only (
Savebutton) - no autosave in v1
- manual save only (
- 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 CoursesWidgetsDesign
- Main panel:
- live preview using the same
@cio/embedsrenderer contract as public embed - desktop/mobile preview toggle
- embed code tab
- live preview using the same
- 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
cardstableprimary_coursecarouseltag_filter
Each layout supports required shared props:
showCourseImageshowCourseTypeBadgeshowLessonsCountshowPriceshowDescriptionExcerptctaLabel
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 Bytoggle
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/embedscourse-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/embedsapp output. - No SvelteKit runtime in embed bundle.
- Use style isolation: Shadow DOM custom element (
<widget-root>) with:hostselector 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-treeparser (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
targetOriginmatching dashboard domain.
10. Analytics (Schema Reserved for Phase 2)
Deferred to Phase 2, but schema defined for forward compatibility:
// 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
- Build Trigger: API call
POST /organization/widgets/:widgetId/publish - 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
{
"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
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
apps/dashboard
- widget editor UI and preview
- imports or mirrors the same renderer contract used by
@cio/embedsin dev/prod preview
apps/api
- org-protected widget CRUD/publish routes
- public widget payload route
- shared
computeWidgetPayload(...)service used during publish and for controlled cache rebuilds
- 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
- Dashboard editor loads widget draft and related data into state (courses/tags/config).
- Left panel changes update local draft JSON in state.
- Dashboard builds preview payload from state and sends it to preview iframe via
postMessage. - Preview iframe mounts the same renderer contract used by
@cio/embeds. - Publish stores a payload snapshot; public runtime requests the published payload for the current version and mounts the same renderer.
Data Model (Proposed)
widget
id,organization_id,name,status,layout_typeselection_mode,public_key,latest_published_version_idcreated_by,updated_by, timestamps
widget_course
widget_id,course_id,order
widget_version
id,widget_id,versionconfig_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)
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(optional in v1, can be deferred)
UX Flow
- Admin opens
/org/[slug]/widgets. - Clicks
Create Widget. - Editor opens with left tabs (
Select Courses,Widgets,Design). - Admin previews and adjusts design.
- Admin publishes, receives embed snippet.
- 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)
- ✅ Free plan color tweaks – 1 preset theme only (default). Paid users get all preset themes (3-5) plus custom color picker.
- ✅ Events endpoint – Deferred to Phase 2; schema reserved but not implemented in v1.
- ✅ Locale detection – Deferred to Phase 2; widgets use single-language labels from org default locale in v1.
- ✅ 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_idpoints 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
// 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
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
-
Origin Validation:
- Production:
targetOriginmust matchVITE_DASHBOARD_URL(e.g.,https://app.classroomio.com) - Development: allow
http://localhost:*with port wildcard - Never use
*in production
- Production:
-
Message Validation:
- Reject messages without
typefield - Reject unknown message types
- Validate payload schema with Zod before processing
- Reject messages without
-
iframe Sandboxing:
- Use
sandbox="allow-scripts"attribute - No
allow-same-origin(prevents cookie access)
- Use
Reliability Rules
-
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 -
Retry Logic:
- READY timeout: 3 retry attempts, exponential backoff (1s, 2s, 4s)
- RENDERED timeout: 2 retry attempts, show error state in parent
-
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/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
- Initial Load:
+page.server.tsprovides hydrated data withserverTimestamp - Stale Detection:
- Check
Date.now() - lastSyncTime > 5 * 60 * 1000on mount - Listen to
visibilitychangeevent: when tab becomes visible, check staleness
- Check
- Background Sync:
- Use
navigator.sendBeaconorfetchwithkeepalivefor refresh - Debounce refresh calls (minimum 30s between requests)
- Use
- Warning UX:
- Non-blocking toast when stale data detected
- Course/tag removed warning appears in preview area (not blocking)
- 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-treefor parsing + whitelist validation - Backup:
reworkcssifcss-treebundle size exceeds 15KB
Validation Rules
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"witharia-labelledbypointing to title - CTA buttons: descriptive
aria-label(e.g., "View Introduction to Python course") - Filter badges:
aria-pressedstate 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-motionfor carousel animations
TODO-6: Bundle Size Enforcement
CI Configuration
# apps/embeds/bundle-size.json
{
"limits": {
"course-widget.js": "3kb",
"course-widget-runtime.js": "35kb"
},
"failOnViolation": true
}
Build Script
#!/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
- Admin can publish a widget and embed it externally in under 5 minutes.
- Embed snippet works across static HTML, React, and WordPress hosts.
- Runtime stays within performance budget and has no host CSS collisions.
- Support tickets for “embed broken by host CSS” remain near zero.
- Dashboard live preview matches public embed rendering for the same payload and version.
- 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).
- Security: Zero XSS vectors through custom CSS or malformed payloads in penetration testing.