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