* feat(ui): add Page.SettingsActions for settings save bar Introduce a sticky bottom save/discard bar on Page and migrate settings pages to use it instead of header or inline save buttons. Includes profile, org, customize-lms, notifications, auth, AI tutor, course settings, certificates, and cohort settings. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): always show Page.SettingsActions, disable when clean Keep the settings save bar visible at all times and disable Save and Discard when hasChanges is false instead of hiding the bar entirely. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): add bottom spacing and shadow to Page.SettingsActions Wrap the actions card in a sticky container with bottom padding so it does not sit flush against the viewport edge, and use shadow-md on the card for clearer elevation. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): stop viewport-sticking Page.SettingsActions on scroll Remove sticky bottom positioning so the save bar stays at the end of the page content and scrolls away when viewing upper sections. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): use sticky bottom for Page.SettingsActions dock behavior Replace the IntersectionObserver fixed/dock toggle that caused layout thrashing and flipping with native position: sticky; bottom: 0. Move overflow-x-hidden from Page.Root to Page.Body so sticky is not broken. Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(ui): keep Page.SettingsActions sticky and above editor content Page.Root carried md:overflow-x-auto, which makes overflow-y compute to auto, turning the root into the sticky bar's nearest scrolling ancestor. Since the root sizes to its content it never scrolls internally, so the bar rendered at its natural flow position instead of pinning to the viewport. Drop the overflow so sticky resolves against the viewport; horizontal containment stays on Page.Body. Raise the bar to z-50 so it is not painted over by the editor root, which is relative z-50. Matching rather than exceeding 50 keeps body-portaled dialogs and sheets above the bar. Show the shadow only while the bar is pinned away from its flow position, tracked with an IntersectionObserver on a 1px sentinel sibling. Unlike the previous observer this toggles a box-shadow only, so it cannot feed back into layout. * fix(ui): tighten settings actions spacing and tab visibility - Remove mt-6 and lower z-index so popovers sit above the bar - Wrap sentinel in a single root to avoid extra Page.Root flex gap - Remove Page.Body mb-4 redundant with settings actions spacing - Show certificate save bar only on the settings tab - Raise theme color picker z-index via --picker-z-index Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(dashboard): address PR review feedback for settings actions - Capture profile/org saved baselines only on initial load, not on edits - Reset course settings errors with explicit field shape in handleDiscard - Restore VisitOrgSiteButton on org profile settings header - Derive AI tutor dirty state from stores directly instead of mirrored state - Use discard-specific French label and document SettingsActions props Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * docs(ui): clarify Page.Root ownership for Page.SettingsActions "Last child of Page.Root" is a runtime relationship. Several settings pages get their Page.Root from a +layout.svelte, so the page file holds only Page.Header, Page.Body, and Page.SettingsActions. Document that this is correct and list the pages, since adding a nested Page.Root stacks two min-height containers and shortens the sticky bar's containing block. Also document that Page.SettingsActions must never be rendered from inside Page.Body: overflow-x-hidden makes Page.Body a scroll container that never scrolls, so a sticky bar in it has no travel. ---------
@cio/storybook
Storybook for ClassroomIO UI components.
Development
To start the Storybook development server:
pnpm dev
Build
To build the Storybook:
pnpm build
Build Storybook
To build the Storybook (alternative command):
pnpm build-storybook
Upload
To upload the built Storybook to Cloudflare R2:
pnpm upload:storybook
Note: This requires the build to be completed first. Make sure you have the following environment variables set:
CLOUDFLARE_ACCESS_KEYCLOUDFLARE_SECRET_ACCESS_KEYCLOUDFLARE_ACCOUNT_ID
Publish
To build and upload the Storybook in one command:
pnpm storybook:publish
This will build the Storybook and then upload it to R2.
How to Create Storybook Stories
This guide documents the patterns and conventions for creating Storybook stories in the ClassroomIO UI component library.
Table of Contents
- File Structure
- Basic Story Structure
- Import Patterns
- Story Definition
- Template Snippets
- Fields File
- Component Categorization
- CSS Class Conventions
- LMS Content Guidelines
- Examples
File Structure
Directory Organization
Stories are organized into two main categories:
- Atoms (
src/atoms/): Basic, indivisible UI components (Button, Input, Badge, etc.) - Molecules (
src/molecules/): Composite components made of multiple atoms (Card, Field, ButtonGroup, etc.)
File Naming
- Create a directory matching the component name (kebab-case)
- Create a story file:
{component-name}.stories.svelte - Optionally create a fields file:
fields.ts(for controls configuration)
Example:
src/molecules/hover-card/
├── hover-card.stories.svelte
└── fields.ts
Basic Story Structure
Every story file follows this structure:
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
// Component imports
import * as ComponentName from '@cio/ui/base/component-name';
// Other imports (icons, utilities, etc.)
import IconName from '@lucide/svelte/icons/icon-name';
// Fields import (if using controls)
import { FIELDS } from './fields';
// Story definition
const { Story } = defineMeta({
title: 'Category/ComponentName',
component: ComponentName.Root, // or ComponentName for single components
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
// State variables (if needed)
let someState = $state('');
</script>
<Story name="StoryName">
{#snippet template()}
<!-- Component usage -->
{/snippet}
</Story>
Import Patterns
Component Imports
-
Namespace imports for multi-part components:
import * as HoverCard from '@cio/ui/base/hover-card'; import * as Card from '@cio/ui/base/card'; import * as Field from '@cio/ui/base/field'; -
Named imports for single components:
import {Button} from '@cio/ui/base/button'; import {Input} from '@cio/ui/base/input'; import {Avatar} from '@cio/ui/base/avatar';
Icon Imports
Use Lucide Svelte icons:
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days'; import UsersIcon from '@lucide/svelte/icons/users';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
Story Definition
Title Format
- Atoms:
'Atom/ComponentName'(e.g.,'Atom/Button') - Molecules:
'Molecules/ComponentName'(e.g.,'Molecules/HoverCard')
Component Reference
-
For multi-part components, reference the
Rootcomponent:component: HoverCard.Root component: Card.Root -
For single components, reference directly:
component: Button component: Input
Parameters
Standard parameters configuration:
parameters: {
layout: 'centered',
controls: {
include: FIELDS // Only if using fields.ts
}
}
Tags
Always include tags: ['autodocs'] for automatic documentation generation.
Template Snippets
Basic Template
<Story name="Default">
{#snippet template()}
<ComponentName.Root>
<!-- Component content -->
</ComponentName.Root>
{/snippet}
</Story>
Template with Args (for controls)
For atoms that need to pass args to controls:
<Story name="Default">
{#snippet template(args)}
<Button {...args}>Button Text</Button>
{/snippet}
</Story>
Multiple Stories
Create multiple stories to showcase different use cases:
<Story name="Default">
{#snippet template()}
<!-- Basic usage -->
{/snippet}
</Story>
<Story name="With Avatar">
{#snippet template()}
<!-- Avatar variant -->
{/snippet}
</Story>
<Story name="Custom Content">
{#snippet template()}
<!-- Custom content variant -->
{/snippet}
</Story>
Fields File
Create a fields.ts file to control which props appear in Storybook controls:
export const FIELDS = ['class'] as string[];
Common fields:
'class'- CSS classes'variant'- Component variants'size'- Component sizes'disabled'- Disabled state- Add more as needed based on component props
Component Categorization
Atoms
Basic, indivisible components:
- Button, Input, Badge, Avatar, Label, Checkbox, Switch, etc.
- Single-purpose components
- Usually don't compose other components
Molecules
Composite components:
- Card, Field, ButtonGroup, HoverCard, Empty, etc.
- Made of multiple atoms or other components
- More complex functionality
Guideline: If a component uses multiple other components together, it's likely a molecule.
CSS Class Conventions
UI Prefix
Use the ui: prefix for utility classes to scope them properly:
<!-- ✅ Correct -->
<div class="ui:flex ui:flex-col ui:gap-6">
<Button class="ui:w-full">
<!-- ❌ Incorrect -->
<div class="flex flex-col gap-6">
<Button class="w-full">
When to Use UI Prefix
- Always use for utility classes (flex, grid, gap, padding, margin, etc.)
- Don't use for component-specific classes or data attributes
- Don't use for Tailwind classes that are part of component styling
Examples
<!-- Utility classes - use ui: prefix -->
<div class="ui:flex ui:items-center ui:gap-2">
<div class="ui:space-y-2">
<div class="ui:text-muted-foreground ui:text-sm">
<!-- Component classes - no prefix needed -->
<Card.Root class="w-full max-w-sm">
<HoverCard.Content class="w-80">
LMS Content Guidelines
Since ClassroomIO is an LMS platform, all story content should be LMS-related:
✅ Good Examples
- Courses: "Introduction to Web Development", "Advanced React Patterns"
- Users: "Sarah Johnson (Instructor)", "Alex Martinez (Student)"
- Content: Course descriptions, assignment types, enrollment information
- Actions: "Create Course", "Enroll Student", "Submit Assignment"
- States: "No courses yet", "Student enrolled", "Assignment submitted"
❌ Avoid
- Generic examples (Svelte, GitHub profiles, etc.)
- Non-educational content
- Examples that don't relate to learning/teaching
Content Patterns
Instructor Profiles:
<h4 class="text-sm font-semibold">Sarah Johnson</h4>
<p class="text-sm">Senior Full-Stack Developer & Instructor</p>
<span class="text-muted-foreground text-xs">Teaching since January 2020</span>
Course Information:
<h4 class="text-sm font-semibold">Advanced React Patterns</h4>
<p class="text-muted-foreground text-sm">
Master advanced React concepts including hooks, context, performance optimization...
</p>
Student Information:
<h4 class="text-sm font-semibold">Alex Martinez</h4>
<p class="text-muted-foreground text-xs">5 courses enrolled</p>
<p class="text-muted-foreground text-xs">Active since March 2024</p>
Examples
Example 1: Simple Atom (Button)
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Button } from '@cio/ui/base/button';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Button',
component: Button,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template(args)}
<Button {...args}>Create Course</Button>
{/snippet}
</Story>
Example 2: Multi-Part Molecule (HoverCard)
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
import * as HoverCard from '@cio/ui/base/hover-card';
import * as Avatar from '@cio/ui/base/avatar';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/HoverCard',
component: HoverCard.Root,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Instructor Profile">
{#snippet template()}
<HoverCard.Root>
<HoverCard.Trigger
href="/instructors/sarah-johnson"
class="ui:rounded-sm ui:underline-offset-4 ui:hover:underline"
>
Sarah Johnson
</HoverCard.Trigger>
<HoverCard.Content class="w-80">
<div class="flex justify-between space-x-4">
<Avatar.Root>
<Avatar.Image src="https://github.com/shadcn.png" />
<Avatar.Fallback>SJ</Avatar.Fallback>
</Avatar.Root>
<div class="space-y-1">
<h4 class="text-sm font-semibold">Sarah Johnson</h4>
<p class="text-sm">Senior Full-Stack Developer & Instructor</p>
<div class="flex items-center pt-2">
<CalendarDaysIcon class="me-2 size-4 opacity-70" />
<span class="text-muted-foreground text-xs">Teaching since January 2020</span>
</div>
</div>
</div>
</HoverCard.Content>
</HoverCard.Root>
{/snippet}
</Story>
Example 3: Complex Molecule with State (Field)
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/Field',
component: Field.Field,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let email = $state('');
</script>
<Story name="Course Enrollment">
{#snippet template()}
<div class="w-lg">
<Field.Set>
<Field.Legend>Enroll Student</Field.Legend>
<Field.Description>Add a new student to your course</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label for="student-email">Student Email</Field.Label>
<Input id="student-email" type="email" bind:value={email} placeholder="student@example.com" />
</Field.Field>
</Field.Group>
</Field.Set>
</div>
{/snippet}
</Story>
Checklist
When creating a new story, ensure:
- File is in the correct directory (
atoms/ormolecules/) - File is named
{component-name}.stories.svelte - Imports use
@cio/ui/base/{component-name}pattern - Title follows
'Category/ComponentName'format - Component reference is correct (Root for multi-part, direct for single)
tags: ['autodocs']is included- All utility classes use
ui:prefix - Content is LMS-related (courses, students, instructors, etc.)
- Multiple stories showcase different use cases
fields.tsis created if controls are needed- State variables use
$state()for reactivity - Icons are imported from
@lucide/svelte/icons/
Additional Notes
- State Management: Use Svelte 5 runes (
$state(),$derived()) for reactive state - Layout: Most stories use
layout: 'centered'in parameters - Styling: Prefer Tailwind utility classes with
ui:prefix - Accessibility: Include proper ARIA labels and semantic HTML
- Documentation: Story names should be descriptive and reflect the use case