Files
classroomio/packages/ui
Rotimi BestandRotimi Best 8ad0141ab8 fix(ui): align LandingButton sizing and hero/pricing CTA consistency (#833)
* fix(ui): restore theme pill radius on course landing CTAs

LandingButton hardcoded rounded-md, so hero Enroll and pricing CTAs
lost the per-theme pill radius after CoursePrimaryAction switched to
LandingButton. Use --landing-radius-pill and cn() so theme overrides
still win.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): preserve landing theme button radii

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): restore hero CTA Button sizing with disabled parity

Revert CoursePrimaryAction to the shared Button so hero enroll matches
View curriculum sizing (h-10 text-sm). When enrollment is closed, apply
landing-button CSS vars with forced opacity so theme color overrides do
not keep the hero CTA looking active while pricing stays muted.

Co-authored-by: Rotimi Best <[email protected]>

* fix(ui): align LandingButton sizing and use for hero CTA

Match LandingButton sm/md/lg heights to the shared Button scale (h-8/h-9/h-10,
text-sm) so hero enroll can use landing-theme tokens without oversized CTAs.

CoursePrimaryAction returns to LandingButton for enabled color parity with
pricing; disabled overrides are preserved. Terminal hero drops hardcoded hex
colors in favor of --landing-button-* tokens.

Co-authored-by: Rotimi Best <[email protected]>

---------
2026-08-05 17:06:47 +01:00
..

@cio/ui

UI component library for ClassroomIO, built with Svelte 5 and Tailwind CSS.

Package Information

  • Name: @cio/ui
  • Type: Svelte component library
  • Framework: Svelte 5
  • Styling: Tailwind CSS with custom ui prefix

Folder Structure

packages/ui/
├── src/
│   ├── base/          # Components from shadcn-svelte
│   ├── custom/        # Components from other sources or built on top of base components
│   ├── hooks/         # Reusable Svelte hooks
│   ├── tools/         # Utility functions (cn, sanitize, etc.)
│   ├── index.css      # Global styles
│   └── index.ts       # Main export file
├── package.json
├── components.json    # shadcn-svelte configuration
└── jsrepo.json        # jsrepo configuration

Storybook

We maintain a Storybook instance at packages/storybook to make it easy to browse, visualize, and reuse every UI component in the app. Instead of digging through source code or guessing how a component looks, you can see each piece rendered in isolation with its variants, props, and states documented.

This is especially useful when you're building a new feature and need to find the right existing component — check Storybook first before creating something from scratch.

Storybook Preview

To run it locally:

cd packages/storybook && pnpm dev

See packages/storybook/README.md for detailed instructions on creating stories and publishing.

Content Structure

Base Components (src/base/)

All components in the base directory are primarily from shadcn-svelte. These are the foundational UI components that follow the shadcn design system.

Guidelines:

  • Any new component from shadcn-svelte should be added to the base directory
  • Components are organized in their own folders with an index.ts file for exports
  • Each component folder typically contains the main component file(s) and an index.ts export file

Examples:

  • base/button/ - Button component from shadcn-svelte
  • base/dialog/ - Dialog component from shadcn-svelte
  • base/tooltip/ - Tooltip component from shadcn-svelte

Custom Components (src/custom/)

Components in the custom directory come from various sources or are built on top of base components:

  1. External Sources:

  2. Extended Components:

    • Any component that builds on top of shadcn-svelte base components
    • Custom implementations that extend base component functionality

Examples:

  • custom/code/ - From shadcn-svelte-extras (syntax-highlighted code block with copy and overflow)
  • custom/underline-tabs/ - From shadcn-svelte-extras
  • custom/editor/ - Rich text editor based on Edra/Tiptap
  • custom/image-cropper/ - Custom image cropping component
  • custom/checkbox-field/ - Field component built on top of base Checkbox
  • custom/newsfeed-reactions/ - Newsfeed reaction picker and summary used by course and program feeds
  • custom/course-creator/ - ChatGPT-style course creation input with level and type selects

Exercise question (src/custom/exercise-question/)

Learner and author UIs for exercise questions (take, preview, review, submission, edit per question type).

  • renderers/shared/ — Cross-type presentation pieces reused by multiple question families (for example labeled-value-row.svelte, mcq-preview-option-row.svelte, submission-response-pie-chart.svelte). Mode entry files stay thin; add new shared pieces here when two or more types need the same markup.
  • renderers/<question-type>/ — One folder per type (radio, numeric, …) containing take.svelte, preview.svelte, and optional review.svelte, submission.svelte, edit.svelte, plus type-specific fragments (for example numeric-expected-fields.svelte, ordering-display.svelte).
  • renderers/option-image.svelte, renderers/submission-utils.ts — Existing shared helpers at the renderers/ root; keep importing from there unless consolidating in a later pass.

