chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -6,6 +6,7 @@
BorderBeam,
CardBody,
CardContainer,
DotField,
DotPattern,
GradualSpacing,
HeroVideoDialog,
@@ -47,6 +48,30 @@
{/snippet}
</Story>
<Story name="DotField">
{#snippet template()}
<div
class="ui:relative ui:mx-auto ui:h-[500px] ui:w-full ui:max-w-3xl ui:overflow-hidden ui:rounded-xl ui:border ui:bg-background"
>
<DotField
dotRadius={1.5}
dotSpacing={14}
cursorRadius={500}
cursorForce={0.1}
bulgeOnly={true}
bulgeStrength={67}
glowRadius={160}
sparkle={false}
waveAmplitude={0}
class="ui:absolute ui:inset-0 ui:h-full ui:w-full"
/>
<div class="ui:relative ui:z-10 ui:flex ui:h-full ui:items-center ui:justify-center ui:text-sm">
Move the cursor over this area
</div>
</div>
{/snippet}
</Story>
<Story name="CardContainer">
{#snippet template()}
<CardContainer className="ui:max-w-lg">
@@ -0,0 +1,61 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { CourseCreator } from '@cio/ui/custom/course-creator';
const { Story } = defineMeta({
title: 'Molecules/CourseCreator',
component: CourseCreator,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#000000' }]
}
},
tags: ['autodocs']
});
</script>
<script>
const heading = 'What course do you want to create?';
const placeholder = 'Ask what you want to learn...';
const levelOptions = [
{ value: 'beginner', label: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' }
];
</script>
<Story name="Default">
{#snippet template()}
<div class="w-[800px] p-8">
<CourseCreator {heading} {placeholder} {levelOptions} onsubmit={(p) => console.log('submit', p)} />
</div>
{/snippet}
</Story>
<Story name="Loading">
{#snippet template()}
<div class="w-[800px] p-8">
<CourseCreator {heading} {placeholder} {levelOptions} loading={true} onsubmit={(p) => console.log('submit', p)} />
</div>
{/snippet}
</Story>
<Story name="Custom Options">
{#snippet template()}
<div class="w-[800px] p-8">
<CourseCreator
{heading}
{placeholder}
levelOptions={[
{ value: 'beginner', label: 'Beginner' },
{ value: 'intermediate', label: 'Intermediate' },
{ value: 'advanced', label: 'Advanced' },
{ value: 'expert', label: 'Expert' }
]}
onsubmit={(p) => console.log('submit', p)}
/>
</div>
{/snippet}
</Story>
@@ -12,10 +12,12 @@
NUMERIC_FIXTURE,
ORDERING_FIXTURE,
RADIO_FIXTURE,
SECTIONED_EXERCISE_FIXTURE,
SHORT_ANSWER_FIXTURE,
STAR_FIXTURE,
TEXTAREA_FIXTURE,
TRUE_FALSE_FIXTURE,
VIDEO_RECORDING_FIXTURE,
WORD_BANK_FIXTURE
} from './question-fixtures';
@@ -27,6 +29,16 @@
},
tags: ['autodocs']
});
const sectionLabels = { section: 'Section', questions: 'questions', points: 'points' };
const sectionOverviewLabels = { ...sectionLabels, back: 'Back', beginSection: 'Start section' };
const autoGroupedSection = {
...SECTIONED_EXERCISE_FIXTURE[0],
id: 'section-auto-grouped',
title: 'Section 1',
description: null,
colorTheme: 'blue' as const
};
</script>
<Story name="Checkbox">
@@ -39,6 +51,129 @@
{/snippet}
</Story>
<Story name="Exercise Sections - Section Intro">
{#snippet template()}
<div class="ui:max-w-5xl">
<ExerciseQuestion.SectionOverview
sectionTitle={SECTIONED_EXERCISE_FIXTURE[0].title}
sectionDescription={SECTIONED_EXERCISE_FIXTURE[0].description}
sectionNumber={1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
questionCount={SECTIONED_EXERCISE_FIXTURE[0].questions.length}
totalPoints={10}
colorTheme={SECTIONED_EXERCISE_FIXTURE[0].colorTheme}
onBegin={() => {}}
onBack={() => {}}
labels={sectionOverviewLabels}
/>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Auto Grouped Existing Questions">
{#snippet template()}
<div class="ui:max-w-5xl">
<ExerciseQuestion.SectionOverview
sectionTitle={autoGroupedSection.title}
sectionDescription={autoGroupedSection.description}
sectionNumber={1}
totalSections={2}
questionCount={autoGroupedSection.questions.length}
totalPoints={10}
colorTheme={autoGroupedSection.colorTheme}
onBegin={() => {}}
onBack={() => {}}
labels={sectionOverviewLabels}
/>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Question In Progress">
{#snippet template()}
<div class="ui:grid ui:max-w-5xl ui:gap-6 ui:lg:grid-cols-[190px_minmax(0,1fr)]">
<ExerciseQuestion.SectionNavigationSidebar
sections={SECTIONED_EXERCISE_FIXTURE.map((section, sectionIndex) => ({
id: section.id,
title: section.title,
isCurrent: sectionIndex === 0,
questions: section.questions.map((question, questionIndex) => ({
key: String(question.id),
label: `Q${questionIndex + 1}`,
isAnswered: questionIndex === 0,
isCurrent: sectionIndex === 0 && questionIndex === 1
}))
}))}
/>
<section class="ui:space-y-4">
<ExerciseQuestion.SectionHeader
title={SECTIONED_EXERCISE_FIXTURE[0].title}
description={SECTIONED_EXERCISE_FIXTURE[0].description}
sectionNumber={1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
colorTheme={SECTIONED_EXERCISE_FIXTURE[0].colorTheme}
questionCount={SECTIONED_EXERCISE_FIXTURE[0].questions.length}
totalPoints={10}
labels={sectionLabels}
/>
<ExerciseQuestion.QuestionRenderer
contract={{
mode: 'take',
question: SECTIONED_EXERCISE_FIXTURE[0].questions[1],
answer: TRUE_FALSE_FIXTURE.answer,
disabled: false
}}
/>
</section>
</div>
{/snippet}
</Story>
<Story name="Exercise Sections - Grading View">
{#snippet template()}
<div class="ui:max-w-4xl ui:space-y-8">
{#each SECTIONED_EXERCISE_FIXTURE as section, sectionIndex (section.id)}
<section class="ui:space-y-4">
<ExerciseQuestion.SectionHeader
title={section.title}
description={section.description}
sectionNumber={sectionIndex + 1}
totalSections={SECTIONED_EXERCISE_FIXTURE.length}
colorTheme={section.colorTheme}
questionCount={section.questions.length}
totalPoints={section.questions.length * 5}
labels={sectionLabels}
/>
<ExerciseQuestion.QuestionList
contract={{
mode: 'review',
questions: section.questions.map((question, questionIndex) => ({
...question,
title: `${questionIndex + 1}. ${question.title}`
})),
answersByKey: {
'q-radio': RADIO_FIXTURE.answer,
'q-true-false': TRUE_FALSE_FIXTURE.answer,
'q-short-answer': SHORT_ANSWER_FIXTURE.answer,
'q-ordering': ORDERING_FIXTURE.answer
},
disabled: true
}}
itemClass="ui:mb-4"
/>
<div class="ui:flex ui:justify-end ui:border-t ui:pt-3 ui:text-sm ui:font-medium">
Section subtotal: {section.questions.length * 4}/{section.questions.length * 5}
</div>
</section>
{/each}
</div>
{/snippet}
</Story>
<Story name="FileUpload">
{#snippet template()}
<QuestionTypeModes
@@ -49,6 +184,26 @@
{/snippet}
</Story>
<Story
name="VideoRecording"
parameters={{
docs: {
description: {
story:
'Browser-recorded video answer with a circular student recording UI, upload states, retakes, and manual grading metadata.'
}
}
}}
>
{#snippet template()}
<QuestionTypeModes
question={VIDEO_RECORDING_FIXTURE.question}
answer={VIDEO_RECORDING_FIXTURE.answer}
viewAnswer={null}
/>
{/snippet}
</Story>
<Story name="FillBlank">
{#snippet template()}
<QuestionTypeModes
@@ -6,6 +6,15 @@ export interface QuestionStoryFixture {
wrongAnswer?: unknown;
}
export interface ExerciseSectionStoryFixture {
id: string;
title: string;
description?: string;
order: number;
colorTheme: 'blue' | 'green' | 'amber' | 'rose' | 'violet' | 'slate';
questions: Record<string, unknown>[];
}
const OPTION_IMAGE_A = 'https://brand.cdn.clsrio.com/classroomio-lms-courses.png';
const OPTION_IMAGE_B = 'https://assets.cdn.clsrio.com/www/futuristic-classroom.jpg';
@@ -196,6 +205,34 @@ export const FILE_UPLOAD_FIXTURE: QuestionStoryFixture = {
answer: 'lesson-plan-week-03.pdf'
};
export const VIDEO_RECORDING_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-video-recording',
key: 'q-video-recording',
title: 'Record a short reflection on what you learned. [VIDEO_RECORDING]',
questionType: 'VIDEO_RECORDING',
settings: {
description: 'Use the browser camera and microphone. You can retake before submitting.',
maxDurationSeconds: 120,
allowRetakes: true
}
},
answer: {
type: 'VIDEO_RECORDING',
assetId: '00000000-0000-4000-8000-000000000001',
storageKey: 'exercise-recordings/story/video-recording-demo.webm',
fileName: 'video-recording-demo.webm',
mimeType: 'video/webm',
size: 1_204_224,
durationSeconds: 42,
recordedAt: '2026-04-30T12:00:00.000Z',
uploadedAt: '2026-04-30T12:01:00.000Z',
provider: 'cloudflare',
retakeCount: 2,
playbackUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm'
}
};
export const MATCHING_FIXTURE: QuestionStoryFixture = {
question: {
id: 'q-matching',
@@ -256,3 +293,22 @@ export const LINK_FIXTURE: QuestionStoryFixture = {
},
answer: ['https://classroomio.com/docs', 'https://www.svelte.dev']
};
export const SECTIONED_EXERCISE_FIXTURE: ExerciseSectionStoryFixture[] = [
{
id: 'section-foundations',
title: 'Foundations',
description: 'A short warm-up section before the applied scenario.',
order: 0,
colorTheme: 'blue',
questions: [RADIO_FIXTURE.question, TRUE_FALSE_FIXTURE.question]
},
{
id: 'section-application',
title: 'Applied Practice',
description: 'Students solve open-response and ordering tasks.',
order: 1,
colorTheme: 'green',
questions: [SHORT_ANSWER_FIXTURE.question, ORDERING_FIXTURE.question]
}
];
@@ -105,6 +105,41 @@ export const QUESTION_LABELS = {
'file_upload.take.download': 'Download',
'file_upload.preview.accepted_types_label': 'Accepted types',
'file_upload.preview.max_size_label': 'Max size (MB)',
'video_recording.edit.max_duration_label': 'Max duration',
'video_recording.edit.max_duration_helper': 'Students record in the browser and can retake before submitting.',
'video_recording.edit.max_duration_placeholder': '60',
'video_recording.edit.manual_grading': 'Requires manual grading',
'video_recording.take.unsupported': 'Video recording is not supported in this browser.',
'video_recording.take.permission_prompt': 'Allow camera and microphone access to record your answer.',
'video_recording.take.permission_denied': 'Camera or microphone access was blocked. Check browser permissions.',
'video_recording.take.start_camera': 'Set up camera',
'video_recording.take.camera_label': 'Camera',
'video_recording.take.camera_placeholder': 'Select camera',
'video_recording.take.play_preview': 'Play recording preview',
'video_recording.take.pause_preview': 'Pause recording preview',
'video_recording.take.start_recording': 'Start recording',
'video_recording.take.stop_recording': 'Stop recording',
'video_recording.take.elapsed': 'Elapsed',
'video_recording.take.remaining': 'Remaining',
'video_recording.take.max_duration': 'Max',
'video_recording.take.too_short': 'Record for at least 1 second.',
'video_recording.take.use_recording': 'Use recording',
'video_recording.take.retake': 'Retake',
'video_recording.take.delete': 'Delete recording',
'video_recording.take.retry_upload': 'Retry upload',
'video_recording.take.uploading': 'Uploading recording...',
'video_recording.take.uploaded': 'Recording uploaded',
'video_recording.take.upload_failed': 'Upload failed. Try again.',
'video_recording.take.replaces_previous': 'A new accepted take replaces the previous answer after upload.',
'video_recording.preview.max_duration_label': 'Max duration',
'video_recording.preview.manual_grading': 'Learners record a short video response. Manual grading required.',
'video_recording.submission.no_video': 'No video submitted.',
'video_recording.submission.unavailable': 'Video unavailable.',
'video_recording.submission.duration': 'Duration',
'video_recording.submission.file_size': 'File size',
'video_recording.submission.recorded_at': 'Recorded',
'video_recording.submission.uploaded_at': 'Uploaded',
'video_recording.submission.retake_count': 'Retakes',
'link.edit.instructions_placeholder': 'Optional instructions for learners',
'link.edit.helper': 'Learners can submit one or more valid URLs (http/https).',
'link.take.helper': 'Add one or more links. Only valid http/https URLs are accepted.',
@@ -5,6 +5,7 @@
interface Props {
question: Record;
answer?: unknown;
viewAnswer?: unknown;
/** When set, renders an additional Review block with this incorrect `AnswerData`. */
wrongAnswer?: unknown;
}
@@ -17,7 +18,7 @@
settings?: Record<string, unknown>;
};
let { question, answer = null, wrongAnswer = undefined }: Props = $props();
let { question, answer = null, viewAnswer: initialViewAnswer = answer, wrongAnswer = undefined }: Props = $props();
function cloneQuestion(source: Record) {
return JSON.parse(JSON.stringify(source));
@@ -109,6 +110,21 @@
fileName: `lesson-plan-${index + 1}.pdf`,
mimeType: 'application/pdf'
};
case 'VIDEO_RECORDING':
return {
type: 'VIDEO_RECORDING',
assetId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
storageKey: `exercise-recordings/story/submission-${index + 1}.webm`,
fileName: `reflection-${index + 1}.webm`,
mimeType: 'video/webm',
size: 900_000 + index * 25_000,
durationSeconds: 30 + (index % 6) * 5,
recordedAt: '2026-04-30T12:00:00.000Z',
uploadedAt: '2026-04-30T12:01:00.000Z',
provider: 'cloudflare',
retakeCount: index % 4,
playbackUrl: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm'
};
case 'MATCHING': {
const pairs = Array.isArray(storyQuestion.settings?.pairs)
? (storyQuestion.settings?.pairs as Array<{ left: string; right: string }>)
@@ -168,7 +184,7 @@
$effect(() => {
editQuestion = cloneQuestion(question);
viewAnswer = answer;
viewAnswer = initialViewAnswer;
});
</script>
@@ -224,7 +240,7 @@
contract={{
mode: 'review',
question,
answer: viewAnswer,
answer,
labels: QUESTION_LABELS,
disabled: true
}}
@@ -0,0 +1,45 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { ModelPicker } from '@cio/ui/custom/model-picker';
const { Story } = defineMeta({
title: 'Molecules/ModelPicker',
component: ModelPicker,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<script>
import { DEFAULT_PICKER_MODEL_ID, type AgentModelId } from '@cio/utils/agent-models';
let selected = $state<AgentModelId>(DEFAULT_PICKER_MODEL_ID);
</script>
<Story name="Default">
{#snippet template()}
<div class="p-8">
<ModelPicker value={selected} onChange={(id) => (selected = id)} />
<p class="mt-4 text-sm text-gray-500">Selected: {selected}</p>
</div>
{/snippet}
</Story>
<Story name="Select Variant">
{#snippet template()}
<div class="p-8">
<ModelPicker value={selected} variant="select" onChange={(id) => (selected = id)} />
<p class="mt-4 text-sm text-gray-500">Selected: {selected}</p>
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<div class="p-8">
<ModelPicker value={DEFAULT_PICKER_MODEL_ID} disabled onChange={() => {}} />
</div>
{/snippet}
</Story>
@@ -1,4 +1,4 @@
<script module>
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import {
NewsfeedReactions,
@@ -0,0 +1,187 @@
import type {
PublicCourseCalloutData,
PublicCourseOrgData,
PublicCourseSidebarSection,
PublicExerciseViewData,
PublicLessonViewData
} from '@cio/ui/custom/public-course';
import type { ExerciseQuestionModel } from '@cio/question-types';
export const CALLOUT_FIXTURE: PublicCourseCalloutData = {
title: 'Want to go deeper on this?',
description: 'Join the full "AI for Builders" cohort where we ship a production-grade RAG pipeline in 4 weeks.',
buttonLabel: 'Read more on my site',
buttonUrl: 'https://example.com/ai-for-builders'
};
export const ORG_FIXTURE: PublicCourseOrgData = {
id: 'org-1',
name: 'ClassroomIO',
siteName: 'classroomio',
avatarUrl: 'https://avatars.githubusercontent.com/u/119528464?s=200&v=4'
};
export const COURSE_TITLE_FIXTURE = 'AI for Builders — Crash Course';
export const SIDEBAR_FIXTURE: PublicCourseSidebarSection[] = [
{
id: 'section-intro',
title: 'Getting started',
items: [
{
kind: 'lesson',
id: 'lesson-welcome',
slug: 'welcome',
title: 'Welcome to the course',
isUnlocked: true
},
{
kind: 'lesson',
id: 'lesson-setup',
slug: 'setting-up-your-environment',
title: 'Setting up your environment',
isUnlocked: true
}
]
},
{
id: 'section-foundations',
title: 'Foundations',
items: [
{
kind: 'lesson',
id: 'lesson-intro-llms',
slug: 'intro-to-large-language-models',
title: 'Intro to large language models',
isUnlocked: true
},
{
kind: 'lesson',
id: 'lesson-hallucinations',
slug: 'hallucination-and-limitations',
title: 'Hallucination & limitations',
isUnlocked: true
},
{
kind: 'exercise',
id: 'exercise-foundations-quiz',
slug: 'foundations-quiz',
title: 'Foundations quiz',
isUnlocked: true
}
]
},
{
id: 'section-advanced',
title: 'Advanced',
items: [
{
kind: 'lesson',
id: 'lesson-rag',
slug: 'retrieval-augmented-generation',
title: 'Retrieval-augmented generation',
isUnlocked: false
},
{
kind: 'lesson',
id: 'lesson-agents',
slug: 'building-agents',
title: 'Building agents',
isUnlocked: false
}
]
}
];
export const LESSON_FIXTURE: PublicLessonViewData = {
kind: 'lesson',
title: 'Hallucination & limitations',
sectionTitle: 'Foundations',
body: `
<p>Large language models are incredibly capable, but they also confidently produce incorrect answers — what the field calls <em>hallucination</em>. Before we design systems around them, it helps to build an intuition for where they fail.</p>
<h2>When LLMs hallucinate</h2>
<ul>
<li>When asked about things outside of their training distribution.</li>
<li>When prompted with ambiguous or leading questions.</li>
<li>When asked to cite sources without retrieval grounding.</li>
</ul>
<p>In the next lessons we'll cover concrete mitigations, including retrieval, tool use, and structured outputs.</p>
`,
video: {
type: 'youtube',
link: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
},
isUnlocked: true
};
export const LESSON_LOCKED_FIXTURE: PublicLessonViewData = {
...LESSON_FIXTURE,
title: 'Building agents',
isUnlocked: false,
video: null,
body: ''
};
const EXERCISE_QUESTION_FIXTURES: ExerciseQuestionModel[] = [
{
id: 'q-radio-1',
title: 'Which of these is the clearest sign of hallucination?',
questionType: 'RADIO',
points: 1,
options: [
{ id: 'a', label: 'The model refuses to answer a question it doesn’t know.', isCorrect: false },
{ id: 'b', label: 'The model confidently invents a fact that doesn’t exist.', isCorrect: true },
{ id: 'c', label: 'The model cites a source verbatim.', isCorrect: false }
]
},
{
id: 'q-checkbox-1',
title: 'Pick every mitigation that grounds an LLM in real data.',
questionType: 'CHECKBOX',
points: 2,
options: [
{ id: 'a', label: 'Retrieval-augmented generation', isCorrect: true },
{ id: 'b', label: 'Turning up temperature', isCorrect: false },
{ id: 'c', label: 'Calling a tool / function', isCorrect: true },
{ id: 'd', label: 'Asking the model to be more confident', isCorrect: false }
]
},
{
id: 'q-tf-1',
title: 'LLMs always know when they’re wrong.',
questionType: 'TRUE_FALSE',
points: 1,
options: [
{ id: 'true-option', label: 'True', value: 'true', isCorrect: false },
{ id: 'false-option', label: 'False', value: 'false', isCorrect: true }
]
},
{
id: 'q-short-1',
title: 'What’s the shorthand term for "retrieval-augmented generation"?',
questionType: 'SHORT_ANSWER',
points: 1,
options: [
{ id: 'rag-1', label: 'rag', isCorrect: true },
{ id: 'rag-2', label: 'RAG', isCorrect: true }
],
settings: { caseSensitive: false }
},
{
id: 'q-numeric-1',
title: 'How many mitigations did we cover in this lesson? (A number is fine.)',
questionType: 'NUMERIC',
points: 1,
options: [{ id: 'three', label: '3', value: '3', isCorrect: true }],
settings: { tolerance: 0 }
}
];
export const EXERCISE_FIXTURE: PublicExerciseViewData = {
kind: 'exercise',
title: 'Foundations quiz',
description: 'A short check-in on the foundations we just covered. Graded instantly in your browser.',
sectionTitle: 'Foundations',
isUnlocked: true,
questions: EXERCISE_QUESTION_FIXTURES
};
@@ -0,0 +1,121 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { PublicCourse } from '@cio/ui';
import type { PublicCourseSidebarItem } from '@cio/ui/custom/public-course';
import { QUESTION_LABELS } from '../exercise-question/question-labels';
import {
CALLOUT_FIXTURE,
COURSE_TITLE_FIXTURE,
EXERCISE_FIXTURE,
LESSON_FIXTURE,
LESSON_LOCKED_FIXTURE,
ORG_FIXTURE,
SIDEBAR_FIXTURE
} from './fixtures';
const { Story } = defineMeta({
title: 'Molecules/PublicCourse',
component: PublicCourse.PublicCourseShell,
parameters: {
layout: 'fullscreen'
},
tags: ['autodocs']
});
const flatItems = SIDEBAR_FIXTURE.flatMap((section) => section.items);
</script>
<Story name="Lesson · desktop + mobile frames">
{#snippet template()}
{@const activeSlug = 'hallucination-and-limitations'}
{@const activeIndex = flatItems.findIndex((item) => item.slug === activeSlug)}
{@const activeItem = flatItems[activeIndex] ?? null}
<PublicCourse.PublicCourseShell
sections={SIDEBAR_FIXTURE}
courseTitle={COURSE_TITLE_FIXTURE}
org={ORG_FIXTURE}
{activeSlug}
{activeItem}
activeFlatIndex={activeIndex}
totalItems={flatItems.length}
hasPrev={activeIndex > 0}
hasNext={activeIndex >= 0 && activeIndex < flatItems.length - 1}
onItemClick={(item: PublicCourseSidebarItem) => console.log('navigate', item.slug)}
onPrev={() => console.log('prev')}
onNext={() => console.log('next')}
>
<PublicCourse.PublicLessonView lesson={LESSON_FIXTURE} callout={CALLOUT_FIXTURE} />
</PublicCourse.PublicCourseShell>
{/snippet}
</Story>
<Story name="Lesson · locked fallback (callout replaces body)">
{#snippet template()}
<PublicCourse.PublicCourseShell
sections={SIDEBAR_FIXTURE}
courseTitle={COURSE_TITLE_FIXTURE}
org={ORG_FIXTURE}
activeSlug="building-agents"
activeItem={{
kind: 'lesson',
id: 'lesson-agents',
slug: 'building-agents',
title: 'Building agents',
isUnlocked: false
}}
activeFlatIndex={flatItems.length - 1}
totalItems={flatItems.length}
hasPrev
hasNext={false}
>
<PublicCourse.PublicLessonView lesson={LESSON_LOCKED_FIXTURE} callout={CALLOUT_FIXTURE} />
</PublicCourse.PublicCourseShell>
{/snippet}
</Story>
<Story name="Exercise · client-graded quiz">
{#snippet template()}
{@const activeSlug = 'foundations-quiz'}
{@const activeIndex = flatItems.findIndex((item) => item.slug === activeSlug)}
<PublicCourse.PublicCourseShell
sections={SIDEBAR_FIXTURE}
courseTitle={COURSE_TITLE_FIXTURE}
org={ORG_FIXTURE}
{activeSlug}
activeItem={flatItems[activeIndex] ?? null}
activeFlatIndex={activeIndex}
totalItems={flatItems.length}
hasPrev={activeIndex > 0}
hasNext={activeIndex < flatItems.length - 1}
>
<PublicCourse.PublicExerciseView exercise={EXERCISE_FIXTURE} callout={CALLOUT_FIXTURE} labels={QUESTION_LABELS} />
</PublicCourse.PublicCourseShell>
{/snippet}
</Story>
<Story name="Sidebar · states only">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-xs ui:border-r ui:border-border">
<PublicCourse.PublicCourseSidebar sections={SIDEBAR_FIXTURE} activeSlug="hallucination-and-limitations" />
</div>
{/snippet}
</Story>
<Story name="Callout · inline + full variants">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-2xl ui:space-y-8 ui:p-8">
<div>
<h3 class="ui:mb-2 ui:text-sm ui:font-semibold ui:text-muted-foreground">Inline (at bottom of lessons)</h3>
<PublicCourse.PublicCourseCallout callout={CALLOUT_FIXTURE} variant="inline" />
</div>
<div>
<h3 class="ui:mb-2 ui:text-sm ui:font-semibold ui:text-muted-foreground">Full (locked item replacement)</h3>
<PublicCourse.PublicCourseCallout callout={CALLOUT_FIXTURE} variant="full" />
</div>
<div>
<h3 class="ui:mb-2 ui:text-sm ui:font-semibold ui:text-muted-foreground">Full (no callout configured)</h3>
<PublicCourse.PublicCourseCallout callout={null} variant="full" />
</div>
</div>
{/snippet}
</Story>