feat: widgets and ai chat
This commit is contained in:
@@ -57,10 +57,10 @@ The LLM sees a set of **tools** (mapped to existing service functions) and decid
|
||||
|
||||
- `ai` — core (`streamText`, `tool`, types) — in `packages/ai-assistant` and `apps/api`
|
||||
- `@ai-sdk/svelte` — Svelte 5 `Chat` class (runes-based, matches project's `LessonApi` / `BaseApiWithErrors` pattern)
|
||||
- `@ai-sdk/openai` + `@ai-sdk/anthropic` — provider packages
|
||||
- `@ai-sdk/openai` + `@ai-sdk/anthropic` + `@ai-sdk/google` — provider packages
|
||||
- `streamText()` returns `toUIMessageStreamResponse()` which works directly as a Hono response
|
||||
- Tool calling is built-in with Zod schemas; SDK handles function-calling protocol, multi-step execution, streaming tool results
|
||||
- Provider abstraction lets us swap between OpenAI and Anthropic by changing one line
|
||||
- Provider abstraction lets us swap between OpenAI, Google, and Anthropic per request via the dashboard model picker
|
||||
|
||||
---
|
||||
|
||||
@@ -121,23 +121,26 @@ The two modes represent different phases of a workflow, not a toggle. A typical
|
||||
|
||||
**What happens for plan execution**:
|
||||
|
||||
A typical plan with 4 sections and 12 lessons needs ~40+ tool calls (create section + create lesson + write content + create exercise for each). This exceeds what a single `streamText()` call can handle in one pass. Plan execution uses a **sequential multi-round approach**:
|
||||
|
||||
1. Agent receives the approved plan
|
||||
2. Creates sections first (in order), capturing the returned section IDs
|
||||
3. Creates lessons within each section (in order), capturing lesson IDs
|
||||
4. For each lesson, generates full content based on the source document + lesson description, then calls `update_lesson_content` to populate it
|
||||
5. Optionally creates exercises with generated questions for lessons marked `hasExercise`
|
||||
6. Shows real-time progress via tool status cards in the chat:
|
||||
- "Creating section: Cell Biology..." -> checkmark
|
||||
- "Creating lesson: Cell Structure and Function..." -> checkmark
|
||||
- "Writing lesson content..." -> checkmark
|
||||
- "Creating exercise with 5 questions..." -> checkmark
|
||||
7. After completion, `onFinish` callback refreshes the course content tree
|
||||
2. The first `streamText()` round creates all sections and the first few lessons (within `maxSteps: 15`)
|
||||
3. When `maxSteps` is reached, the `onFinish` callback automatically sends a continuation message: "Continue implementing the plan from where you left off"
|
||||
4. The agent sees the conversation history (including tool results from prior rounds) and picks up where it stopped
|
||||
5. This repeats until the plan is fully executed
|
||||
6. Each round checks the token balance before starting — if tokens run out mid-plan, the agent stops cleanly and reports what was completed
|
||||
|
||||
Within each round, the agent:
|
||||
- Creates sections first (in order), capturing the returned section IDs
|
||||
- Creates lessons within each section (in order), capturing lesson IDs
|
||||
- For each lesson, generates full content based on the source document + lesson description, then calls `update_lesson_content` to populate it
|
||||
- Optionally creates exercises with generated questions for lessons marked `hasExercise`
|
||||
|
||||
**What happens for individual actions** (update lesson, generate questions, etc.):
|
||||
|
||||
Same as the standard agent flow — LLM decides which tool to call, executes it, streams the result.
|
||||
Same as the standard agent flow — LLM decides which tool to call, executes it, streams the result. Single-round, `maxSteps: 15` is sufficient.
|
||||
|
||||
**Progress tracking**: During plan execution, the agent emits structured progress events that the UI renders as a checklist:
|
||||
**Progress tracking**: During plan execution, the agent emits structured progress events that the UI renders as a checklist. This persists across multi-round execution:
|
||||
|
||||
```
|
||||
[x] Section 1: Cell Biology (created)
|
||||
@@ -148,6 +151,33 @@ Same as the standard agent flow — LLM decides which tool to call, executes it,
|
||||
[ ] Section 2: Genetics
|
||||
```
|
||||
|
||||
### Partial Failure & Resume
|
||||
|
||||
Plan execution can fail partway through — tokens run out, LLM rate limit, network error, or teacher hits Stop. When this happens:
|
||||
|
||||
- **Already-created content is kept.** Sections and lessons created before the failure exist in the database and are visible in the course content tree.
|
||||
- **The progress card shows what was completed vs. what remains.** Completed items have checkmarks; the failed/stopped item shows an error or stop icon; remaining items show empty circles.
|
||||
- **The teacher can resume.** After the failure, the chat shows a "Resume" button. Clicking it sends a message: "Continue implementing the plan." The agent calls `get_course_structure` to see what already exists, compares it against the plan, and picks up from the first uncreated item.
|
||||
- **Token exhaustion is explicit.** If the failure was due to running out of tokens, the progress card shows: "Paused — token limit reached. [Buy more credits] or wait for reset on [date]." The plan state is preserved in the conversation — the teacher can resume after purchasing credits or next month.
|
||||
|
||||
### Existing Course Content
|
||||
|
||||
Before executing a plan, the agent checks if the course already has content via `get_course_structure`. If sections or lessons already exist:
|
||||
|
||||
- The agent reports what it found: "This course already has 3 sections and 8 lessons."
|
||||
- It asks the teacher how to proceed: **"Should I add the new content after the existing sections, or would you like to start fresh?"**
|
||||
- **Append** (default): new sections are created with `order` values starting after the last existing section. Existing content is untouched.
|
||||
- **Start fresh**: the agent warns that this is destructive ("This will not delete existing content, but the new sections will be added alongside it. You can remove old content manually."). In v1, the agent cannot delete content — it can only append. Deletion tools are added in Phase 4.
|
||||
|
||||
### Token Cost Estimation
|
||||
|
||||
Before executing a plan, the agent estimates the token cost and warns the teacher:
|
||||
|
||||
- The estimate is based on plan size: `(sectionCount * ~500) + (lessonCount * ~3,000) + (exerciseCount * ~1,500)` tokens per item (rough heuristic based on typical generation lengths + tool call overhead)
|
||||
- Displayed in the plan card before "Implement Plan": *"Estimated cost: ~45,000 tokens (9% of your monthly allowance)"*
|
||||
- If the estimate exceeds 50% of remaining tokens: stronger warning — *"This plan will use a significant portion of your remaining tokens (estimated ~45,000 of 62,000 remaining). Proceed?"*
|
||||
- This is a best-effort estimate, not a guarantee. Actual usage varies based on content complexity.
|
||||
|
||||
### Mode Detection
|
||||
|
||||
The mode is implicit based on context, not explicitly toggled by the user:
|
||||
@@ -185,27 +215,34 @@ Maximum upload size: **5MB** (aligns with the existing `MAX_DOCUMENT_SIZE` const
|
||||
- Client validates before upload as well (check `file.size` on selection) and shows an inline error: "File is too large. Maximum size is 5MB."
|
||||
- 5MB is sufficient for most textbook chapters, syllabi, and course outlines. Full textbook PDFs (50MB+) are out of scope — teachers should upload individual chapters.
|
||||
|
||||
### One Document Per Conversation
|
||||
|
||||
Only one document can be uploaded per conversation. If the teacher uploads a second file, it replaces the first — the previous `documentId` in Redis is deleted and the new one takes its place. This keeps the context simple and avoids confusion about which document the agent is referencing. If a teacher needs to combine multiple sources, they should merge them into one document before uploading.
|
||||
|
||||
### Upload Flow
|
||||
|
||||
1. Teacher clicks the attachment icon in the chat input
|
||||
2. **Plan check**: if BASIC plan, trigger upgrade modal and stop. If EARLY_ADOPTER+, continue.
|
||||
3. Selects a PDF or DOCX file
|
||||
3. Selects a PDF, DOCX, or PPTX file
|
||||
4. **Size check**: if file > 5MB, show inline error and stop
|
||||
5. File is uploaded to `POST /agent/upload` which:
|
||||
- Validates plan (EARLY_ADOPTER+ required)
|
||||
- Validates file type (PDF/DOCX only) and size (max 5MB)
|
||||
- Validates file type (PDF/DOCX/PPTX only) and size (max 5MB)
|
||||
- Extracts text content server-side
|
||||
- Returns `{ documentId, fileName, pageCount, textPreview }` (first ~200 chars)
|
||||
- **Truncates** extracted text to a maximum of 100,000 characters (~75K tokens) to stay within LLM context limits. If truncated, the response includes `truncated: true` so the UI can show: "Document was large — only the first portion was analyzed."
|
||||
- Returns `{ documentId, fileName, pageCount, textPreview, truncated }` (first ~200 chars for preview)
|
||||
- Stores extracted text in Redis with a TTL (keyed by `documentId`, expires after 1 hour)
|
||||
6. The `documentId` is attached to the next chat message
|
||||
7. When the agent processes the message, it retrieves the full extracted text from Redis and includes it in the LLM context
|
||||
|
||||
### Text Extraction
|
||||
### Supported File Types & Text Extraction
|
||||
|
||||
**PDF**: Use `pdf-parse` (lightweight, pure JS, no native dependencies). Extracts text content from all pages. Handles multi-column layouts reasonably well for structured content like textbooks/syllabi.
|
||||
|
||||
**DOCX**: Use `mammoth` (pure JS). Extracts text content preserving heading structure (H1, H2, etc.) which helps the LLM understand document hierarchy for course structure generation.
|
||||
|
||||
**PPTX**: Use `pptx-parser` (pure JS). Extracts text from slides, preserving slide order as a natural section boundary. Slide titles become potential lesson titles; slide body text becomes content context. This covers the common case of teachers converting existing slide decks into courses.
|
||||
|
||||
### Why Server-Side Extraction
|
||||
|
||||
- Keeps document parsing out of the client bundle
|
||||
@@ -218,7 +255,7 @@ Maximum upload size: **5MB** (aligns with the existing `MAX_DOCUMENT_SIZE` const
|
||||
```
|
||||
POST /agent/upload
|
||||
Content-Type: multipart/form-data
|
||||
Body: file (PDF or DOCX, max 5MB)
|
||||
Body: file (PDF, DOCX, or PPTX, max 5MB)
|
||||
Auth: authMiddleware + orgMemberMiddleware + courseTeamMemberMiddleware
|
||||
Plan: EARLY_ADOPTER+ required (BASIC returns 403)
|
||||
|
||||
@@ -226,15 +263,16 @@ POST /agent/upload
|
||||
documentId: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
pageCount: number | null, // PDF only
|
||||
pageCount: number | null, // PDF and PPTX (slide count)
|
||||
wordCount: number,
|
||||
textPreview: string // first ~200 chars
|
||||
textPreview: string, // first ~200 chars
|
||||
truncated: boolean // true if text exceeded 100K char limit
|
||||
}
|
||||
|
||||
Error Responses:
|
||||
403: { error: "document_upload_requires_upgrade", upgradeRequired: true } // BASIC plan
|
||||
413: { error: "file_too_large", maxSize: 5242880 } // > 5MB
|
||||
415: { error: "unsupported_file_type", allowed: ["application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"] }
|
||||
415: { error: "unsupported_file_type", allowed: ["application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.presentationml.presentation"] }
|
||||
```
|
||||
|
||||
The extracted text is NOT returned in the upload response (it can be very large). It lives in Redis and is fetched internally by the agent chat endpoint when `documentId` is provided in the chat message.
|
||||
@@ -256,7 +294,7 @@ A thin, backend-focused package consumed by `apps/api`. No UI code. The heavy li
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- **Provider factory** — creates the right AI SDK `LanguageModel` from env vars (`AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`)
|
||||
- **Provider factory** — creates the right AI SDK `LanguageModel` for a given provider (`createModel`); `getProviderConfigForProvider(provider)` reads the per-provider key (`OPENAI_API_KEY` / `GOOGLE_API_KEY` / `ANTHROPIC_API_KEY`); `pickAnyConfiguredProvider()` returns the first provider with a key set, used by the status route and title generation
|
||||
- **Role-aware tool registry** — `getToolSchemas(role)` returns Zod schemas per role
|
||||
- **Role-aware system prompt** — `buildSystemPrompt(context)` generates different LLM instructions for teachers vs students, including Plan Mode vs Agent Mode behavior
|
||||
- **Course plan types** — `CoursePlan`, `CoursePlanSection`, `CoursePlanLesson` types used by both API and dashboard
|
||||
@@ -382,6 +420,60 @@ The system prompt includes an explicit list of what the agent CANNOT do. The age
|
||||
|
||||
---
|
||||
|
||||
## Locale Detection
|
||||
|
||||
`upsertLessonLanguageService` requires a `locale` parameter. The agent determines the locale as follows:
|
||||
|
||||
1. **Check existing course content first.** If the course already has lessons with content in a specific locale (e.g., lessons with `lesson_language` rows in `fr`), use that locale. This ensures consistency with existing content.
|
||||
2. **If the course is empty**, default to `en`.
|
||||
3. **For document uploads**, the agent does NOT auto-detect the document's language. The content is generated in the course's locale regardless of the source document language. If a teacher uploads a French textbook but the course is in English, the agent generates English content based on the French source material. This is a feature, not a bug — teachers often want to create courses in a different language than their source material.
|
||||
4. **The teacher can override.** If they say "Write the content in Spanish," the agent uses `es`. The system prompt instructs: "Default to the course's existing locale. If the teacher requests a specific language, use that locale instead."
|
||||
|
||||
Supported locales match the existing `lesson_language.locale` enum: `en`, `hi`, `fr`, `pt`, `de`, `vi`, `ru`, `es`, `pl`, `da`.
|
||||
|
||||
---
|
||||
|
||||
## Lesson Content Format
|
||||
|
||||
The lesson editor uses **TipTap** (ProseMirror-based) and expects `HTMLContent` — standard HTML that TipTap can parse. The agent must generate content that renders correctly in the editor.
|
||||
|
||||
### Allowed HTML Tags
|
||||
|
||||
The system prompt includes explicit formatting guidance:
|
||||
|
||||
```
|
||||
When generating lesson content, use only these HTML elements:
|
||||
- <h2>, <h3> for section headings (never <h1> — that's the lesson title)
|
||||
- <p> for paragraphs
|
||||
- <ul><li> for unordered lists, <ol><li> for ordered lists
|
||||
- <strong> for bold, <em> for italic
|
||||
- <blockquote> for callouts or important notes
|
||||
- <code> for inline code, <pre><code> for code blocks
|
||||
- <a href="..."> for links (only if referencing a specific URL the teacher provided)
|
||||
|
||||
Do NOT use: <div>, <span>, <table>, <img>, <iframe>, <script>, <style>,
|
||||
or any custom elements. Content is sanitized before storage — unsupported
|
||||
tags will be stripped.
|
||||
```
|
||||
|
||||
This aligns with TipTap's default schema and the existing `sanitizeHtml()` / `sanitizeOptionalHtml()` functions used throughout the codebase.
|
||||
|
||||
### Content Length Guidance
|
||||
|
||||
The system prompt includes guidance on lesson length:
|
||||
|
||||
```
|
||||
When writing lesson content:
|
||||
- Aim for 800-2,000 words per lesson (roughly 3-8 minutes reading time)
|
||||
- Use headings to break content into scannable sections
|
||||
- Include practical examples where relevant
|
||||
- For plan execution: match the depth to the lesson description — a lesson
|
||||
described as "brief overview" should be shorter than one described as
|
||||
"deep dive into..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token-Based Usage Tracking
|
||||
|
||||
AI usage is metered by tokens. Each org gets a monthly base allowance from their plan, with the option to buy extra credit packs when exhausted.
|
||||
@@ -449,10 +541,68 @@ Four layers:
|
||||
|
||||
---
|
||||
|
||||
## Observability: Tinybird Event Tracking
|
||||
|
||||
Agent actions are tracked via **Tinybird** — a real-time analytics backend for high-volume event data. This keeps observability data out of the main Postgres database and provides instant SQL-based querying for debugging and analytics.
|
||||
|
||||
### Why Tinybird (not Postgres)
|
||||
|
||||
- Agent events are **append-only, high-volume** — every tool call, every conversation turn generates an event. This is analytics workload, not transactional.
|
||||
- The main Postgres DB should not carry this load. Token usage tracking (`ai_token_usage` table) stays in Postgres because it's used for balance enforcement (transactional). Observability events go to Tinybird.
|
||||
- Tinybird provides **real-time ingestion** via HTTP API and **instant SQL querying** — investigate issues within seconds of them happening.
|
||||
- Dashboards and alerts can be built on top without custom infrastructure.
|
||||
|
||||
### Events to Track
|
||||
|
||||
| Event | When | Key Fields |
|
||||
|-------|------|------------|
|
||||
| `agent.chat.started` | Chat request received | `orgId`, `userId`, `courseId`, `role`, `hasDocument`, `messageCount` |
|
||||
| `agent.chat.completed` | Stream finished | `orgId`, `userId`, `courseId`, `promptTokens`, `completionTokens`, `model`, `durationMs`, `toolCallCount` |
|
||||
| `agent.chat.error` | LLM or service error | `orgId`, `userId`, `courseId`, `errorCode`, `errorMessage`, `model` |
|
||||
| `agent.tool.called` | Tool execution starts | `orgId`, `userId`, `courseId`, `toolName`, `toolArgs` (sanitized) |
|
||||
| `agent.tool.completed` | Tool execution finishes | `orgId`, `userId`, `courseId`, `toolName`, `success`, `durationMs`, `resultSummary` |
|
||||
| `agent.tool.failed` | Tool execution errors | `orgId`, `userId`, `courseId`, `toolName`, `errorCode`, `errorMessage` |
|
||||
| `agent.document.uploaded` | Document parsed | `orgId`, `userId`, `courseId`, `mimeType`, `fileSize`, `wordCount`, `truncated` |
|
||||
| `agent.plan.generated` | Course plan proposed | `orgId`, `userId`, `courseId`, `sectionCount`, `lessonCount` |
|
||||
| `agent.plan.executed` | Plan execution completed | `orgId`, `userId`, `courseId`, `sectionsCreated`, `lessonsCreated`, `exercisesCreated`, `durationMs`, `completedFully` |
|
||||
|
||||
### Ingestion Pattern
|
||||
|
||||
Events are sent to Tinybird via its **Events API** (`POST https://api.tinybird.co/v0/events`). The API fires events asynchronously (fire-and-forget) — agent performance is never blocked by analytics.
|
||||
|
||||
```typescript
|
||||
// Thin wrapper in apps/api/src/utils/tinybird.ts
|
||||
async function trackAgentEvent(eventName: string, data: Record<string, unknown>) {
|
||||
if (!TINYBIRD_TOKEN) return; // Skip silently when not configured (self-hosted)
|
||||
fetch(`${TINYBIRD_BASE_URL}/v0/events?name=${eventName}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${TINYBIRD_TOKEN}` },
|
||||
body: JSON.stringify({ ...data, timestamp: new Date().toISOString() })
|
||||
}).catch(() => {}); // Fire-and-forget, never block the agent
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `TINYBIRD_TOKEN` — Tinybird API token for event ingestion
|
||||
- `TINYBIRD_BASE_URL` — Tinybird API base URL (default: `https://api.tinybird.co`)
|
||||
|
||||
When not configured (self-hosted, or dev environments), events are silently skipped. No errors, no impact on functionality.
|
||||
|
||||
### What This Enables
|
||||
|
||||
- **Debugging**: "Teacher X says the AI created wrong content" -> query `agent.tool.called` and `agent.tool.completed` events for that user + course to see exactly which tools fired and what arguments were passed
|
||||
- **Usage analytics**: aggregate tool call frequency, popular actions, average conversation length per org
|
||||
- **Error monitoring**: track `agent.chat.error` and `agent.tool.failed` rates by model, tool, or org
|
||||
- **Plan execution monitoring**: track `agent.plan.executed` to see completion rates, average plan sizes, how often teachers modify vs. accept plans as-is
|
||||
- **Cost analysis**: correlate token usage with tool call patterns to understand what drives cost
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted vs Cloud
|
||||
|
||||
- **Cloud**: AI assistant enabled by default. ClassroomIO provides the LLM API key server-side. Usage metered per plan tier.
|
||||
- **Self-hosted**: AI assistant **disabled by default**. To enable, admin sets `AI_API_KEY` and optionally `AI_PROVIDER` / `AI_MODEL` in env. When configured, assistant becomes available. When not configured, button is hidden. No token metering for self-hosted (user's own API key, their cost).
|
||||
- **Self-hosted**: AI assistant **disabled by default**. To enable, admin sets at least one of `OPENAI_API_KEY`, `GOOGLE_API_KEY`, or `ANTHROPIC_API_KEY`. The dashboard model picker exposes Gemini 2.5 Flash (Google) and GPT-4o (OpenAI); each option requires its own provider key. Anthropic is supported in code but not exposed in the picker. The assistant button is hidden when no provider key is set. No token metering for self-hosted (user's own API key, their cost).
|
||||
|
||||
---
|
||||
|
||||
@@ -478,7 +628,7 @@ Right sidebar using `@cio/ui` Sheet (`side="right"`, ~420px). Does NOT replace t
|
||||
|
||||
- Attachment icon next to the text input (paperclip icon)
|
||||
- **BASIC plan users**: clicking the attachment icon triggers the upgrade modal (`openUpgradeModal()`). File picker does not open. A subtle tooltip or label can hint "Upgrade to upload documents".
|
||||
- **EARLY_ADOPTER+ users**: clicking opens file picker filtered to `.pdf, .docx`
|
||||
- **EARLY_ADOPTER+ users**: clicking opens file picker filtered to `.pdf, .docx, .pptx`
|
||||
- **Client-side size check**: if selected file exceeds 5MB, show inline error below the input: "File is too large. Maximum size is 5MB." File is not uploaded.
|
||||
- Selected file shows as a chip above the input: `[Biology-Textbook.pdf (24 pages)] [x]`
|
||||
- On send, file is uploaded first (`POST /agent/upload`), then `documentId` is included in the chat message
|
||||
@@ -592,7 +742,7 @@ New route domain: `/agent`
|
||||
|--------|-------|-------------|------|
|
||||
| `GET` | `/agent/status` | Feature flag + role + usage info | auth + orgMember |
|
||||
| `POST` | `/agent/chat` | Main chat endpoint (SSE stream) | auth + orgMember + courseMember |
|
||||
| `POST` | `/agent/upload` | Upload PDF/DOCX for parsing | auth + orgMember + courseTeamMember |
|
||||
| `POST` | `/agent/upload` | Upload PDF/DOCX/PPTX for parsing (EARLY_ADOPTER+ only) | auth + orgMember + courseTeamMember |
|
||||
| `GET` | `/agent/usage` | Detailed usage stats | auth + orgMember |
|
||||
| `POST` | `/agent/credits` | Purchase credits | auth + orgAdmin |
|
||||
|
||||
@@ -617,7 +767,7 @@ Flow:
|
||||
2. Check token balance
|
||||
3. If `documentId` provided, fetch extracted text from Redis and inject into context
|
||||
4. Load tools + system prompt based on role
|
||||
5. Call `streamText()` with `maxSteps: 10` (plan execution may need more steps than simple queries)
|
||||
5. Call `streamText()` with `maxSteps: 15`. Plan execution uses multi-round continuation (see Plan Mode section).
|
||||
6. Stream response to client
|
||||
7. Record token usage after completion
|
||||
|
||||
@@ -642,11 +792,15 @@ Registered in `apps/api/src/app.ts` as `.route('/agent', agentRouter)`.
|
||||
|
||||
## LLM Provider Abstraction
|
||||
|
||||
Configurable via environment variables:
|
||||
Per-provider API keys configure which models are available:
|
||||
|
||||
- `AI_PROVIDER` — `openai` | `anthropic` (default: `openai`)
|
||||
- `AI_API_KEY` — the provider API key
|
||||
- `AI_MODEL` — model name override (default: provider's recommended model)
|
||||
- `OPENAI_API_KEY` — enables GPT-4o (and `gpt-4o-mini` for title generation)
|
||||
- `GOOGLE_API_KEY` — enables Gemini 2.5 Flash
|
||||
- `ANTHROPIC_API_KEY` — enables Claude (Sonnet 4.5 / Haiku for titles); supported in code, not in the picker UI
|
||||
- `TINYBIRD_TOKEN` — Tinybird API token for event ingestion (optional; events silently skipped when not set)
|
||||
- `TINYBIRD_BASE_URL` — Tinybird API base URL (default: `https://api.tinybird.co`)
|
||||
|
||||
The dashboard model picker (above the chat input) is persisted per browser in `localStorage` under `classroomio-ai-chat-model`. Each chat request sends a `model` field; `apps/api/src/routes/agent/chat.ts` looks it up in `AGENT_MODELS` (`packages/utils/src/agent-models`) to resolve provider + backend model id, then 503s with `AI_NOT_CONFIGURED` if that provider's key is missing. Anthropic stays in `AGENT_MODELS` so backend code keeps working; it's gated out of the UI by the `UI_PICKER_MODEL_IDS` subset.
|
||||
|
||||
Provider factory in `packages/ai-assistant` normalizes into a single `LanguageModel` instance for `streamText()`.
|
||||
|
||||
@@ -724,27 +878,29 @@ Plan Mode works without a document too — the teacher can describe what they wa
|
||||
|
||||
### Phase 1: Foundation
|
||||
|
||||
- Create `packages/ai-assistant` with types (`AgentRole`, `AgentContext`, `CoursePlan`), provider factory, tool registry (9 v1 tools), system prompt with Plan/Agent mode instructions and no-hallucination boundary
|
||||
- Create `packages/ai-assistant` with types (`AgentRole`, `AgentContext`, `CoursePlan`), provider factory, tool registry (9 v1 tools), system prompt with Plan/Agent mode instructions, content format guidance, locale handling, and no-hallucination boundary
|
||||
- Add `ai_token_usage` and `ai_credit_balance` tables to DB schema
|
||||
- Add `GET /agent/status` and `POST /agent/chat` route skeleton with role detection (students get 403 in v1) and token balance check
|
||||
- Add `POST /agent/upload` route with PDF/DOCX parsing (`pdf-parse`, `mammoth`)
|
||||
- Add Redis key management for uploaded document text
|
||||
- Add `POST /agent/upload` route with PDF/DOCX/PPTX parsing (`pdf-parse`, `mammoth`, `pptx-parser`), 100K char text truncation, EARLY_ADOPTER+ plan gating
|
||||
- Add Redis key management for uploaded document text (one document per conversation, replacement on re-upload)
|
||||
- Set up Tinybird data sources and add `trackAgentEvent()` utility
|
||||
- Add "AI Assistant" button to course sidebar (gated on status endpoint + plan check)
|
||||
- Build right sidebar Sheet with basic chat UI (hardcoded echo responses)
|
||||
- Add file upload UI (attachment icon, file chip, upload progress)
|
||||
- Add file upload UI (attachment icon, file chip, upload progress, plan-gated upgrade modal for BASIC users)
|
||||
|
||||
### Phase 2: Agent Core
|
||||
|
||||
- Implement provider factory (OpenAI + Anthropic) using `createModel(config)`
|
||||
- Wire 9 v1 tools to existing services via `streamText()` with `maxSteps: 10`
|
||||
- Implement `generate_course_plan` tool with `CoursePlan` JSON schema
|
||||
- Record token usage after each call; enforce balance check before each call
|
||||
- Wire 9 v1 tools to existing services via `streamText()` with `maxSteps: 15`
|
||||
- Implement `generate_course_plan` tool with `CoursePlan` JSON schema and token cost estimation
|
||||
- Implement multi-round plan execution with automatic continuation and partial failure/resume
|
||||
- Add existing course content detection — append vs. inform teacher before plan execution
|
||||
- Record token usage after each call; enforce balance check before each call (including mid-plan checks)
|
||||
- Instrument all tool calls and chat completions with Tinybird events
|
||||
- Connect chat UI to streaming API via `@ai-sdk/svelte` `Chat` class
|
||||
- Render streamed tokens and tool status cards
|
||||
- Build Plan View component (structured plan card with sections/lessons)
|
||||
- Build Agent Mode progress card (checklist with real-time updates)
|
||||
- Implement "Implement Plan" button -> plan execution flow
|
||||
- Add usage meter to AI assistant sidebar header
|
||||
- Build Plan View component (structured plan card with sections/lessons + cost estimate + "Implement Plan" / "Ask for Changes")
|
||||
- Build Agent Mode progress card (checklist with real-time updates, persists across multi-round execution, shows partial completion state + "Resume" button on failure)
|
||||
- Implement `onFinish` store refresh for course content tree, lessons, exercises
|
||||
|
||||
### Phase 3: Polish + Safeguards + Credits
|
||||
@@ -784,9 +940,12 @@ Plan Mode works without a document too — the teacher can describe what they wa
|
||||
| `ai` | `packages/ai-assistant`, `apps/api` | Vercel AI SDK core (`streamText`, `tool`, types) |
|
||||
| `@ai-sdk/svelte` | `apps/dashboard` | Svelte 5 `Chat` class for streaming chat |
|
||||
| `@ai-sdk/openai` | `packages/ai-assistant` | OpenAI provider |
|
||||
| `@ai-sdk/google` | `packages/ai-assistant` | Google (Gemini) provider |
|
||||
| `@ai-sdk/anthropic` | `packages/ai-assistant` | Anthropic provider |
|
||||
| `pdf-parse` | `apps/api` | PDF text extraction |
|
||||
| `mammoth` | `apps/api` | DOCX text extraction |
|
||||
| `pptx-parser` | `apps/api` | PPTX text extraction |
|
||||
| `@tinybird-sdk/node` | `apps/api` | Tinybird event ingestion (or raw fetch — see Observability section) |
|
||||
|
||||
---
|
||||
|
||||
@@ -797,6 +956,7 @@ Plan Mode works without a document too — the teacher can describe what they wa
|
||||
- `packages/ai-assistant/` — entire new package (types, providers, tools, prompts)
|
||||
- `apps/api/src/routes/agent/` — agent route handlers (chat, upload, status, usage, credits)
|
||||
- `apps/api/src/services/agent/` — agent service (document parsing, token tracking)
|
||||
- `apps/api/src/utils/tinybird.ts` — Tinybird event tracking utility
|
||||
- `apps/dashboard/src/lib/features/ai-assistant/` — UI components (sheet, chat, plan view, progress card, file upload)
|
||||
|
||||
### Modified files
|
||||
@@ -818,11 +978,17 @@ Plan Mode works without a document too — the teacher can describe what they wa
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Teacher can upload a PDF/DOCX and get a proposed course structure within 30 seconds
|
||||
- Teacher can upload a PDF/DOCX/PPTX and get a proposed course structure within 30 seconds
|
||||
- BASIC plan users see upgrade modal when attempting to upload a document
|
||||
- Teacher can review, edit, and approve the plan before any content is created
|
||||
- Agent can execute an approved plan end-to-end: creating sections, lessons, and content
|
||||
- Agent shows estimated token cost before plan execution
|
||||
- Agent can execute an approved plan end-to-end: creating sections, lessons, and content, with multi-round continuation for large plans
|
||||
- Plan execution can resume after failure (token exhaustion, network error, or manual stop)
|
||||
- Agent checks for existing course content and handles append correctly
|
||||
- Generated lesson content renders correctly in the TipTap editor
|
||||
- Teacher can see real-time progress during plan execution
|
||||
- All standard agent actions work: update lesson text, generate questions, draft lessons
|
||||
- No user can modify another org's data; all actions pass permission checks
|
||||
- Self-hosted instances can enable/disable the assistant via env var
|
||||
- Token usage is tracked and metered per org plan
|
||||
- All agent events (tool calls, errors, plan executions) are tracked in Tinybird for observability
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
1. AI assistant is teacher-only (course team members; not students).
|
||||
2. Agent executes via API using Vercel AI SDK tool-calling pattern.
|
||||
3. All actions are org/course-scoped; no cross-org access.
|
||||
4. Self-hosted: assistant disabled by default; enabled via `AI_API_KEY` env var.
|
||||
4. Self-hosted: assistant disabled by default; enabled when any of `OPENAI_API_KEY`, `GOOGLE_API_KEY`, or `ANTHROPIC_API_KEY` is set.
|
||||
5. Chat lives in a right sidebar (Sheet) triggered from the course sidebar navigation.
|
||||
6. Two modes: **Plan Mode** (propose course structure from document or prompt) and **Agent Mode** (execute approved actions).
|
||||
7. Supported actions: upload PDF/DOCX, generate course plan, create sections, create/update lessons, create exercises with questions.
|
||||
7. Supported actions: upload PDF/DOCX/PPTX, generate course plan, create sections, create/update lessons, create exercises with questions.
|
||||
8. One document per conversation. BASIC plan users cannot upload (upgrade modal).
|
||||
9. Extracted document text capped at 100K characters for LLM context limits.
|
||||
10. Agent events tracked in Tinybird for observability (fire-and-forget, silently skipped when not configured).
|
||||
|
||||
## Delivery Phases
|
||||
|
||||
1. Phase 1: Foundation — package + types + DB tables + route skeletons + upload endpoint + sidebar UI shell
|
||||
1. Phase 1: Foundation — package + types + DB tables + route skeletons + upload endpoint + Tinybird setup + sidebar UI shell
|
||||
2. Phase 2: Agent Core — LLM integration + 9 tools + streaming chat + plan view + agent execution + usage tracking
|
||||
3. Phase 3: Polish — rate limits, quick actions, self-host gating, credits, stop button
|
||||
4. Phase 4: More Tools — section/lesson/exercise management (delete, reorder, update metadata)
|
||||
@@ -27,14 +30,15 @@
|
||||
| AI-1.1 | Package | Create `packages/ai-assistant` with types (`AgentRole`, `AgentContext`, `CoursePlan`, `AIProviderConfig`) | `packages/ai-assistant/src/types.ts` | None |
|
||||
| AI-1.2 | Package | Implement provider factory (`createModel(config)`) with OpenAI + Anthropic | `packages/ai-assistant/src/providers/index.ts` | AI-1.1 |
|
||||
| AI-1.3 | Package | Implement tool registry (`getToolSchemas(role)`) with 9 v1 tool schemas | `packages/ai-assistant/src/tools/` | AI-1.1 |
|
||||
| AI-1.4 | Package | Implement system prompt builder with Plan/Agent mode instructions + no-hallucination boundary | `packages/ai-assistant/src/prompt/` | AI-1.1 |
|
||||
| AI-1.4 | Package | Implement system prompt builder with Plan/Agent mode instructions, TipTap HTML format guidance, locale handling, content length guidance, and no-hallucination boundary | `packages/ai-assistant/src/prompt/` | AI-1.1 |
|
||||
| AI-1.5 | DB | Add `ai_token_usage` and `ai_credit_balance` tables to schema + migration | `packages/db/src/schema.ts` | None |
|
||||
| AI-1.6 | API | Add agent route skeleton (`/agent/status`, `/agent/chat`, `/agent/upload`) with auth middleware + role detection | `apps/api/src/routes/agent/` | AI-1.1 |
|
||||
| AI-1.7 | API | Implement `POST /agent/upload` — plan gating (EARLY_ADOPTER+ required, 403 for BASIC), file size validation (max 5MB), PDF parsing (`pdf-parse`) + DOCX parsing (`mammoth`) + Redis storage | `apps/api/src/services/agent/document.ts` | AI-1.6 |
|
||||
| AI-1.7 | API | Implement `POST /agent/upload` — plan gating (EARLY_ADOPTER+ required, 403 for BASIC), file size validation (max 5MB), PDF parsing (`pdf-parse`) + DOCX parsing (`mammoth`) + PPTX parsing (`pptx-parser`) + text truncation (100K char cap) + Redis storage (one doc per conversation, replace on re-upload) | `apps/api/src/services/agent/document.ts` | AI-1.6 |
|
||||
| AI-1.8 | API | Add Redis key generators for agent documents | `apps/api/src/utils/redis/key-generators.ts` | None |
|
||||
| AI-1.8b | API | Set up Tinybird data sources and implement `trackAgentEvent()` fire-and-forget utility. Silently skip when `TINYBIRD_TOKEN` not configured. | `apps/api/src/utils/tinybird.ts` | None |
|
||||
| AI-1.9 | Dashboard | Add "AI Assistant" button to course sidebar nav (gated on `/agent/status`) | `course-sidebar-navigation.svelte` | AI-1.6 |
|
||||
| AI-1.10 | Dashboard | Build right sidebar Sheet with placeholder chat UI (message list + input + file upload button) | `apps/dashboard/src/lib/features/ai-assistant/` | AI-1.9 |
|
||||
| AI-1.11 | Dashboard | Implement file upload flow — plan gate (BASIC clicks attachment icon -> `openUpgradeModal()`), client-side 5MB size check, file picker -> upload -> chip display, handle 403 from server by triggering upgrade modal | `apps/dashboard/src/lib/features/ai-assistant/` | AI-1.7, AI-1.10 |
|
||||
| AI-1.11 | Dashboard | Implement file upload flow — plan gate (BASIC clicks attachment icon -> `openUpgradeModal()`), client-side 5MB size check, file picker (`.pdf, .docx, .pptx`) -> upload -> chip display (show "truncated" badge if applicable), handle 403 from server by triggering upgrade modal | `apps/dashboard/src/lib/features/ai-assistant/` | AI-1.7, AI-1.10 |
|
||||
|
||||
### Phase 2: Agent Core
|
||||
|
||||
@@ -44,14 +48,15 @@
|
||||
| AI-2.2 | API | Implement tool execute functions: `get_course_structure`, `get_lesson_content`, `get_exercise_details` (read tools) | `apps/api/src/routes/agent/chat.ts` | AI-2.1 |
|
||||
| AI-2.3 | API | Implement tool execute functions: `create_section`, `create_lesson`, `update_lesson_content` (write tools) | `apps/api/src/routes/agent/chat.ts` | AI-2.1 |
|
||||
| AI-2.4 | API | Implement tool execute functions: `create_exercise`, `add_questions` (exercise tools) | `apps/api/src/routes/agent/chat.ts` | AI-2.1 |
|
||||
| AI-2.5 | API | Implement `generate_course_plan` tool — structured `CoursePlan` JSON output | `apps/api/src/routes/agent/chat.ts` | AI-2.1 |
|
||||
| AI-2.5 | API | Implement `generate_course_plan` tool — structured `CoursePlan` JSON output with token cost estimation | `apps/api/src/routes/agent/chat.ts` | AI-2.1 |
|
||||
| AI-2.5b | API | Implement multi-round plan execution — automatic continuation when `maxSteps` reached, partial failure/resume support, existing course content detection (append logic) | `apps/api/src/routes/agent/chat.ts` | AI-2.3, AI-2.5 |
|
||||
| AI-2.6 | API | Inject uploaded document text from Redis into LLM context when `documentId` present | `apps/api/src/routes/agent/chat.ts` | AI-1.7, AI-2.1 |
|
||||
| AI-2.7 | API | Token usage tracking — record `promptTokens` + `completionTokens` after each `streamText()` call | `apps/api/src/services/agent/usage.ts` | AI-1.5, AI-2.1 |
|
||||
| AI-2.8 | API | Token balance check — enforce before each LLM call, return 402 when exhausted | `apps/api/src/services/agent/usage.ts` | AI-2.7 |
|
||||
| AI-2.7 | API | Token usage tracking — record `promptTokens` + `completionTokens` after each `streamText()` call. Instrument all tool calls and chat completions with Tinybird events. | `apps/api/src/services/agent/usage.ts` | AI-1.5, AI-1.8b, AI-2.1 |
|
||||
| AI-2.8 | API | Token balance check — enforce before each LLM call and between plan execution rounds, return 402 when exhausted | `apps/api/src/services/agent/usage.ts` | AI-2.7 |
|
||||
| AI-2.9 | Dashboard | Connect chat UI to `/agent/chat` via `@ai-sdk/svelte` `Chat` class | `apps/dashboard/src/lib/features/ai-assistant/` | AI-2.1, AI-1.10 |
|
||||
| AI-2.10 | Dashboard | Render streamed tokens and tool status cards (spinner -> checkmark) | `apps/dashboard/src/lib/features/ai-assistant/` | AI-2.9 |
|
||||
| AI-2.11 | Dashboard | Build Plan View component — structured plan card with sections/lessons tree, "Implement Plan" and "Ask for Changes" buttons | `apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte` | AI-2.5, AI-2.9 |
|
||||
| AI-2.12 | Dashboard | Build Agent Mode progress card — real-time checklist during plan execution | `apps/dashboard/src/lib/features/ai-assistant/progress-card.svelte` | AI-2.11 |
|
||||
| AI-2.11 | Dashboard | Build Plan View component — structured plan card with sections/lessons tree, token cost estimate, "Implement Plan" and "Ask for Changes" buttons | `apps/dashboard/src/lib/features/ai-assistant/plan-view.svelte` | AI-2.5, AI-2.9 |
|
||||
| AI-2.12 | Dashboard | Build Agent Mode progress card — real-time checklist during multi-round plan execution, persists across rounds, shows partial completion + "Resume" button on failure, token exhaustion state | `apps/dashboard/src/lib/features/ai-assistant/progress-card.svelte` | AI-2.11 |
|
||||
| AI-2.13 | Dashboard | Implement `onFinish` store refresh — course content tree, lesson, exercise data | `apps/dashboard/src/lib/features/ai-assistant/` | AI-2.9 |
|
||||
| AI-2.14 | Dashboard | Add usage meter to AI assistant sidebar header | `apps/dashboard/src/lib/features/ai-assistant/` | AI-2.8 |
|
||||
|
||||
@@ -61,7 +66,7 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| AI-3.1 | API | Tool-level permission re-validation (verify resource belongs to course/org in every tool execute) | `apps/api/src/routes/agent/chat.ts` | AI-2.2, AI-2.3, AI-2.4 |
|
||||
| AI-3.2 | API | Rate limiting — per-user, per-org requests/minute on agent endpoints | `apps/api/src/middlewares/` | AI-1.6 |
|
||||
| AI-3.3 | API | Self-host env var detection: hide assistant when no `AI_API_KEY`; uncap rate limits for self-hosted | `apps/api/src/routes/agent/status.ts` | AI-1.6 |
|
||||
| AI-3.3 | API | Self-host env var detection: hide assistant when no provider key is set (`OPENAI_API_KEY` / `GOOGLE_API_KEY` / `ANTHROPIC_API_KEY`); uncap rate limits for self-hosted | `apps/api/src/routes/agent/status.ts` | AI-1.6 |
|
||||
| AI-3.4 | API | Conversation context windowing — trim long message histories, especially after document uploads | `apps/api/src/routes/agent/chat.ts` | AI-2.1 |
|
||||
| AI-3.5 | API | `POST /agent/credits` — credit purchase endpoint (orgAdmin only) | `apps/api/src/routes/agent/credits.ts` | AI-1.5 |
|
||||
| AI-3.6 | API | `GET /agent/usage` — detailed usage stats endpoint | `apps/api/src/routes/agent/usage.ts` | AI-2.7 |
|
||||
@@ -77,7 +82,7 @@
|
||||
```typescript
|
||||
// Response
|
||||
{
|
||||
enabled: boolean, // false when self-hosted + no AI_API_KEY
|
||||
enabled: boolean, // false when self-hosted + no provider key set
|
||||
role: 'teacher' | 'student',
|
||||
usage: {
|
||||
used: number,
|
||||
@@ -107,8 +112,8 @@
|
||||
### POST /agent/upload
|
||||
|
||||
```typescript
|
||||
// Request: multipart/form-data with 'file' field (PDF or DOCX, max 5MB)
|
||||
// Requires: EARLY_ADOPTER+ plan
|
||||
// Request: multipart/form-data with 'file' field (PDF, DOCX, or PPTX, max 5MB)
|
||||
// Requires: EARLY_ADOPTER+ plan. One document per conversation (replaces previous).
|
||||
|
||||
// Success Response (200)
|
||||
{
|
||||
@@ -117,13 +122,14 @@
|
||||
mimeType: string,
|
||||
pageCount: number | null,
|
||||
wordCount: number,
|
||||
textPreview: string
|
||||
textPreview: string,
|
||||
truncated: boolean // true if text exceeded 100K char limit
|
||||
}
|
||||
|
||||
// Error Responses
|
||||
// 403: { error: "document_upload_requires_upgrade", upgradeRequired: true } — BASIC plan
|
||||
// 413: { error: "file_too_large", maxSize: 5242880 } — > 5MB
|
||||
// 415: { error: "unsupported_file_type", allowed: [...] } — not PDF/DOCX
|
||||
// 415: { error: "unsupported_file_type", allowed: [...] } — not PDF/DOCX/PPTX
|
||||
```
|
||||
|
||||
### GET /agent/usage
|
||||
|
||||
@@ -82,36 +82,39 @@ When a course is created or updated with type `COMPLIANCE`, the following settin
|
||||
| `maxRetakeAttempts` | integer \| null | — | `null` | Max attempts per retake cycle. `null` = unlimited |
|
||||
| `passingScore` | integer | — | `80` | Minimum score (0–100) to count as a valid completion |
|
||||
|
||||
**CCT-3: Completion Validity Window**
|
||||
- When a learner completes a compliance course (meets passing score), their completion is **valid for `retakeIntervalMonths` months**
|
||||
**CCT-3: Due Date Ownership**
|
||||
- The initial `dueDate` for cycle 1 is set by the course admin/teacher when learners are assigned or enrolled into a compliance course
|
||||
- `dueDate` is the operational deadline used for reminder scheduling, grace period handling, extensions, and overdue status
|
||||
- For renewal cycles, the system automatically creates the next cycle with `dueDate = validUntil` from the previous compliant cycle
|
||||
- Admin actions such as extend or reset may override the current cycle's `dueDate`
|
||||
|
||||
**CCT-4: Completion Validity Window**
|
||||
- When a learner completes a compliance course, their completion is valid for `retakeIntervalMonths` months
|
||||
- `validUntil` = `completedAt` + `retakeIntervalMonths`
|
||||
- `validUntil` is the date that triggers renewal into the next cycle
|
||||
- The system tracks this validity window per learner per course in the `course_completion_record` table
|
||||
- A valid completion satisfies compliance; an expired completion does not
|
||||
|
||||
**CCT-4: Retake Lifecycle**
|
||||
**CCT-5: Retake Lifecycle**
|
||||
A learner's relationship to a compliance course follows this cycle:
|
||||
|
||||
```
|
||||
Enrolled → In Progress → Completed (Compliant)
|
||||
↓
|
||||
[retakeIntervalMonths pass]
|
||||
↓
|
||||
Expiring Soon ← reminder notifications sent
|
||||
↓
|
||||
[validity expires]
|
||||
↓
|
||||
In Grace Period ← if gracePeriodDays > 0
|
||||
↓
|
||||
[grace period ends]
|
||||
↓
|
||||
Non-Compliant ← escalation triggers
|
||||
↓
|
||||
[learner re-enrolled automatically]
|
||||
↓
|
||||
In Progress → Completed (Compliant) → ...
|
||||
Cycle 1 created → Not Started → In Progress → Completed (Compliant)
|
||||
↓
|
||||
[validUntil reaches current date]
|
||||
↓
|
||||
Cycle 2 created automatically as new active cycle
|
||||
↓
|
||||
Not Started → Expiring Soon → In Grace Period → Non-Compliant → Completed
|
||||
```
|
||||
|
||||
**CCT-5: Compliance Status per Learner**
|
||||
- There is exactly one active cycle per learner per compliance course
|
||||
- The active cycle owns the learner's current `status`, `dueDate`, reminder schedule, and overdue state
|
||||
- When a learner completes a cycle, that cycle becomes historical and remains visible in history
|
||||
- When `validUntil` is reached, the system creates the next cycle as the new active record with status `not_started`
|
||||
- The previous cycle is preserved for audit/history and is no longer used as the source of current reminder or overdue state
|
||||
|
||||
**CCT-6: Compliance Status per Learner**
|
||||
Each learner enrolled in a compliance course has one of these statuses (tracked on `course_completion_record`):
|
||||
|
||||
| Status | Condition |
|
||||
@@ -124,17 +127,23 @@ Each learner enrolled in a compliance course has one of these statuses (tracked
|
||||
| `non_compliant` | Past expiry + grace period with no valid completion |
|
||||
| `waived` | Exempted by admin (with reason and optional expiration) |
|
||||
|
||||
**CCT-6: Automatic Re-enrollment**
|
||||
**CCT-7: Automatic Re-enrollment**
|
||||
- When a learner's completion validity expires, the system automatically creates a new completion record with status `not_started` and a new `dueDate`
|
||||
- Previous completions are preserved in history (never deleted)
|
||||
- The learner sees the course reappear in their "Due" list
|
||||
- A background job (`compliance-expiry-checker`) runs daily to process expirations
|
||||
|
||||
**CCT-7: Auto-grading Behavior**
|
||||
- Like `SELF_PACED` courses, `COMPLIANCE` courses auto-grade submissions when all questions are auto-gradable
|
||||
- If the learner's score meets or exceeds `passingScore`, the completion is recorded as valid
|
||||
**CCT-8: Completion Rule**
|
||||
- Compliance completion is determined by the course completion rule, not by any single passing submission in isolation
|
||||
- For v1, a compliance cycle is complete when the learner satisfies the course's configured completion requirements and achieves `passingScore` on the designated final assessment for the cycle
|
||||
- Submission and lesson events may trigger a re-check, but a centralized compliance completion service decides whether the cycle becomes `compliant`
|
||||
- If below `passingScore` and `maxRetakeAttempts` is not reached, the learner may retry
|
||||
|
||||
**CCT-9: Auto-grading Behavior**
|
||||
- Like `SELF_PACED` courses, `COMPLIANCE` courses auto-grade submissions when all questions are auto-gradable
|
||||
- Auto-graded results feed into the centralized compliance completion evaluation
|
||||
- Manually graded assessments may also complete the cycle once grading is finished and the completion rule is satisfied
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Notifications & Reminders
|
||||
@@ -161,6 +170,11 @@ Automatically remind learners before their compliance course deadlines and notif
|
||||
- When a new retake cycle is created (automatic re-enrollment), notify the learner
|
||||
- Email includes: course name, new due date, link to start
|
||||
|
||||
**NR-5: Notification Idempotency**
|
||||
- Reminder and alert delivery is persisted per cycle so scheduled jobs can be re-run safely
|
||||
- The system records reminder events such as `reminder_30d`, `reminder_7d`, `reminder_1d`, `renewal_created`, and `became_non_compliant`
|
||||
- A given reminder or alert may be sent at most once per cycle per channel unless an admin explicitly triggers a manual resend
|
||||
|
||||
> **Note**: The notification system PRD (`prd/notification-system`) covers the general delivery infrastructure (templates, channels, preferences). This section only defines the compliance-specific triggers.
|
||||
|
||||
---
|
||||
@@ -185,7 +199,10 @@ Certificates issued for compliance courses should carry an expiration date match
|
||||
- Certificate PDF shows: completion date, expiration date, cycle number, framework tag (if set)
|
||||
- Certificate list view in dashboard shows validity status badge (valid/expiring/expired)
|
||||
|
||||
> **Note**: This builds on the existing certificate system (`course.certificate` JSONB settings). No new certificate tables are needed — the `course_completion_record.cycle_number` links each certificate to its retake cycle.
|
||||
**CL-4: Certificate Persistence**
|
||||
- Compliance certificates are stored per cycle in a dedicated issuance table linked to `course_completion_record`
|
||||
- Each issuance stores `issuedAt`, `expiresAt`, `status`, and the associated `cycleNumber`
|
||||
- The existing course-level certificate settings remain the configuration source, but per-cycle issuance history is persisted separately so learners and admins can view all historical certificates
|
||||
|
||||
---
|
||||
|
||||
@@ -278,7 +295,9 @@ On the organization dashboard, show a summary card for compliance courses:
|
||||
- Course card turns orange: "In Grace Period" (if grace period > 0)
|
||||
- OR red: "Non-Compliant" (if no grace period)
|
||||
↓
|
||||
5. Student clicks course → Sees their previous completion history:
|
||||
5. Student clicks the same course → Sees the active cycle plus prior cycle history:
|
||||
- This is not a separate course and not a course-switcher in nav
|
||||
- The learner stays in the same course shell and sees a "Current Cycle" panel plus a "Past Cycles" history list
|
||||
"Cycle 1: Completed Jan 2025 (Score: 92%) — Expired"
|
||||
"Cycle 2: Due by Jan 2026 — [Start Retake]"
|
||||
↓
|
||||
@@ -371,6 +390,36 @@ CREATE INDEX idx_ccr_due_date ON course_completion_record(due_date);
|
||||
CREATE INDEX idx_ccr_valid_until ON course_completion_record(valid_until);
|
||||
```
|
||||
|
||||
### New Table: `course_completion_notification_event`
|
||||
```sql
|
||||
CREATE TABLE course_completion_notification_event (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
course_completion_record_id UUID NOT NULL REFERENCES course_completion_record(id) ON DELETE CASCADE,
|
||||
channel VARCHAR NOT NULL, -- 'email', 'in_app'
|
||||
event_type VARCHAR NOT NULL, -- 'reminder_30d', 'renewal_created', 'became_non_compliant', etc.
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE(course_completion_record_id, channel, event_type)
|
||||
);
|
||||
```
|
||||
|
||||
### New Table: `course_certificate_issue`
|
||||
```sql
|
||||
CREATE TABLE course_certificate_issue (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
course_id UUID NOT NULL REFERENCES course(id) ON DELETE CASCADE,
|
||||
profile_id UUID NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
course_completion_record_id UUID NOT NULL REFERENCES course_completion_record(id) ON DELETE CASCADE,
|
||||
cycle_number INTEGER NOT NULL,
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR NOT NULL DEFAULT 'valid', -- 'valid', 'expiring_soon', 'expired', 'renewed'
|
||||
file_url TEXT,
|
||||
|
||||
UNIQUE(course_completion_record_id)
|
||||
);
|
||||
```
|
||||
|
||||
### Drizzle Schema (TypeScript)
|
||||
|
||||
```typescript
|
||||
@@ -527,8 +576,8 @@ Two new routes under `/internal/`, protected by the existing `PRIVATE_SERVER_KEY
|
||||
```
|
||||
POST /internal/compliance/check-expiry
|
||||
→ Checks valid_until dates across all compliance courses
|
||||
→ Transitions statuses: compliant → expiring_soon → in_grace_period → non_compliant
|
||||
→ Creates new cycle records (automatic re-enrollment) when expired
|
||||
→ Creates the next cycle when a compliant cycle reaches valid_until
|
||||
→ Transitions only the active cycle through: not_started/in_progress → expiring_soon → in_grace_period → non_compliant
|
||||
→ Returns: { processed: number, transitioned: number, reEnrolled: number }
|
||||
|
||||
POST /internal/compliance/send-reminders
|
||||
@@ -617,6 +666,7 @@ apps/dashboard/src/lib/features/course/
|
||||
apps/course-app/ (learner-facing)
|
||||
# Updated: show compliance status badge, due date, retake history on course page
|
||||
# Updated: course card shows "Due by" and color-coded status
|
||||
# Updated: same course page shows "Current Cycle" and "Past Cycles" history instead of separate course entries
|
||||
```
|
||||
|
||||
### Files That Need Code Changes
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Compliance UX
|
||||
|
||||
This document describes the compliance user journey that is implemented today. It is intentionally limited to the current behavior in code and dashboard UI.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applies only to courses whose type is `COMPLIANCE`
|
||||
- The `Compliance` nav item only appears for `COMPLIANCE` courses
|
||||
- The current implementation uses one course with compliance history inside it
|
||||
- It does not create separate retake courses or a course-switching UI
|
||||
|
||||
## Key prerequisites
|
||||
|
||||
- A teacher/admin must create or configure the course as `COMPLIANCE`
|
||||
- The course must have `certificate.deadline` set before student enrollment can create the initial compliance cycle
|
||||
- Student completion is currently tied to the existing course certification rule, not a separate compliance-only completion rule
|
||||
|
||||
## Teacher/Admin Journey
|
||||
|
||||
### 1. Configure the course
|
||||
|
||||
The teacher/admin creates or updates a course to use the `COMPLIANCE` type.
|
||||
|
||||
For the compliance lifecycle to begin for learners, the course must also have a certificate deadline configured. That deadline is used as the initial `dueDate` for the first compliance cycle.
|
||||
|
||||
### 2. Enroll learners
|
||||
|
||||
When a student is enrolled into the course, the system attempts to create that learner's first compliance record automatically.
|
||||
|
||||
This automatic record creation currently runs when enrollment happens through:
|
||||
|
||||
- direct course member add
|
||||
- bulk course member add
|
||||
- course invite acceptance
|
||||
- organization invite acceptance when courses are assigned
|
||||
- audience-based course enrollment
|
||||
|
||||
If the course has no certificate deadline, compliance record creation is blocked.
|
||||
|
||||
### 3. View course compliance status
|
||||
|
||||
Staff can open the course `Compliance` page.
|
||||
|
||||
The page currently shows:
|
||||
|
||||
- summary cards for learner counts by status
|
||||
- a learner table with current status, due date, validity date, and score
|
||||
|
||||
This is an overview of the latest compliance record per learner, not a multi-cycle drilldown UI.
|
||||
|
||||
### 4. Take bulk staff actions
|
||||
|
||||
On the course `Compliance` page, staff can select learners and apply bulk actions:
|
||||
|
||||
- `Reset`
|
||||
- `Extend`
|
||||
- `Waive`
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `Reset` creates a first record if none exists, or resets the learner's latest record back to `not_started` with a new due date
|
||||
- `Extend` updates the due date on the learner's current incomplete record; if the learner has no current incomplete record and also has no completed record, it creates a new `not_started` record
|
||||
- `Waive` marks the learner's current incomplete record as `waived`
|
||||
|
||||
The page supports:
|
||||
|
||||
- select all visible learners
|
||||
- clear selection
|
||||
- staff-entered due date for reset/extend
|
||||
- optional waiver reason
|
||||
- optional waiver expiration date
|
||||
|
||||
### 5. Refresh data
|
||||
|
||||
The `Compliance` page has a refresh action. It clears the local compliance state and reloads the course data.
|
||||
|
||||
## Student Journey
|
||||
|
||||
### 1. Join the course
|
||||
|
||||
The student joins the `COMPLIANCE` course through one of the supported enrollment flows.
|
||||
|
||||
If the course is correctly configured, the system creates the student's first compliance cycle automatically with:
|
||||
|
||||
- status `not_started`
|
||||
- cycle number `1`
|
||||
- due date from `certificate.deadline`
|
||||
|
||||
### 2. Take the course normally
|
||||
|
||||
The student experience remains inside the same course shell. There is no separate retake course.
|
||||
|
||||
The student works through lessons and exercises using the existing course flow.
|
||||
|
||||
### 3. Open the Compliance page
|
||||
|
||||
Students see a `Compliance` tab for compliance courses.
|
||||
|
||||
The page currently shows:
|
||||
|
||||
- `Current Cycle`
|
||||
- `History`
|
||||
|
||||
`Current Cycle` displays the latest active record fields that are currently available in the system:
|
||||
|
||||
- cycle number
|
||||
- status
|
||||
- due date
|
||||
- completed at
|
||||
- valid until
|
||||
- score
|
||||
- attempts
|
||||
|
||||
`History` shows previous and current cycle records in a table.
|
||||
|
||||
### 4. Complete the course
|
||||
|
||||
Compliance completion is not triggered by a dedicated "mark complete" action on the compliance page.
|
||||
|
||||
Instead, the system evaluates compliance after submission activity. When the student becomes eligible under the existing course certification rule:
|
||||
|
||||
- the active compliance record is marked `compliant`
|
||||
- `completedAt` is stored
|
||||
- `validUntil` is calculated from the course retake interval
|
||||
- a per-cycle certificate issue record is created if certificates are downloadable for the course
|
||||
|
||||
If the student is not yet eligible for the course certificate, the compliance record remains incomplete.
|
||||
|
||||
## System-driven lifecycle
|
||||
|
||||
Some parts of the journey are implemented as backend jobs rather than dashboard actions.
|
||||
|
||||
### Reminder and status scan
|
||||
|
||||
The system has an internal reminder/status scan that can:
|
||||
|
||||
- mark a record `expiring_soon`
|
||||
- move a learner into `in_grace_period`
|
||||
- move a learner into `non_compliant`
|
||||
- create reminder notification-event records for configured reminder offsets
|
||||
|
||||
This logic is implemented in the backend. There is no teacher-facing reminder management UI yet.
|
||||
|
||||
### Renewal cycle creation
|
||||
|
||||
When a compliant cycle reaches `validUntil`, the expiry job:
|
||||
|
||||
- marks the old cycle as expired
|
||||
- marks its certificate issue as expired
|
||||
- creates the next cycle as `not_started`
|
||||
- sets the next cycle's due date to the previous cycle's `validUntil`
|
||||
|
||||
There is no separate student retake selector in the UI. The renewal appears as another cycle in the same course history.
|
||||
|
||||
## Current UI boundaries
|
||||
|
||||
The following are implemented in the backend but not yet exposed as full dashboard workflows:
|
||||
|
||||
- staff drilldown into a specific learner's compliance history from the staff overview page
|
||||
- reminder management UI
|
||||
- explicit certificate history UI tied to compliance cycles
|
||||
|
||||
The current dashboard UX is therefore:
|
||||
|
||||
- staff: overview + learner table + bulk actions
|
||||
- student: current cycle + cycle history
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user