Types with heavier or distinct UIs (textarea editor, file upload, matching/hotspot authoring, link lists) intentionally keep separate take / preview implementations until a second consumer (for example a dedicated review mode) justifies extracting more shared fragments.

Question type picker (src/custom/question-type-picker/)

Marketing / demo widget: left-hand list of question types and a live take-mode preview using ExerciseQuestion.QuestionRenderer. Copy is English-only (no dashboard i18n). Also consumed by the @cio/embeds app as a CDN bundle (apps/embeds).

Attachment list (src/custom/attachment-list/)

Presentational list for lesson (or similar) file attachments with view and edit modes. View mode shows a header (paperclip + title + file count) and rows with view/download icon buttons. Edit mode shows sortable rows (when onReorder is provided) with a drag handle, view, and delete actions. Copy is passed via the labels: AttachmentListLabels prop (including reorder for the drag handle) so dashboard wrappers can supply translated strings. AttachmentListFile.type accepts a file extension or MIME type for icon styling. See Molecules/AttachmentList in Storybook.

Live session card (src/custom/live-session-card/)

Presentational card for a live-class lesson with three states (live, upcoming, ended) derived from lessonAt + durationMinutes, or forced via the status prop (used by Storybook). Shows a join/copy action set when live, an "Add to calendar" combo button (Google, Outlook.com, Office 365, Yahoo, plus an .ics download for Apple) and a countdown when upcoming. All copy is passed in via the labels: LiveSessionLabels prop, so the dashboard wrapper supplies translated strings; onCopyLink fires after the link is copied (e.g. for a snackbar). See Molecules/LiveSessionCard in Storybook.

Hooks (src/hooks/)

Reusable Svelte hooks are located in the src/hooks/ directory. These are Svelte 5 runes-based utilities that can be used across components.

Available Hooks:

  • is-mobile.svelte.ts - Media query hook for mobile breakpoints
  • use-clipboard.svelte.ts - Clipboard copy functionality with state management

Usage:

<script lang="ts">
  import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
  import { UseClipboard } from '@cio/ui/hooks/use-clipboard.svelte';
</script>

Tools (src/tools/)

Utility functions and helpers are located in src/tools/. The main utility is the cn function for class name merging.

Page layout (src/base/page/)

Composable page shell used across dashboard list and settings screens. Import as import * as Page from '@cio/ui/base/page'.

Export Purpose
Page.Root Flex column wrapper with minimum viewport height
Page.Header Title row; pass isSticky to pin the header while scrolling
Page.HeaderContent Title + subtitle column
Page.Title / Page.Subtitle Page heading and description
Page.Action Right-aligned header actions
Page.Body Main content area (child snippet)
Page.BodyHeader Toolbar inside the body
Page.SettingsActions Save/discard bar for settings forms (floats, then docks)

Settings pages: Place Page.SettingsActions as the last child inside Page.Root, after Page.Body. The bar uses position: sticky; bottom: 0 so it stays pinned to the viewport bottom while you scroll, then settles into normal flow at the end of the page. Do not put overflow on Page.Root that would break sticky positioning (horizontal overflow on Page.Body is fine). Disable Save and Discard via hasChanges={false} when the form is clean. Pass translated statusLabel, discardLabel, and saveLabel props from the dashboard. Use disabled to block Save only (Discard still follows hasChanges). Use contentClass for extra classes on the inner card when needed.

Page.Root can come from a layout. "Last child of Page.Root" is a runtime relationship, not a same-file one. Where a +layout.svelte owns the Page.Root and renders pages into it via {@render children?.()}, the page file itself contains only Page.Header, Page.Body, and Page.SettingsActions — that is correct and already satisfies the rule. Do not add a second Page.Root in the page: nesting them stacks two min-h-[calc(100vh-48px)] containers, drops the layout's width constraints, and shortens the sticky bar's containing block so it stops floating. Pages that rely on a layout-owned Page.Root:

  • routes/(app)/org/[slug]/settings/+page.svelte and settings/org/+page.svelte, settings/customize-lms/+page.svelte, settings/notifications/+page.svelte → routes/(app)/org/[slug]/settings/+layout.svelte
  • routes/(app)/lms/settings/+page.svelte, routes/(app)/lms/settings/notifications/+page.svelte → routes/(app)/lms/settings/+layout.svelte

Never render Page.SettingsActions from inside Page.Body. That includes rendering it from a feature component that a route passes into Page.Body's child snippet. Page.Body sets overflow-x-hidden, and CSS computes overflow-y to auto whenever the other axis is not visible — so Page.Body is a scroll container. It is sized by its content and never scrolls internally, so a sticky bar inside it has no travel and renders flat at the end of the content. Keep the bar a sibling of Page.Body and lift the dirty/saving state up to the route with bind:this and bind:hasUnsavedChanges, the way routes/(app)/courses/[id]/settings/+page.svelte does.

