chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user