feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
+123 -53
View File
@@ -17,9 +17,80 @@ Ship an organization-scoped widget system so admins can embed selected courses o
- `Widgets`
- `Design`
5. Published widgets deploy to Cloudflare-backed static hosting/edge delivery (R2 + Workers).
6. Dashboard editing imports the widget package in dev mode for real preview parity.
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 computed widget payload (courses + design config) from server endpoints.
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.
@@ -99,7 +170,7 @@ This creates gaps:
- `Widgets`
- `Design`
- Main panel:
- live preview using the same shared render package as embed
- live preview using the same `@cio/embeds` renderer contract as public embed
- desktop/mobile preview toggle
- embed code tab
- Live preview behavior:
@@ -185,14 +256,16 @@ Design panel is grouped as:
- custom CSS enabled
### 7. Public Embed Contract
- Copy-paste script embed:
- `<script async src="https://widgets.classroomio.com/v1/loader.js" data-widget="wgt_xxx"></script>`
- Loader pulls published widget payload via public key and mounts Svelte widget.
- 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 package (`packages/widgets`) compiled in library mode (Vite `build.lib`).
- 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:
@@ -246,7 +319,7 @@ Design panel is grouped as:
- 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 builds/fetches payload server-side from persisted widget config and course queries.
- 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.
@@ -258,12 +331,11 @@ Design panel is grouped as:
```
a. API receives publish request
b. Compute final payload with resolved courses + design config
c. Trigger build worker (Cloudflare Worker or GitHub Actions)
d. Build @cio/widgets package with embedded design tokens
e. Generate hashed filenames (e.g., `widget.a3f2b1c.js`)
f. Upload to R2 bucket: `widgets/{orgId}/{widgetId}/v{version}/`
g. Return manifest URLs to API
h. API saves `artifact_manifest` to `widget_version` table
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
@@ -273,17 +345,13 @@ Design panel is grouped as:
"widgetId": "wgt_xxx",
"orgId": "org_yyy",
"createdAt": "2026-02-19T12:00:00Z",
"files": {
"js": {
"url": "https://cdn.classroomio.com/widgets/org_yyy/wgt_xxx/v3/widget.a3f2b1c.js",
"integrity": "sha384-abc123...",
"size": 24567
},
"css": {
"url": "https://cdn.classroomio.com/widgets/org_yyy/wgt_xxx/v3/styles.7e8d9f0.css",
"integrity": "sha384-def456...",
"size": 3456
}
"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..."
}
@@ -298,14 +366,15 @@ Design panel is grouped as:
```
User's Website Cloudflare
| |
|-- script src="loader.js" -----> |-- Worker: validate key
| |-- Check cache (KV)
| |-- Cache miss: fetch from R2
|<-- loader.js -------------------|
|-- script src="course-widget.js"->|-- Serve static `@cio/embeds` asset
|<-- course-widget.js ------------|
| |
|-- loader fetches manifest ----->|-- Return artifact_manifest
|-- runtime fetches payload ----->|-- Validate widget key
| |-- Check CDN/cache
| |-- Cache miss: read published payload snapshot
|<-- payload JSON ----------------|
| |
|-- loader injects JS/CSS ------->|-- Serve from R2 (immutable cache)
|-- runtime mounts widget ------->|-- Static assets already cached/immutable
```
---
@@ -313,31 +382,32 @@ User's Website Cloudflare
## Proposed Architecture
### Package and App Boundaries
1. `packages/widgets`
- plain Svelte renderer components
- layout implementations
- shared design token mapping
- build output: ESM + browser bundle
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 renderer from `@cio/widgets` in dev/prod build
- 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 for public payload routes
- shared `computeWidgetPayload(...)` service used during publish and for controlled cache rebuilds
4. Cloudflare publish target
- stores versioned JS/CSS artifacts
- serves loader + static assets + cache headers
- 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 `@cio/widgets` renderer with received payload.
5. Publish stores snapshot; public loader requests server-computed payload for published version and mounts same renderer.
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`
@@ -351,7 +421,8 @@ User's Website Cloudflare
3. `widget_version`
- `id`, `widget_id`, `version`
- `config_snapshot` (jsonb)
- `artifact_manifest` (jsonb: js/css URLs + integrity)
- `payload_snapshot` (jsonb: canonical public payload)
- `runtime_manifest` (jsonb: embed entry URL + release metadata)
- `published_at`, `published_by`
## API Contract Additions (Target)
@@ -407,8 +478,8 @@ User's Website Cloudflare
### Caching Strategy
- Public payload endpoint: `Cache-Control: public, max-age=60, stale-while-revalidate=300`
- Widget JS/CSS artifacts: `Cache-Control: public, max-age=31536000, immutable`
- Loader script: `Cache-Control: public, max-age=3600` (versioned via query param)
- 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)
@@ -497,7 +568,7 @@ Parent (Dashboard) Iframe (Widget Runtime)
#### Documentation Output
- Spec file: `prd/course-widget-embed/iframe-protocol.md` (this section)
- Implementation: `packages/widgets/src/preview/iframe-bridge.ts` + `apps/dashboard/src/lib/features/widget/preview/iframe-parent.ts`
- 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)
@@ -639,12 +710,11 @@ const CSS_SANITIZER_CONFIG = {
#### CI Configuration
```yaml
# packages/widgets/bundle-size.json
# apps/embeds/bundle-size.json
{
"limits": {
"loader.js": "3kb",
"runtime.js": "35kb",
"runtime.css": "5kb"
"course-widget.js": "3kb",
"course-widget-runtime.js": "35kb"
},
"failOnViolation": true
}
@@ -653,13 +723,13 @@ const CSS_SANITIZER_CONFIG = {
#### Build Script
```bash
#!/bin/bash
# packages/widgets/scripts/check-bundle-size.sh
# apps/embeds/scripts/check-bundle-size.sh
max_size_loader=$((3 * 1024)) # 3KB
max_size_runtime=$((35 * 1024)) # 35KB
loader_size=$(gzip -c dist/loader.js | wc -c)
runtime_size=$(gzip -c dist/runtime.js | wc -c)
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"
+113 -20
View File
@@ -1,7 +1,41 @@
# 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 is plain Svelte package output (no SvelteKit runtime dependency in embedded widget).
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:
@@ -18,13 +52,62 @@
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/widgets` package (plain Svelte runtime + loader contract).
3. Phase C: `@cio/embeds` extension (plain Svelte runtime + loader contract).
4. Phase D: Dashboard widgets feature (list, editor, preview, publish).
5. Phase E: Cloudflare publish pipeline + observability + hardening.
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
@@ -40,12 +123,13 @@
| 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-C1 | Widgets Package | Create `@cio/widgets` package (plain Svelte runtime) | `packages/widgets/*` | CW-A3 | Package builds to ESM/browser bundle without SvelteKit |
| CW-C2 | Layouts | Implement 5 layout renderers | `packages/widgets/src/layouts/*` | CW-C1 | All required layouts render from same config contract |
| CW-C3 | Design Tokens | Add config-to-style mapper + Shadow DOM isolation | `packages/widgets/src/styles/*` | CW-C1 | Host CSS does not break widget visuals; Shadow DOM encapsulation verified in tests |
| CW-C4 | Loader Contract | Add loader bootstrap interface | `packages/widgets/src/loader/*` | CW-C1 | Loader can mount widget by payload id/key |
| CW-C5 | Size Budget | Add bundle size CI checks (loader ≤3KB, runtime ≤35KB gzip) | `packages/widgets/package.json` scripts + CI workflow | CW-C1 | CI fails when runtime exceeds target budgets; budget file in `bundle-size.json` |
| CW-C6 | Renderer Contract | Add strict typed payload contract shared by dashboard and API | `packages/widgets/src/types/payload.ts` | CW-C1,CW-B6 | Renderer accepts canonical payload schema only |
| 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 |
@@ -58,10 +142,10 @@
| 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 to Cloudflare | `scripts/widgets/*` or api worker hook | CW-C4,CW-B2 | Publish produces versioned URLs + manifest persisted |
| 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/package tests | CW-B4,CW-C2 | Core flows covered and passing in CI |
| 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)
@@ -82,9 +166,11 @@
## 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 embed package).
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:
@@ -92,7 +178,7 @@
- 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 package renderer used in published bundle.
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.
@@ -112,11 +198,13 @@
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. CSS sanitization rejects malicious input in tests.
6. Rate limiting enforced on public endpoints.
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/widgets` package builds independently.
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.
@@ -140,20 +228,20 @@
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.
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/widgets build`
4. `pnpm --filter @cio/embeds build`
5. `pnpm --filter @cio/dashboard build`
6. Run widget API and package tests (unit/integration).
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 (widgets package scaffold + payload contract)
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.
@@ -170,3 +258,8 @@ Each ticket must include:
- 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