diff --git a/CLAUDE.md b/CLAUDE.md
index 8b71b1c1..884eb602 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,6 +15,7 @@ Datalayer Core - Python SDK and CLI for the Datalayer AI Platform. Hybrid Python
**Python**: `pip install -e .[test]` | `pytest datalayer_core/tests/` | `mypy datalayer_core/`
**TypeScript Library**: `npm install` | `npm run build:lib` | `npm run lint` | `npm run test`
**Examples**: `npm run example` (starts dev server at http://localhost:3000/)
+**Storybook**: `npm run storybook` (starts on http://localhost:6006/) | `npm run build-storybook`
**Code Quality**: `npm run check` | `npm run check:fix` | `npm run lint` | `npm run format` | `npm run type-check`
**Docs**: `cd docs && make build` | `npm run typedoc` (generates TypeScript API docs)
**Make**: `make build` | `make start` | `make docs`
@@ -64,6 +65,36 @@ Datalayer Core - Python SDK and CLI for the Datalayer AI Platform. Hybrid Python
- Ensure things always build after changes
- Run also npm run format/lint/type-check to ensure all is working properly
+## Storybook Component Testing
+
+**Component Story Structure:**
+- All UI components have `.stories.tsx` files colocated with their source code
+- Stories follow the pattern: `ComponentName.tsx` → `ComponentName.stories.tsx`
+- Each story file includes multiple variants showcasing different states
+- Interactive controls allow testing component props in real-time
+
+**Running Storybook:**
+```bash
+npm run storybook # Start dev server (http://localhost:6006)
+npm run build-storybook # Build static Storybook
+```
+
+**Component Coverage:**
+All 50+ UI components have comprehensive stories including:
+- Avatars, Banners, Buttons, Checkout, Confetti
+- Context, Display, ECharts, Flashes, IAM
+- Icons, Labels, Landings, NavBar, NBGrader
+- Notebooks, Primer, Progress, Runtimes, Screenshot
+- Snapshots, Snippets, Storage, Students, SubNav
+- Tables, TextReveal, Tokens, Toolbars, Users
+
+**Story Best Practices:**
+- Mock external dependencies (API calls, services)
+- Provide realistic test data
+- Include edge cases (loading, error, empty states)
+- Use TypeScript for type safety
+- Follow existing patterns in the codebase
+
## Running Examples
**Start the examples server:**
diff --git a/README.md b/README.md
index b681a990..0f478fdf 100644
--- a/README.md
+++ b/README.md
@@ -225,6 +225,35 @@ pip install -e .[test]
npm install
```
+### Storybook Component Development
+
+Datalayer Core includes comprehensive Storybook coverage for all UI components. Each component has its own `.stories.tsx` file located next to the component source code.
+
+```bash
+# Start Storybook development server
+npm run storybook # Runs on http://localhost:6006
+
+# Build static Storybook
+npm run build-storybook
+
+# Run Storybook on a different port
+npm run storybook -- --port 6007
+```
+
+**Component Story Structure:**
+- Stories are colocated with components (e.g., `Button.tsx` → `Button.stories.tsx`)
+- All UI components have comprehensive test coverage
+- Multiple story variants showcase different component states
+- Interactive controls for testing component props
+
+**Available Component Categories:**
+- Avatars, Banners, Buttons, Checkout, Confetti
+- Context, Display, ECharts, Flashes, IAM
+- Icons, Labels, Landings, NavBar, NBGrader
+- Notebooks, Primer, Progress, Runtimes, Screenshot
+- Snapshots, Snippets, Storage, Students, SubNav
+- Tables, TextReveal, Tokens, Toolbars, Users
+
### Code Quality
This project maintains high code quality standards with automated linting, formatting, and type checking:
diff --git a/examples/nextjs-notebook/src/app/environments/page.tsx b/examples/nextjs-notebook/src/app/environments/page.tsx
index 04d03a36..8c8a1920 100644
--- a/examples/nextjs-notebook/src/app/environments/page.tsx
+++ b/examples/nextjs-notebook/src/app/environments/page.tsx
@@ -36,7 +36,6 @@ export default function EnvironmentsPage() {
// Redirect to welcome page if no token
router.push('/welcome');
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, router]);
const fetchEnvironments = async () => {
@@ -142,7 +141,6 @@ export default function EnvironmentsPage() {
}}
>
{imageUrl ? (
- // eslint-disable-next-line
- {/* eslint-disable-next-line */}
+ {}
- {/* eslint-disable-next-line */}
+ {}
;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ user: {
+ id: '1',
+ username: 'johndoe',
+ email: 'john@example.com',
+ avatarUrl: 'https://github.com/octocat.png',
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ size: 100,
+ },
+};
+
+export const WithClick: Story = {
+ args: {
+ user: {
+ id: '1',
+ username: 'johndoe',
+ email: 'john@example.com',
+ avatarUrl: 'https://github.com/octocat.png',
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ size: 100,
+ onClick: () => alert('Avatar clicked!'),
+ },
+};
+
+export const SmallSize: Story = {
+ args: {
+ user: {
+ id: '1',
+ username: 'johndoe',
+ email: 'john@example.com',
+ avatarUrl: 'https://github.com/octocat.png',
+ firstName: 'John',
+ lastName: 'Doe',
+ },
+ size: 40,
+ },
+};
+
+export const Loading: Story = {
+ args: {
+ user: undefined,
+ size: 100,
+ },
+};
diff --git a/src/components/banners/NoAutomationBanner.stories.tsx b/src/components/banners/NoAutomationBanner.stories.tsx
new file mode 100644
index 00000000..086026c0
--- /dev/null
+++ b/src/components/banners/NoAutomationBanner.stories.tsx
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { NoAutomationBanner } from './NoAutomationBanner';
+
+const meta = {
+ title: 'Datalayer/Banners/NoAutomationBanner',
+ component: NoAutomationBanner,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/components/buttons/DownloadCSVButton.stories.tsx b/src/components/buttons/DownloadCSVButton.stories.tsx
new file mode 100644
index 00000000..72b3bb19
--- /dev/null
+++ b/src/components/buttons/DownloadCSVButton.stories.tsx
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { DownloadCSVButton } from './DownloadCSVButton';
+
+const meta = {
+ title: 'Datalayer/Buttons/DownloadCSVButton',
+ component: DownloadCSVButton,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['default', 'primary', 'invisible', 'danger', 'link'],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const sampleData = {
+ users: [
+ { name: 'Alice', age: 30, email: 'alice@example.com' },
+ { name: 'Bob', age: 25, email: 'bob@example.com' },
+ { name: 'Charlie', age: 35, email: 'charlie@example.com' },
+ ],
+};
+
+export const Default: Story = {
+ args: {
+ data: sampleData,
+ fileName: 'sample-data',
+ variant: 'default',
+ },
+};
+
+export const Primary: Story = {
+ args: {
+ data: sampleData,
+ fileName: 'sample-data',
+ variant: 'primary',
+ },
+};
+
+export const Danger: Story = {
+ args: {
+ data: sampleData,
+ fileName: 'sample-data',
+ variant: 'danger',
+ },
+};
+
+export const NoData: Story = {
+ args: {
+ fileName: 'empty-data',
+ variant: 'default',
+ },
+};
diff --git a/src/components/buttons/DownloadJsonButton.stories.tsx b/src/components/buttons/DownloadJsonButton.stories.tsx
new file mode 100644
index 00000000..9f2148d1
--- /dev/null
+++ b/src/components/buttons/DownloadJsonButton.stories.tsx
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { DownloadJsonButton } from './DownloadJsonButton';
+
+const meta = {
+ title: 'Datalayer/Buttons/DownloadJsonButton',
+ component: DownloadJsonButton,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['default', 'primary', 'invisible', 'danger', 'link'],
+ },
+ extension: {
+ control: 'select',
+ options: ['json', 'yaml', 'xml'],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const sampleData = {
+ project: 'Datalayer Core',
+ version: '0.0.3',
+ components: ['buttons', 'banners', 'notebooks'],
+ metadata: {
+ created: '2023-01-01',
+ updated: '2025-01-01',
+ },
+};
+
+export const Default: Story = {
+ args: {
+ data: sampleData,
+ fileName: 'project-data',
+ extension: 'json',
+ variant: 'default',
+ },
+};
+
+export const Primary: Story = {
+ args: {
+ data: sampleData,
+ fileName: 'project-data',
+ extension: 'json',
+ variant: 'primary',
+ },
+};
+
+export const YamlExport: Story = {
+ args: {
+ data: sampleData,
+ fileName: 'project-data',
+ extension: 'yaml',
+ variant: 'default',
+ },
+};
+
+export const NoData: Story = {
+ args: {
+ fileName: 'empty-data',
+ extension: 'json',
+ variant: 'default',
+ },
+};
diff --git a/src/components/buttons/LongActionButton.stories.tsx b/src/components/buttons/LongActionButton.stories.tsx
new file mode 100644
index 00000000..63cd98a5
--- /dev/null
+++ b/src/components/buttons/LongActionButton.stories.tsx
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
+import { PlayIcon, StopIcon, SyncIcon } from '@primer/octicons-react';
+
+import { LongActionButton } from './LongActionButton';
+
+const meta = {
+ title: 'Datalayer/Buttons/LongActionButton',
+ component: LongActionButton,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ onClick: { action: 'clicked' },
+ },
+ args: {
+ onClick: fn(),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ label: 'Run Action',
+ icon: PlayIcon,
+ onClick: async () => {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ },
+ },
+};
+
+export const WithCustomIcon: Story = {
+ args: {
+ label: 'Sync Data',
+ icon: SyncIcon,
+ onClick: async () => {
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ },
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ label: 'Disabled Action',
+ icon: StopIcon,
+ disabled: true,
+ onClick: async () => {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ },
+ },
+};
+
+export const ForcedProgress: Story = {
+ args: {
+ label: 'Processing',
+ icon: PlayIcon,
+ inProgress: true,
+ onClick: async () => {
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ },
+ },
+};
+
+export const LongRunning: Story = {
+ args: {
+ label: 'Long Process',
+ icon: PlayIcon,
+ onClick: async () => {
+ await new Promise(resolve => setTimeout(resolve, 5000));
+ },
+ },
+};
diff --git a/src/components/buttons/UploadButton.stories.tsx b/src/components/buttons/UploadButton.stories.tsx
new file mode 100644
index 00000000..09ab245c
--- /dev/null
+++ b/src/components/buttons/UploadButton.stories.tsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { fn } from 'storybook/test';
+
+import { UploadButton, UploadIconButton } from './UploadButton';
+
+const meta = {
+ title: 'Datalayer/Buttons/UploadButton',
+ component: UploadButton,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['default', 'primary', 'invisible', 'danger', 'link'],
+ },
+ upload: { action: 'file uploaded' },
+ },
+ args: {
+ upload: fn().mockResolvedValue(undefined),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ label: 'Upload File',
+ variant: 'primary',
+ multiple: false,
+ },
+};
+
+export const Multiple: Story = {
+ args: {
+ label: 'Upload Files',
+ variant: 'default',
+ multiple: true,
+ },
+};
+
+export const Primary: Story = {
+ args: {
+ label: 'Upload Document',
+ variant: 'primary',
+ multiple: false,
+ },
+};
+
+export const Invisible: Story = {
+ args: {
+ label: 'Upload',
+ variant: 'invisible',
+ multiple: false,
+ },
+};
+
+// Icon button variant
+const iconMeta = {
+ ...meta,
+ title: 'Datalayer/Buttons/UploadIconButton',
+ component: UploadIconButton,
+} satisfies Meta;
+
+export const IconButton: StoryObj = {
+ args: {
+ label: 'Upload file',
+ multiple: false,
+ },
+};
+
+export const IconButtonMultiple: StoryObj = {
+ args: {
+ label: 'Upload files',
+ multiple: true,
+ },
+};
diff --git a/src/components/checkout/StripeCheckout.stories.tsx b/src/components/checkout/StripeCheckout.stories.tsx
new file mode 100644
index 00000000..26345e8f
--- /dev/null
+++ b/src/components/checkout/StripeCheckout.stories.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { StripeCheckout } from './StripeCheckout';
+import type { ICheckoutPortal } from '../../models';
+
+const meta = {
+ title: 'Datalayer/Checkout/StripeCheckout',
+ component: StripeCheckout,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const mockCheckoutPortal: ICheckoutPortal = {
+ route: '/checkout',
+ is_modal: false,
+ metadata: {
+ stripe_key: 'pk_test_mock_stripe_key',
+ },
+};
+
+const mockCheckoutPortalModal: ICheckoutPortal = {
+ url: 'https://checkout.stripe.com/test',
+ is_modal: true,
+ metadata: {
+ stripe_key: 'pk_test_mock_stripe_key',
+ },
+};
+
+export const Default: Story = {
+ args: {
+ checkoutPortal: mockCheckoutPortal,
+ },
+};
+
+export const Modal: Story = {
+ args: {
+ checkoutPortal: mockCheckoutPortalModal,
+ },
+};
+
+export const NoPortal: Story = {
+ args: {
+ checkoutPortal: null,
+ },
+};
diff --git a/src/components/confetti/ConfettiSuccess.stories.tsx b/src/components/confetti/ConfettiSuccess.stories.tsx
new file mode 100644
index 00000000..0ec908ab
--- /dev/null
+++ b/src/components/confetti/ConfettiSuccess.stories.tsx
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { ConfettiSuccess } from './ConfettiSuccess';
+
+const meta = {
+ title: 'Datalayer/Confetti/ConfettiSuccess',
+ component: ConfettiSuccess,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'fullscreen',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/components/context/OrganizationSelect.stories.tsx b/src/components/context/OrganizationSelect.stories.tsx
new file mode 100644
index 00000000..e6091f15
--- /dev/null
+++ b/src/components/context/OrganizationSelect.stories.tsx
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { OrganizationSelect } from './OrganizationSelect';
+
+// Mock dependencies
+const meta = {
+ title: 'Datalayer/Context/OrganizationSelect',
+ component: OrganizationSelect,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ mockAddonConfigs: {
+ globalMockData: [
+ {
+ path: '../../hooks',
+ default: {
+ useUser: () => ({
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ }),
+ useCache: () => ({
+ refreshUserOrganizations: () =>
+ Promise.resolve({ success: true }),
+ getUserOrganizations: () => [
+ { id: '1', name: 'Organization 1' },
+ { id: '2', name: 'Organization 2' },
+ { id: '3', name: 'Organization 3' },
+ ],
+ }),
+ },
+ },
+ {
+ path: '../../state',
+ default: {
+ useLayoutStore: () => ({
+ organization: undefined,
+ updateLayoutOrganization: () => {},
+ updateLayoutSpace: () => {},
+ }),
+ },
+ },
+ ],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WithSelection: Story = {
+ parameters: {
+ mockAddonConfigs: {
+ globalMockData: [
+ {
+ path: '../../hooks',
+ default: {
+ useUser: () => ({
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ }),
+ useCache: () => ({
+ refreshUserOrganizations: () =>
+ Promise.resolve({ success: true }),
+ getUserOrganizations: () => [
+ { id: '1', name: 'Organization 1' },
+ { id: '2', name: 'Organization 2' },
+ { id: '3', name: 'Organization 3' },
+ ],
+ }),
+ },
+ },
+ {
+ path: '../../state',
+ default: {
+ useLayoutStore: () => ({
+ organization: { id: '1', name: 'Organization 1' },
+ updateLayoutOrganization: () => {},
+ updateLayoutSpace: () => {},
+ }),
+ },
+ },
+ ],
+ },
+ },
+};
+
+export const EmptyOrganizations: Story = {
+ parameters: {
+ mockAddonConfigs: {
+ globalMockData: [
+ {
+ path: '../../hooks',
+ default: {
+ useUser: () => ({
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ }),
+ useCache: () => ({
+ refreshUserOrganizations: () =>
+ Promise.resolve({ success: true }),
+ getUserOrganizations: () => [],
+ }),
+ },
+ },
+ {
+ path: '../../state',
+ default: {
+ useLayoutStore: () => ({
+ organization: undefined,
+ updateLayoutOrganization: () => {},
+ updateLayoutSpace: () => {},
+ }),
+ },
+ },
+ ],
+ },
+ },
+};
diff --git a/src/components/context/SpaceSelect.stories.tsx b/src/components/context/SpaceSelect.stories.tsx
new file mode 100644
index 00000000..2b0c289c
--- /dev/null
+++ b/src/components/context/SpaceSelect.stories.tsx
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { SpaceSelect } from './SpaceSelect';
+
+// Mock dependencies
+const meta = {
+ title: 'Datalayer/Context/SpaceSelect',
+ component: SpaceSelect,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ mockAddonConfigs: {
+ globalMockData: [
+ {
+ path: '../../hooks',
+ default: {
+ useUser: () => ({
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ }),
+ useCache: () => ({
+ refreshUserSpaces: () => Promise.resolve({ success: true }),
+ getUserSpaces: () => [
+ { id: '1', name: 'Personal Space' },
+ { id: '2', name: 'Team Space' },
+ { id: '3', name: 'Project Space' },
+ ],
+ refreshOrganizationSpaces: () =>
+ Promise.resolve({ success: true }),
+ getOrganizationSpaces: () => [
+ { id: '1', name: 'Org Space 1' },
+ { id: '2', name: 'Org Space 2' },
+ ],
+ }),
+ },
+ },
+ {
+ path: '../../state',
+ default: {
+ useLayoutStore: () => ({
+ organization: undefined,
+ space: undefined,
+ updateLayoutSpace: () => {},
+ }),
+ },
+ },
+ ],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WithOrganization: Story = {
+ parameters: {
+ mockAddonConfigs: {
+ globalMockData: [
+ {
+ path: '../../hooks',
+ default: {
+ useUser: () => ({
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ }),
+ useCache: () => ({
+ refreshUserSpaces: () => Promise.resolve({ success: true }),
+ getUserSpaces: () => [
+ { id: '1', name: 'Personal Space' },
+ { id: '2', name: 'Team Space' },
+ ],
+ refreshOrganizationSpaces: () =>
+ Promise.resolve({ success: true }),
+ getOrganizationSpaces: () => [
+ { id: '1', name: 'Org Space 1' },
+ { id: '2', name: 'Org Space 2' },
+ { id: '3', name: 'Org Space 3' },
+ ],
+ }),
+ },
+ },
+ {
+ path: '../../state',
+ default: {
+ useLayoutStore: () => ({
+ organization: { id: '1', name: 'Test Organization' },
+ space: undefined,
+ updateLayoutSpace: () => {},
+ }),
+ },
+ },
+ ],
+ },
+ },
+};
+
+export const WithSelectedSpace: Story = {
+ parameters: {
+ mockAddonConfigs: {
+ globalMockData: [
+ {
+ path: '../../hooks',
+ default: {
+ useUser: () => ({
+ id: '1',
+ name: 'Test User',
+ email: 'test@example.com',
+ }),
+ useCache: () => ({
+ refreshUserSpaces: () => Promise.resolve({ success: true }),
+ getUserSpaces: () => [
+ { id: '1', name: 'Personal Space' },
+ { id: '2', name: 'Team Space' },
+ { id: '3', name: 'Project Space' },
+ ],
+ refreshOrganizationSpaces: () =>
+ Promise.resolve({ success: true }),
+ getOrganizationSpaces: () => [
+ { id: '1', name: 'Org Space 1' },
+ { id: '2', name: 'Org Space 2' },
+ ],
+ }),
+ },
+ },
+ {
+ path: '../../state',
+ default: {
+ useLayoutStore: () => ({
+ organization: undefined,
+ space: { id: '1', name: 'Personal Space' },
+ updateLayoutSpace: () => {},
+ }),
+ },
+ },
+ ],
+ },
+ },
+};
diff --git a/src/components/display/AvatarSkeleton.stories.tsx b/src/components/display/AvatarSkeleton.stories.tsx
new file mode 100644
index 00000000..fda21163
--- /dev/null
+++ b/src/components/display/AvatarSkeleton.stories.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { AvatarSkeleton } from './AvatarSkeleton';
+
+const meta = {
+ title: 'Datalayer/Display/AvatarSkeleton',
+ component: AvatarSkeleton,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ size: {
+ control: 'number',
+ description: 'Size of the avatar skeleton',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Small: Story = {
+ args: {
+ size: 24,
+ },
+};
+
+export const Medium: Story = {
+ args: {
+ size: 48,
+ },
+};
+
+export const Large: Story = {
+ args: {
+ size: 72,
+ },
+};
diff --git a/src/components/display/CenteredSpinner.stories.tsx b/src/components/display/CenteredSpinner.stories.tsx
new file mode 100644
index 00000000..7a1e6987
--- /dev/null
+++ b/src/components/display/CenteredSpinner.stories.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { CenteredSpinner } from './CenteredSpinner';
+
+const meta = {
+ title: 'Datalayer/Display/CenteredSpinner',
+ component: CenteredSpinner,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ message: {
+ control: 'text',
+ description: 'Optional message to display next to spinner',
+ },
+ size: {
+ control: 'select',
+ options: ['small', 'medium', 'large'],
+ description: 'Size of the spinner',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const WithMessage: Story = {
+ args: {
+ message: 'Loading data...',
+ },
+};
+
+export const Small: Story = {
+ args: {
+ size: 'small',
+ message: 'Processing...',
+ },
+};
+
+export const Large: Story = {
+ args: {
+ size: 'large',
+ message: 'Loading application...',
+ },
+};
diff --git a/src/components/display/CodePreview.stories.tsx b/src/components/display/CodePreview.stories.tsx
new file mode 100644
index 00000000..b9d2a1df
--- /dev/null
+++ b/src/components/display/CodePreview.stories.tsx
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { CodePreview } from './CodePreview';
+
+const meta = {
+ title: 'Datalayer/Display/CodePreview',
+ component: CodePreview,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+ argTypes: {
+ code: {
+ control: 'text',
+ description: 'Code to display',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ code: 'console.log("Hello, world!");',
+ },
+};
+
+export const PythonCode: Story = {
+ args: {
+ code: `import pandas as pd
+import numpy as np
+
+def analyze_data(data):
+ """Analyze the input data"""
+ result = pd.DataFrame(data)
+ return result.describe()
+
+# Example usage
+data = {'values': np.random.randn(100)}
+stats = analyze_data(data)
+print(stats)`,
+ },
+};
+
+export const JSONCode: Story = {
+ args: {
+ code: `{
+ "name": "datalayer-core",
+ "version": "1.0.0",
+ "description": "Datalayer Core SDK",
+ "scripts": {
+ "build": "npm run build:lib",
+ "dev": "vite",
+ "test": "vitest"
+ }
+}`,
+ },
+};
+
+export const LongCode: Story = {
+ args: {
+ code: `function calculateComplexAnalysis(dataset, options = {}) {
+ const { threshold = 0.5, method = 'standard', includeMetrics = true } = options;
+
+ // This is a very long line that will demonstrate word wrapping behavior in the code preview component when dealing with extensive content that exceeds normal display widths
+ const processedData = dataset.map(item => ({ ...item, processed: true, timestamp: Date.now(), metadata: { source: 'analysis', confidence: Math.random() } }));
+
+ return {
+ results: processedData,
+ summary: includeMetrics ? generateSummaryMetrics(processedData) : null,
+ config: { threshold, method }
+ };
+}`,
+ },
+};
diff --git a/src/components/display/DatalayerBox.stories.tsx b/src/components/display/DatalayerBox.stories.tsx
new file mode 100644
index 00000000..b944ba6a
--- /dev/null
+++ b/src/components/display/DatalayerBox.stories.tsx
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { DatalayerBox } from './DatalayerBox';
+
+const meta = {
+ title: 'Datalayer/Display/DatalayerBox',
+ component: DatalayerBox,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ mockAddonConfigs: {
+ globalMockData: [
+ {
+ path: '../../hooks',
+ default: {
+ useNavigate: () => (path: string) => {
+ console.log('Navigate to:', path);
+ },
+ },
+ },
+ ],
+ },
+ },
+ argTypes: {
+ title: {
+ control: 'text',
+ description: 'Title of the box',
+ },
+ linkLabel: {
+ control: 'text',
+ description: 'Label for the navigation link',
+ },
+ linkRoute: {
+ control: 'text',
+ description: 'Route for the navigation link',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ title: 'Sample Box Title',
+ children: 'This is the content inside the Datalayer box component.',
+ },
+};
+
+export const WithLink: Story = {
+ args: {
+ title: 'Notebooks',
+ linkLabel: 'View All',
+ linkRoute: '/notebooks',
+ children: 'Here you can see your recent notebooks and create new ones.',
+ },
+};
+
+export const WithComplexContent: Story = {
+ args: {
+ title: 'Dashboard Statistics',
+ linkLabel: 'Details',
+ linkRoute: '/dashboard',
+ children: (
+
+
Recent activity summary:
+
+ - 5 notebooks created this week
+ - 12 experiments completed
+ - 3 models deployed
+
+
+ ),
+ },
+};
+
+export const LongTitle: Story = {
+ args: {
+ title: 'This is a Very Long Title That Might Wrap to Multiple Lines',
+ linkLabel: 'See More',
+ linkRoute: '/details',
+ children: 'Content for the box with a long title.',
+ },
+};
diff --git a/src/components/display/HorizontalCenter.stories.tsx b/src/components/display/HorizontalCenter.stories.tsx
new file mode 100644
index 00000000..118f948b
--- /dev/null
+++ b/src/components/display/HorizontalCenter.stories.tsx
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { HorizontalCenter } from './HorizontalCenter';
+
+const meta = {
+ title: 'Datalayer/Display/HorizontalCenter',
+ component: HorizontalCenter,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+ argTypes: {
+ margin: {
+ control: 'text',
+ description: 'CSS margin value',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ children: (
+
+ Centered Content
+
+ ),
+ },
+};
+
+export const WithMargin: Story = {
+ args: {
+ margin: '20px',
+ children: (
+
+ Content with margin
+
+ ),
+ },
+};
+
+export const MultipleElements: Story = {
+ args: {
+ margin: '10px',
+ children: (
+ <>
+
+ Element 1
+
+
+ Element 2
+
+ Element 3
+ >
+ ),
+ },
+};
diff --git a/src/components/display/JupyterDialog.stories.tsx b/src/components/display/JupyterDialog.stories.tsx
new file mode 100644
index 00000000..cd4f80df
--- /dev/null
+++ b/src/components/display/JupyterDialog.stories.tsx
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { createElement } from 'react';
+import { Dialog } from '@jupyterlab/apputils';
+
+import { JupyterDialog } from './JupyterDialog';
+
+const meta = {
+ title: 'Datalayer/Display/JupyterDialog',
+ component: JupyterDialog,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ title: {
+ control: 'text',
+ description: 'Dialog title',
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+// Simple body component for stories
+const SimpleBody = ({
+ setValue,
+}: {
+ setValue: (v: string | Error) => void;
+}) => {
+ return createElement('div', {}, 'This is a simple dialog body');
+};
+
+// Form body component for stories
+const FormBody = ({ setValue }: { setValue: (v: string | Error) => void }) => {
+ return createElement('div', {
+ children: [
+ createElement('p', { key: 'text' }, 'Please enter some text:'),
+ createElement('input', {
+ key: 'input',
+ type: 'text',
+ placeholder: 'Enter text here...',
+ onChange: (e: any) => setValue(e.target.value),
+ style: { width: '100%', padding: '8px', marginTop: '8px' },
+ }),
+ ],
+ });
+};
+
+export const Default: Story = {
+ render: () => {
+ const dialog = new JupyterDialog({
+ title: 'Sample Dialog',
+ body: SimpleBody,
+ buttons: [Dialog.cancelButton(), Dialog.okButton()],
+ checkbox: null,
+ host: document.body,
+ });
+
+ // For Storybook, we'll render the component directly
+ return dialog.render();
+ },
+};
+
+export const WithCheckbox: Story = {
+ render: () => {
+ const dialog = new JupyterDialog({
+ title: 'Dialog with Checkbox',
+ body: SimpleBody,
+ buttons: [Dialog.cancelButton(), Dialog.okButton()],
+ checkbox: {
+ label: 'Remember my choice',
+ caption: 'This will save your preference',
+ checked: false,
+ },
+ host: document.body,
+ });
+
+ return dialog.render();
+ },
+};
+
+export const WithForm: Story = {
+ render: () => {
+ const dialog = new JupyterDialog({
+ title: 'Input Dialog',
+ body: FormBody,
+ buttons: [Dialog.cancelButton(), Dialog.okButton()],
+ checkbox: null,
+ host: document.body,
+ });
+
+ return dialog.render();
+ },
+};
+
+export const DangerButton: Story = {
+ render: () => {
+ const dialog = new JupyterDialog({
+ title: 'Confirm Action',
+ body: ({ setValue }) =>
+ createElement('div', {}, 'This action cannot be undone. Are you sure?'),
+ buttons: [
+ Dialog.cancelButton(),
+ Dialog.warnButton({ label: 'Delete', accept: true }),
+ ],
+ checkbox: null,
+ host: document.body,
+ });
+
+ return dialog.render();
+ },
+};
diff --git a/src/components/display/Markdown.stories.tsx b/src/components/display/Markdown.stories.tsx
new file mode 100644
index 00000000..4bf6d094
--- /dev/null
+++ b/src/components/display/Markdown.stories.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2023-2025 Datalayer, Inc.
+ * Distributed under the terms of the Modified BSD License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Markdown } from './Markdown';
+
+const meta = {
+ title: 'Datalayer/Display/Markdown',
+ component: Markdown,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const mockMarkdownParser = {
+ render: async (text: string) => {
+ // Simple markdown to HTML conversion for demo
+ return text
+ .replace(/^# (.+)$/gm, '$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/\n/g, '
');
+ },
+};
+
+export const SimpleMarkdown: Story = {
+ args: {
+ text: '# Hello World\nThis is **bold** and this is *italic*',
+ markdownParser: mockMarkdownParser as any,
+ },
+};
+
+export const ComplexMarkdown: Story = {
+ args: {
+ text: `# Main Title
+## Subtitle
+This is a paragraph with **bold** and *italic* text.
+
+Another paragraph here.`,
+ markdownParser: mockMarkdownParser as any,
+ },
+};
+
+export const WithSanitizer: Story = {
+ args: {
+ text: '# Hello World\n',
+ markdownParser: mockMarkdownParser as any,
+ sanitizer: {
+ sanitize: (html: string) =>
+ html.replace(/