The bar also sits at z-50, matching the editor root so it paints above editor content (later in DOM wins the tie) while staying below body-portaled dialogs and sheets. Its shadow appears only while it is pinned away from its flow position, tracked by an IntersectionObserver on a 1px sentinel rendered as its next sibling.

Prop Description
hasChanges Enables Save and Discard when the form is dirty
loading Shows loading state on Save
disabled Disables Save only (e.g. while not initialized)
contentClass Extra classes on the inner card (width, padding, etc.)
statusLabel Left-side status text (e.g. "Unsaved changes")
discardLabel Discard button label
saveLabel Save button label
onSave Save handler
onDiscard Discard handler
<Page.Root>
  <Page.Header>...</Page.Header>
  <Page.Body>
    {#snippet child()}
      <!-- form sections -->
    {/snippet}
  </Page.Body>
  <Page.SettingsActions
    hasChanges={hasUnsavedChanges}
    loading={isSaving}
    statusLabel={$t('common.unsaved_changes.label')}
    discardLabel={$t('common.discard')}
    saveLabel={$t('common.save_changes')}
    onSave={handleSave}
    onDiscard={handleDiscard}
  />
</Page.Root>

See Molecules/Page → Settings Actions in Storybook.

Importing cn

The cn utility function is used throughout the codebase for merging Tailwind CSS classes. It combines clsx and tailwind-merge to handle class conflicts intelligently.

Import:

import { cn } from '../../tools';

Example usage:

<script lang="ts">
  import { cn } from '../../tools';

  let { class: className } = $props();
</script>

<div class={cn('ui:bg-primary ui:text-white', className)}>Content</div>

The cn function is exported from src/tools/index.ts and is also available as a named export from the main package:

import { cn } from '@cio/ui';

Tailwind Prefix

All Tailwind CSS classes in this package use a custom prefix: ui

This ensures that styles don't conflict with other Tailwind configurations in consuming applications.

Usage:

<div class="ui:bg-primary ui:text-primary-foreground ui:rounded-md">Content</div>

Note: When adding new components or styles, always use the ui: prefix for all Tailwind classes.

Component Exports

Components are exported from the main src/index.ts file in an alphabetical order. There are two main export patterns:

Base Components

Base components are exported as namespaced exports:

export * as Button from './base/button';
export * as Dialog from './base/dialog';
export * as Tooltip from './base/tooltip';

Usage:

<script lang="ts">
  import { Button, Dialog, Tooltip } from '@cio/ui';
</script>

<Button.Root>Click me</Button.Root>
<Dialog.Root>...</Dialog.Root>

Custom Components

Custom components may use namespaced exports or direct exports depending on their structure:

// Namespaced
export * as UnderlineTabs from './custom/underline-tabs';

// Direct exports
export { Chip } from './custom/chip';
export { IconButton } from './custom/icon-button';

Usage:

<script lang="ts">
  import { UnderlineTabs, Chip, IconButton } from '@cio/ui';
</script>

Component Index Files

Each component directory contains an index.ts file that exports the component and its types:

// base/button/index.ts
import Root, { type ButtonProps, buttonVariants } from './button.svelte';

export { Root, Root as Button, type ButtonProps, buttonVariants };

Adding New Components

When adding a new component to this package, follow these steps:

  1. Determine the location:

    • If it's from shadcn-svelte → add to src/base/
    • If it's from another source or extends base components → add to src/custom/
  2. Create the component structure:

    • Create a folder with the component name (kebab-case)
    • Add the component file(s)
    • Create an index.ts file for exports
  3. Export from main index:

    • Add the export to src/index.ts
  4. Add Tailwind prefix:

    • Run pnpm prefix to automatically add the ui: prefix to all Tailwind classes
  5. Create a Storybook story:

    • Required: After adding a new component, you must add a story for it in packages/storybook
    • See packages/storybook/README.md for detailed instructions on how to create stories
    • Stories help document component usage and enable visual testing

Development

Scripts

  • pnpm generate-css - Generate CSS from Tailwind
  • pnpm build - Build the package
  • pnpm dev - Watch mode for CSS generation
  • pnpm format - Format code with Prettier
  • pnpm prefix:list - List files that need the ui: prefix (dry-run, exit 0)
  • pnpm prefix:check - Fail if any file under src/ needs the ui: prefix
  • pnpm prefix:check:staged - Fail if staged packages/ui files need the prefix (pre-commit)
  • pnpm prefix:check:changed - Fail if changed packages/ui files need the prefix (CI; set UI_PREFIX_GIT_BASE)
  • pnpm prefix - Add ui: prefix to all Tailwind classes and format

Configuration

  • components.json - shadcn-svelte configuration
  • jsrepo.json - jsrepo configuration for component installation
  • svelte.config.js - Svelte configuration
  • tsconfig.json - TypeScript configuration