Storybook coverage: scan harness + stories (#7073)

## What

Gets most of the app's components into Storybook and adds a scan that
runs every story in a real browser, so we have a base to build
accessibility testing on next.

- **~380 new stories**, taking story files from 144 to 526. Components
with a story:

  | Layer | Before | After |
  |---|---|---|
  | core | 41 / 309 (13%) | **183 / 309 (59%)** |
  | portal | 91 / 161 (57%) | **127 / 161 (79%)** |
  | proprietary | 1 / 105 (1%) | **39 / 105 (37%)** |
| cloud / desktop / saas / portal-saas / prototypes | 0 / 84 | 0 / 84
(unchanged) |
  | **Total** | **133 / 659 (20%)** | **349 / 659 (53%)** |

Both columns are counted the same way — every `.tsx` exporting a
component, so the denominator includes things that aren't really visual
units (contexts, providers, barrels). Excluding those it's 22% → 58%.
Either way it's reproducible from the tree rather than a number you have
to take on trust.

- **Scan harness** — the Storybook Vitest addon runs each story in
headless Chromium as a **render/smoke check** (a story must mount
without throwing). New task: `task frontend:storybook:test` (pass a
filter, e.g. `-- Button`). Separate Vitest config so it doesn't touch
the existing jsdom unit tests.

## Scope

- **Stories and Storybook config only, with one exception:** a one-line
fix to `ProviderCard`, which re-rendered forever whenever its optional
`settings` prop was omitted. Called out because it's the only component
source change here.
- The preview gains a `QueryClientProvider` (the portal app has one, so
stories reaching a query hook threw without it), and the scan task now
installs the browser it drives.
- **a11y is report-only** and **nothing runs the scan in CI yet** —
enforcing a11y and wiring it into CI is the follow-up, #7086.
- Components that can't render as an isolated unit are **not** included:
anything needing the full editor runtime (ToolWorkflow / FileManager /
AppConfig / a live PDF engine) or that's headless (providers, gates, API
bridges, config factories). Stories that only rendered by mounting the
whole `AppProviders` tree were dropped for the same reason — that isn't
isolation, and the tree's ErrorBoundary swallowed render failures so
those stories could never fail. A few that need assets the headless
browser can't serve are tagged `!test`, so they still show in the UI but
sit out the scan.

## Testing

Typecheck (all build variants), ESLint, Prettier and the unit suite
pass. Every story in the scanned set mounts without throwing.

## Notes for reviewers

- Stories use the `@app`/`@core`/`@portal`/`@proprietary` aliases (no
deep relative imports) and mock data-fetching components with MSW.
- Running the full suite in one go can flake on the Vite dep-optimizer;
scan in small batches (or by filter) for a stable local run.
This commit is contained in:
Reece Browne
2026-07-28 16:06:06 +00:00
committed by GitHub
parent ba404d3f90
commit 22ec0947c9
394 changed files with 19750 additions and 46 deletions
+16
View File
@@ -194,6 +194,22 @@ tasks:
cmds:
- npx storybook build {{.CLI_ARGS}}
storybook:browser:
internal: true
desc: "Install the Chromium build the story scan runs in"
run: once
deps: [install]
cmds:
- npx playwright install chromium
storybook:test:
desc: "Scan every story in real Chromium — each must mount without throwing"
deps: [install, storybook:browser]
cmds:
# Runs each story as a browser test. Pass a filter through, e.g.
# task frontend:storybook:test -- Button
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
# ============================================================
# Code quality
# ============================================================
+5 -1
View File
@@ -15,7 +15,11 @@ const config: StorybookConfig = {
"../editor/src/portal/**/*.mdx",
"../editor/src/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
addons: [
"@storybook/addon-themes",
"@storybook/addon-a11y",
"@storybook/addon-vitest",
],
framework: {
name: "@storybook/react-vite",
options: {},
+29 -18
View File
@@ -13,6 +13,7 @@ import { withThemeByDataAttribute } from "@storybook/addon-themes";
// classic runtime needs it present even though it's not named in the JSX.
void React;
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TierProvider, type Tier } from "@portal/contexts/TierContext";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
@@ -86,6 +87,14 @@ if (!i18next.isInitialized) {
// Start MSW once. Storybook runs in a browser so this uses the service worker.
initialize({ onUnhandledRequest: "bypass" }, handlers);
// PortalApp wraps the app in a QueryClientProvider, so any component reaching a
// shared query hook throws "No QueryClient set" without one here. `retry: false`
// matches the portal test providers: a story showing an error state should show
// it immediately rather than sitting through backoff retries.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Storybook-only: stub a SaaS session so apiClient.saas reads (invoices, payment
// method, wallet) clear the session check and reach the MSW handlers instead of
// failing with "No SaaS session". VITE_SUPABASE_URL/KEY are defined empty (see
@@ -194,24 +203,26 @@ const withProviders: Decorator = (Story, context) => {
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
return (
<MemoryRouter initialEntries={["/"]}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
<TierKey tier={tier}>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
</SuiProvider>
</ThemeBridge>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<SchemeSetup scheme={colorScheme} />
<ThemeBridge theme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
<TierKey tier={tier}>
<UIProvider>
<Suspense fallback={null}>
<Story />
</Suspense>
</UIProvider>
</TierKey>
</LinkProvider>
</SuiProvider>
</ThemeBridge>
</ThemeProvider>
</QueryClientProvider>
</MemoryRouter>
);
};
+62
View File
@@ -0,0 +1,62 @@
import { resolve } from "node:path";
import { defineConfig } from "vitest/config";
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
/**
* Dedicated Vitest config that turns every story into a browser test: it mounts
* the story in real Chromium as a render/smoke check (a story must mount without
* throwing). a11y is currently report-only (preview's `a11y.test: "todo"`) and is
* not yet enforced here — flipping it to pass/fail is a follow-up. Kept separate
* from editor/vitest.config.ts (the jsdom unit tests) so the two suites don't collide.
*
* The storybook test must live in a `test.projects[]` entry (not a flat config)
* so Vitest wires up the browser test runner correctly.
*
* Run with: npx vitest run --config .storybook/vitest.config.ts
*/
export default defineConfig({
optimizeDeps: {
// Pre-scan every story + the preview so Vite discovers the story set's large
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
entries: ["editor/src/**/*.stories.@(ts|tsx)", ".storybook/preview.tsx"],
// `entries` alone does not catch deps reached only through a transformed
// JSX runtime import, so Vite optimizes them lazily mid-run and emits
// "optimized dependencies changed, reloading". That reload tears down the
// browser worker and whichever stories were mid-load fail with a bogus
// "Failed to fetch dynamically imported module" — a result that looks real.
// Naming them here keeps a run deterministic.
include: [
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"react-dom",
"react-dom/client",
],
},
test: {
projects: [
{
extends: true,
// Reads .storybook/main.ts (stories glob + viteFinal aliases) so every
// discovered story becomes a test with the app's real module resolution.
plugins: [storybookTest({ configDir: resolve(__dirname) })],
test: {
name: "storybook",
// Mounting a story takes well over Vitest's 5s default on the heavier
// screens, and a story that trips the timeout is reported as a failure
// with no message — which reads like a crash. Give it room; a
// genuinely hung story still fails, just later.
testTimeout: 60_000,
hookTimeout: 60_000,
browser: {
enabled: true,
headless: true,
provider: "playwright",
instances: [{ browser: "chromium" }],
},
setupFiles: [resolve(__dirname, "vitest.setup.ts")],
},
},
],
},
});
+10
View File
@@ -0,0 +1,10 @@
import { beforeAll } from "vitest";
import { setProjectAnnotations } from "@storybook/react-vite";
// eslint-disable-next-line no-restricted-imports -- Storybook-only: the sibling preview config has no @-alias.
import * as projectAnnotations from "./preview";
// Apply the same decorators/parameters/globals the Storybook UI uses (providers,
// i18n, theme) so stories run under Vitest render identically to the browser.
const project = setProjectAnnotations([projectAnnotations]);
beforeAll(project.beforeAll);
@@ -0,0 +1,75 @@
import { useEffect } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppLayout } from "@app/components/AppLayout";
import { BannerProvider, useBanner } from "@app/contexts/BannerContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { InfoBanner } from "@app/components/shared/InfoBanner";
const meta = {
title: "Components/AppLayout",
component: AppLayout,
parameters: { layout: "fullscreen" },
// AppLayout reads the active banner from BannerContext and renders
// NavigationWarningModal + LoginAgreementModal, which need the navigation
// guard (backed by the tool registry) mounted above them.
decorators: [
(Story) => (
<ToolRegistryProvider>
<NavigationProvider>
<BannerProvider>
<Story />
</BannerProvider>
</NavigationProvider>
</ToolRegistryProvider>
),
],
} satisfies Meta<typeof AppLayout>;
export default meta;
type Story = StoryObj<typeof meta>;
const sampleContent = (
<div style={{ padding: 24 }}>
<h1>Workbench</h1>
<p>Tool panels and file previews render in this area.</p>
</div>
);
export const Default: Story = {
args: {
children: sampleContent,
},
};
// Calls BannerContext's setBanner on mount so the story can exercise
// AppLayout's height-adjustment behaviour (the child area shrinks to make
// room for the banner) without adding a banner prop to AppLayout itself.
function BannerSetter() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(
<InfoBanner
icon="info-rounded"
title="Heads up"
message="This workspace is running in offline mode."
/>,
);
return () => setBanner(null);
}, [setBanner]);
return null;
}
export const WithBanner: Story = {
args: {
children: sampleContent,
},
decorators: [
(Story) => (
<>
<BannerSetter />
<Story />
</>
),
],
};
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import StorageStatsCard from "@app/components/StorageStatsCard";
import { StorageStats } from "@app/services/fileStorage";
const storageStats: StorageStats = {
used: 128 * 1024 * 1024,
available: 512 * 1024 * 1024,
fileCount: 12,
quota: 512 * 1024 * 1024,
};
const meta = {
title: "Components/StorageStatsCard",
component: StorageStatsCard,
parameters: { layout: "padded" },
args: {
storageStats,
filesCount: 12,
onClearAll: () => {},
onReloadFiles: () => {},
},
} satisfies Meta<typeof StorageStatsCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const NearingQuota: Story = {
args: {
storageStats: {
...storageStats,
used: 460 * 1024 * 1024,
fileCount: 48,
},
filesCount: 48,
},
};
export const NoQuota: Story = {
args: {
storageStats: {
used: 32 * 1024 * 1024,
available: 0,
fileCount: 3,
},
filesCount: 3,
},
};
@@ -0,0 +1,68 @@
import type { ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider";
import { SignatureProvider } from "@app/contexts/SignatureContext";
// BaseAnnotationTool reads usePDFAnnotation()/useSignature() for undo/redo and
// placement wiring — stub both providers so the story can mount standalone.
const StoryProviders = ({ children }: { children: ReactNode }) => (
<SignatureProvider>
<PDFAnnotationProvider
activateDrawMode={() => {}}
deactivateDrawMode={() => {}}
activateSignaturePlacementMode={() => {}}
activateDeleteMode={() => {}}
updateDrawSettings={() => {}}
undo={() => {}}
redo={() => {}}
storeImageData={() => {}}
getImageData={() => undefined}
isPlacementMode={false}
signatureConfig={null}
setSignatureConfig={() => {}}
>
{children}
</PDFAnnotationProvider>
</SignatureProvider>
);
// BaseAnnotationTool clones its child with tool-specific props (selectedColor,
// signatureData, onSignatureDataChange, onColorSwatchClick, disabled) — a real
// tool component absorbs these; a bare DOM element would just log prop warnings.
const ToolContent = (_props: Record<string, unknown>) => (
<div>Tool content</div>
);
const meta = {
title: "Annotation/BaseAnnotationTool",
component: BaseAnnotationTool,
decorators: [
(Story) => (
<StoryProviders>
<Story />
</StoryProviders>
),
],
} satisfies Meta<typeof BaseAnnotationTool>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
config: {
enableImageUpload: true,
showPlaceButton: true,
placeButtonText: "Place Image",
},
children: <ToolContent />,
},
};
export const Disabled: Story = {
args: {
...Default.args,
disabled: true,
},
};
@@ -0,0 +1,35 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ColorControl } from "@app/components/annotation/shared/ColorControl";
const meta: Meta<typeof ColorControl> = {
title: "Annotation/ColorControl",
component: ColorControl,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof ColorControl>;
function ColorControlDemo({
initialColor = "#ff0000",
disabled,
}: {
initialColor?: string;
disabled?: boolean;
}) {
const [color, setColor] = useState(initialColor);
return (
<ColorControl
label="Colour"
value={color}
onChange={setColor}
disabled={disabled}
/>
);
}
export const Default: Story = { render: () => <ColorControlDemo /> };
export const Disabled: Story = {
render: () => <ColorControlDemo disabled />,
};
@@ -0,0 +1,45 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
const meta = {
title: "Annotation/Shared/ColorPicker",
component: ColorPicker,
parameters: { layout: "fullscreen" },
args: {
isOpen: true,
onClose: () => {},
selectedColor: "#cc0000",
onColorChange: () => {},
},
} satisfies Meta<typeof ColorPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
function ColorPickerDemo(
props: Partial<React.ComponentProps<typeof ColorPicker>>,
) {
const [color, setColor] = useState(props.selectedColor ?? "#cc0000");
const [opacity, setOpacity] = useState(props.opacity ?? 100);
return (
<ColorPicker
isOpen
onClose={() => {}}
{...props}
selectedColor={color}
onColorChange={setColor}
opacity={opacity}
onOpacityChange={setOpacity}
/>
);
}
/** The base modal: swatches + hex picker, no opacity slider. */
export const Default: Story = {
render: () => <ColorPickerDemo />,
};
/** With the opacity slider shown, for tools that need translucent fills (e.g. highlight, watermark). */
export const WithOpacity: Story = {
render: () => <ColorPickerDemo showOpacity />,
};
@@ -0,0 +1,52 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
const meta = {
title: "Annotation/DrawingCanvas",
component: DrawingCanvas,
parameters: { layout: "padded" },
args: {
selectedColor: "#000000",
penSize: 3,
penSizeInput: "3",
onColorSwatchClick: () => {},
onPenSizeChange: () => {},
onPenSizeInputChange: () => {},
onSignatureDataChange: () => {},
},
} satisfies Meta<typeof DrawingCanvas>;
export default meta;
type Story = StoryObj<typeof meta>;
function DrawingCanvasDemo(
props: Partial<React.ComponentProps<typeof DrawingCanvas>>,
) {
const [penSize, setPenSize] = useState(3);
const [penSizeInput, setPenSizeInput] = useState("3");
return (
<DrawingCanvas
selectedColor="#000000"
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => {}}
onPenSizeChange={(size) => {
setPenSize(size);
setPenSizeInput(String(size));
}}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={() => {}}
{...props}
/>
);
}
/** Empty canvas preview with the drawing modal closed. */
export const Default: Story = { render: () => <DrawingCanvasDemo /> };
/** Preview disabled — clicking the canvas should not open the modal. */
export const Disabled: Story = {
render: () => <DrawingCanvasDemo disabled />,
};
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DrawingControls } from "@app/components/annotation/shared/DrawingControls";
const meta: Meta<typeof DrawingControls> = {
title: "Annotation/DrawingControls",
component: DrawingControls,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof DrawingControls>;
export const Default: Story = {
args: {
onUndo: () => {},
onRedo: () => {},
onPlaceSignature: () => {},
hasSignatureData: true,
canUndo: true,
canRedo: true,
},
};
export const NoHistory: Story = {
args: {
onUndo: () => {},
onRedo: () => {},
onPlaceSignature: () => {},
hasSignatureData: false,
canUndo: false,
canRedo: false,
},
};
export const Disabled: Story = {
args: {
...Default.args,
disabled: true,
},
};
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
const meta = {
title: "Annotation/Shared/ImageUploader",
component: ImageUploader,
parameters: { layout: "padded" },
} satisfies Meta<typeof ImageUploader>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
onImageChange: () => {},
},
};
export const WithLabelAndHint: Story = {
args: {
onImageChange: () => {},
label: "Signature image",
hint: "PNG, JPG, or SVG - transparent backgrounds work best",
},
};
export const WithBackgroundRemoval: Story = {
args: {
onImageChange: () => {},
allowBackgroundRemoval: true,
onProcessedImageData: () => {},
},
};
export const Disabled: Story = {
args: {
onImageChange: () => {},
disabled: true,
},
};
@@ -0,0 +1,23 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OpacityControl } from "@app/components/annotation/shared/OpacityControl";
const meta: Meta<typeof OpacityControl> = {
title: "Annotation/OpacityControl",
component: OpacityControl,
};
export default meta;
type Story = StoryObj<typeof OpacityControl>;
function OpacityControlDemo({ disabled }: { disabled?: boolean }) {
const [value, setValue] = useState(80);
return (
<OpacityControl value={value} onChange={setValue} disabled={disabled} />
);
}
export const Default: Story = { render: () => <OpacityControlDemo /> };
export const Disabled: Story = {
render: () => <OpacityControlDemo disabled />,
};
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PropertiesPopover } from "@app/components/annotation/shared/PropertiesPopover";
const meta = {
title: "Annotation/Shared/PropertiesPopover",
component: PropertiesPopover,
parameters: { layout: "centered" },
} satisfies Meta<typeof PropertiesPopover>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Text: Story = {
args: {
annotationType: "text",
annotation: undefined,
onUpdate: () => {},
},
};
export const Shape: Story = {
args: {
annotationType: "shape",
annotation: undefined,
onUpdate: () => {},
},
};
export const Disabled: Story = {
args: {
annotationType: "text",
annotation: undefined,
onUpdate: () => {},
disabled: true,
},
};
@@ -0,0 +1,78 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
const meta = {
title: "Annotation/Shared/TextInputWithFont",
component: TextInputWithFont,
parameters: { layout: "padded" },
args: {
text: "Confidential",
onTextChange: () => {},
fontSize: 24,
onFontSizeChange: () => {},
fontFamily: "Helvetica",
onFontFamilyChange: () => {},
label: "Text",
placeholder: "Enter text",
fontLabel: "Font",
fontSizeLabel: "Size",
fontSizePlaceholder: "24",
},
} satisfies Meta<typeof TextInputWithFont>;
export default meta;
type Story = StoryObj<typeof meta>;
function TextInputWithFontDemo(
props: Partial<React.ComponentProps<typeof TextInputWithFont>>,
) {
const [text, setText] = useState(props.text ?? "Confidential");
const [fontSize, setFontSize] = useState(props.fontSize ?? 24);
const [fontFamily, setFontFamily] = useState(props.fontFamily ?? "Helvetica");
const [textColor, setTextColor] = useState(props.textColor ?? "#000000");
const [textAlign, setTextAlign] = useState<"left" | "center" | "right">(
props.textAlign ?? "left",
);
return (
<TextInputWithFont
label="Text"
placeholder="Enter text"
fontLabel="Font"
fontSizeLabel="Size"
fontSizePlaceholder="24"
colorLabel="Colour"
{...props}
text={text}
onTextChange={setText}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
textColor={textColor}
onTextColorChange={setTextColor}
textAlign={textAlign}
onTextAlignChange={setTextAlign}
/>
);
}
/** Full control set: text, font, size, colour and alignment. */
export const Default: Story = {
render: () => <TextInputWithFontDemo />,
};
/** Without the colour picker or alignment control, for tools that don't need them. */
export const WithoutColorOrAlign: Story = {
render: () => (
<TextInputWithFontDemo
onTextColorChange={undefined}
onTextAlignChange={undefined}
/>
),
};
/** All fields disabled. */
export const Disabled: Story = {
render: () => <TextInputWithFontDemo disabled />,
};
@@ -0,0 +1,35 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { WidthControl } from "@app/components/annotation/shared/WidthControl";
const meta: Meta<typeof WidthControl> = {
title: "Annotation/WidthControl",
component: WidthControl,
};
export default meta;
type Story = StoryObj<typeof WidthControl>;
function WidthControlDemo({
min = 1,
max = 12,
disabled,
}: {
min?: number;
max?: number;
disabled?: boolean;
}) {
const [value, setValue] = useState(Math.round((min + max) / 2));
return (
<WidthControl
value={value}
onChange={setValue}
min={min}
max={max}
disabled={disabled}
/>
);
}
export const Default: Story = { render: () => <WidthControlDemo /> };
export const Disabled: Story = { render: () => <WidthControlDemo disabled /> };
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DrawingTool } from "@app/components/annotation/tools/DrawingTool";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider";
// DrawingTool renders BaseAnnotationTool, which reads both SignatureContext and
// PDFAnnotationContext — neither is mounted by the shared preview, so stub both
// here with no-op handlers.
const meta = {
title: "Annotation/DrawingTool",
component: DrawingTool,
decorators: [
(Story) => (
<SignatureProvider>
<PDFAnnotationProvider
activateDrawMode={() => {}}
deactivateDrawMode={() => {}}
activateSignaturePlacementMode={() => {}}
activateDeleteMode={() => {}}
updateDrawSettings={() => {}}
undo={() => {}}
redo={() => {}}
storeImageData={() => {}}
getImageData={() => undefined}
isPlacementMode={false}
signatureConfig={null}
setSignatureConfig={() => {}}
>
<Story />
</PDFAnnotationProvider>
</SignatureProvider>
),
],
} satisfies Meta<typeof DrawingTool>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
onDrawingChange: () => {},
},
};
export const Disabled: Story = {
args: {
onDrawingChange: () => {},
disabled: true,
},
};
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ImageTool } from "@app/components/annotation/tools/ImageTool";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { PDFAnnotationProvider } from "@app/components/annotation/providers/PDFAnnotationProvider";
// ImageTool renders BaseAnnotationTool, which reads both SignatureContext and
// PDFAnnotationContext — neither is mounted by the shared preview, so stub both
// here with no-op handlers.
const meta = {
title: "Annotation/ImageTool",
component: ImageTool,
decorators: [
(Story) => (
<SignatureProvider>
<PDFAnnotationProvider
activateDrawMode={() => {}}
deactivateDrawMode={() => {}}
activateSignaturePlacementMode={() => {}}
activateDeleteMode={() => {}}
updateDrawSettings={() => {}}
undo={() => {}}
redo={() => {}}
storeImageData={() => {}}
getImageData={() => undefined}
isPlacementMode={false}
signatureConfig={null}
setSignatureConfig={() => {}}
>
<Story />
</PDFAnnotationProvider>
</SignatureProvider>
),
],
} satisfies Meta<typeof ImageTool>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
onImageChange: () => {},
},
};
export const Disabled: Story = {
args: {
onImageChange: () => {},
disabled: true,
},
};
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 1024,
lastModified: 0,
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
...overrides,
});
const meta = {
title: "FileEditor/FileEditorFileName",
component: FileEditorFileName,
} satisfies Meta<typeof FileEditorFileName>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
file: buildFileStub(),
},
};
export const LongFileName: Story = {
args: {
file: buildFileStub({
name: "annual-financial-report-quarter-four-2026-final-version.pdf",
}),
maxLength: 30,
},
};
@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import CompactFileDetails from "@app/components/fileManager/CompactFileDetails";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 1024 * 512,
lastModified: 0,
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
...overrides,
});
const meta = {
title: "FileManager/CompactFileDetails",
component: CompactFileDetails,
args: {
onPrevious: () => {},
onNext: () => {},
onOpenFiles: () => {},
},
} satisfies Meta<typeof CompactFileDetails>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
currentFile: buildFileStub(),
thumbnail: null,
selectedFiles: [buildFileStub()],
currentFileIndex: 0,
numberOfFiles: 1,
isAnimating: false,
},
};
export const MultipleFiles: Story = {
args: {
currentFile: buildFileStub({ name: "invoice-final.pdf", versionNumber: 2 }),
thumbnail: null,
selectedFiles: [
buildFileStub({ id: "file-1" as FileId, name: "invoice-final.pdf" }),
buildFileStub({ id: "file-2" as FileId, name: "receipt.pdf" }),
buildFileStub({ id: "file-3" as FileId, name: "statement.pdf" }),
],
currentFileIndex: 1,
numberOfFiles: 3,
isAnimating: false,
},
};
export const NoFileLoaded: Story = {
args: {
currentFile: null,
thumbnail: null,
selectedFiles: [],
currentFileIndex: 0,
numberOfFiles: 0,
isAnimating: false,
},
};
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import DragOverlay from "@app/components/fileManager/DragOverlay";
const meta = {
title: "FileManager/DragOverlay",
component: DragOverlay,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof DragOverlay>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
isVisible: true,
},
decorators: [
(Story) => (
<div style={{ position: "relative", height: "20rem" }}>
<Story />
</div>
),
],
};
export const Hidden: Story = {
args: {
isVisible: false,
},
};
@@ -0,0 +1,73 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileDetails from "@app/components/fileManager/FileDetails";
import { FileContextProvider } from "@app/contexts/FileContext";
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFile: StirlingFileStub = {
id: "story-file-1" as FileId,
name: "quarterly-report.pdf",
type: "application/pdf",
size: 2_400_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "story-file-1",
versionNumber: 1,
// Set so useIndexedDBThumbnail short-circuits on the stored thumbnail
// instead of trying to read file bytes out of IndexedDB.
thumbnailUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E",
};
/**
* FileDetails reads from FileManagerContext, which itself needs FileContext
* (for useFileActions/useFileManagement) and IndexedDBContext (pulled in by
* FileContextProvider) further up the tree — neither is part of the shared
* preview decorators, so both are stood up here with static mock data.
*/
function withFileManager(activeFileIds: FileId[]) {
return (Story: () => ReactElement) => (
<FileContextProvider>
<FileManagerProvider
recentFiles={[mockFile]}
onRecentFilesSelected={() => {}}
onNewFilesSelect={() => {}}
onClose={() => {}}
isFileSupported={() => true}
isOpen
onFileRemove={() => {}}
modalHeight="600px"
refreshRecentFiles={async () => {}}
isLoading={false}
activeFileIds={activeFileIds}
>
<Story />
</FileManagerProvider>
</FileContextProvider>
);
}
const meta = {
title: "FileManager/FileDetails",
component: FileDetails,
} satisfies Meta<typeof FileDetails>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
decorators: [withFileManager([mockFile.id])],
};
export const Empty: Story = {
decorators: [withFileManager([])],
};
export const Compact: Story = {
args: {
compact: true,
},
decorators: [withFileManager([mockFile.id])],
};
@@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 1024,
lastModified: 0,
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
...overrides,
});
const localFile = buildFileStub();
const cloudOnlyFile = buildFileStub({
id: "server-1" as FileId,
name: "shared-invoice.pdf",
originalFileId: "server-1",
remoteStorageId: 42,
remoteOwnedByCurrentUser: true,
});
const meta = {
title: "FilesPage/DeleteFilesDialog",
component: DeleteFilesDialog,
args: {
opened: true,
onClose: () => {},
onConfirm: async () => {},
},
} satisfies Meta<typeof DeleteFilesDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
files: [localFile],
},
};
export const CloudOnly: Story = {
args: {
files: [cloudOnlyFile],
},
};
export const LocalAndCloudChoice: Story = {
args: {
files: [localFile, cloudOnlyFile],
},
};
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog";
import { createFolderId } from "@app/types/folder";
const mockFolder = {
id: createFolderId(),
name: "Invoices",
parentFolderId: null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
const meta = {
title: "FilesPage/DeleteFolderDialog",
component: DeleteFolderDialog,
} satisfies Meta<typeof DeleteFolderDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
opened: true,
folder: mockFolder,
fileCount: 0,
onClose: () => {},
onConfirm: () => {},
},
};
export const WithFiles: Story = {
args: {
opened: true,
folder: mockFolder,
fileCount: 12,
onClose: () => {},
onConfirm: () => {},
},
};
@@ -0,0 +1,88 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import type { FolderRecord } from "@app/types/folder";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 245_760,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "file-1" as FileId,
versionNumber: 1,
...overrides,
});
const folder: FolderRecord = {
id: "folder-1" as FolderRecord["id"],
name: "Contracts",
parentFolderId: null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
const meta = {
title: "FilesPage/FileDetailsPanel",
component: FileDetailsPanel,
} satisfies Meta<typeof FileDetailsPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
const singleFile = buildFileStub();
const fileMap = new Map<FileId, StirlingFileStub>([
[singleFile.id, singleFile],
]);
export const Default: Story = {
args: {
selectedFileIds: [singleFile.id],
fileMap,
currentFolder: null,
onClose: () => {},
onAddToWorkspace: () => {},
onMove: () => {},
onRemove: () => {},
},
};
export const InFolder: Story = {
args: {
...Default.args,
currentFolder: folder,
},
};
export const MultiSelect: Story = {
args: (() => {
const fileA = buildFileStub({ id: "file-a" as FileId, name: "a.pdf" });
const fileB = buildFileStub({
id: "file-b" as FileId,
name: "b.pdf",
size: 102_400,
});
return {
selectedFileIds: [fileA.id, fileB.id],
fileMap: new Map<FileId, StirlingFileStub>([
[fileA.id, fileA],
[fileB.id, fileB],
]),
currentFolder: null,
onClose: () => {},
onAddToWorkspace: () => {},
onMove: () => {},
onRemove: () => {},
};
})(),
};
export const LocalOnlyWithSaveToServer: Story = {
args: {
...Default.args,
onSaveToServer: () => {},
},
};
@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactElement } from "react";
import {
FileGrid,
type FilesPageEntry,
} from "@app/components/filesPage/FileGrid";
import { FileContextProvider } from "@app/contexts/FileContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 1_240_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
// Set so useLazyThumbnail short-circuits on the stored thumbnail instead of
// trying to read file bytes out of IndexedDB.
thumbnailUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E",
...overrides,
});
const localFile = buildFileStub();
const cloudFile = buildFileStub({
id: "file-2" as FileId,
name: "shared-invoice.pdf",
originalFileId: "file-2",
remoteStorageId: 42,
remoteOwnedByCurrentUser: true,
});
const spreadsheetFile = buildFileStub({
id: "file-3" as FileId,
name: "budget.xlsx",
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
originalFileId: "file-3",
thumbnailUrl: undefined,
});
const fileEntries: FilesPageEntry[] = [
{ kind: "file", file: localFile },
{ kind: "file", file: cloudFile },
{ kind: "file", file: spreadsheetFile },
];
/**
* FileGrid renders file cards/rows via useLazyThumbnail, which reads
* IndexedDBContext + FileContext further up the tree - neither is part of
* the shared preview decorators, so FileContextProvider is stood up here.
* Folder entries are intentionally left out of these mocks: FolderCard /
* FolderRow call useFolders(), which needs a FolderProvider wired to
* IndexedDB/auth/app-config context this story doesn't stand up.
*/
function withFileContext(Story: () => ReactElement) {
return (
<FileContextProvider>
<Story />
</FileContextProvider>
);
}
const meta = {
title: "FilesPage/FileGrid",
component: FileGrid,
decorators: [withFileContext],
args: {
entries: fileEntries,
selectedFileIds: new Set<FileId>(),
viewMode: "grid",
onSelectFile: () => {},
onOpenFolder: () => {},
onOpenFile: () => {},
onMoveFiles: () => {},
onMoveFolder: () => {},
onRenameFolder: () => {},
onDeleteFolder: () => {},
onChangeFolderAppearance: () => {},
onRemoveFiles: () => {},
onPromptMoveFiles: () => {},
},
} satisfies Meta<typeof FileGrid>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const ListMode: Story = {
args: {
viewMode: "list",
},
};
export const Loading: Story = {
args: {
entries: [],
loading: true,
},
};
export const Empty: Story = {
args: {
entries: [],
loading: false,
currentTab: "all",
onEmptyUpload: () => {},
onEmptyCreateFolder: () => {},
},
};
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge";
const meta: Meta<typeof FileOriginBadge> = {
title: "FilesPage/FileOriginBadge",
component: FileOriginBadge,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
origin: "local",
},
};
export const Cloud: Story = {
args: {
origin: "cloud",
},
};
export const SharedWithMe: Story = {
args: {
origin: "shared-with-me",
},
};
export const Compact: Story = {
args: {
origin: "cloud",
compact: true,
},
};
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
import { FolderRecord } from "@app/types/folder";
const folder: FolderRecord = {
id: "folder-1" as FolderRecord["id"],
name: "Contracts",
parentFolderId: null,
color: "#3b82f6",
icon: "star",
createdAt: Date.now(),
updatedAt: Date.now(),
};
const meta = {
title: "FilesPage/FolderAppearancePicker",
component: FolderAppearancePicker,
parameters: { layout: "padded" },
args: {
folder,
onChange: fn(),
},
} satisfies Meta<typeof FolderAppearancePicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const NoAppearanceSet: Story = {
args: {
folder: {
...folder,
color: undefined,
icon: undefined,
},
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
const meta = {
title: "FilesPage/FolderNameDialog",
component: FolderNameDialog,
} satisfies Meta<typeof FolderNameDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
opened: true,
title: "New folder",
submitLabel: "Create",
onClose: () => {},
onSubmit: () => {},
},
};
export const Rename: Story = {
args: {
opened: true,
title: "Rename folder",
initialName: "Invoices 2026",
submitLabel: "Save",
onClose: () => {},
onSubmit: () => {},
},
};
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
const meta = {
title: "FilesPage/FolderThumbnail",
component: FolderThumbnail,
} satisfies Meta<typeof FolderThumbnail>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
color: "#6366f1",
fileCount: 12,
},
};
export const RowSize: Story = {
args: {
color: "#22c55e",
fileCount: 3,
size: "row",
},
};
export const WithIconGlyph: Story = {
args: {
color: "#f97316",
fileCount: 5,
iconGlyph: "📄",
},
};
export const Empty: Story = {
args: {
size: "thumb",
},
};
@@ -0,0 +1,51 @@
import type React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar";
import { FileContextProvider } from "@app/contexts/FileContext";
import { FolderProvider } from "@app/contexts/FolderContext";
import { FilesPageProvider } from "@app/contexts/FilesPageContext";
import { ROOT_FOLDER_ID } from "@app/types/folder";
/**
* FolderTreeSidebar reads the folder tree and active tab from FolderContext /
* FilesPageContext, neither of which is part of the shared preview
* decorators. Both providers also pull in IndexedDBContext (via
* FileContextProvider) further up the tree, so all three are stood up here.
* No folders are seeded into IndexedDB, so the tree renders with just the
* pinned "All files" / "Local" rows - an accurate empty state.
*/
function withFolderContexts(Story: () => React.JSX.Element) {
return (
<FileContextProvider>
<FolderProvider>
<FilesPageProvider>
<Story />
</FilesPageProvider>
</FolderProvider>
</FileContextProvider>
);
}
const meta = {
title: "FilesPage/FolderTreeSidebar",
component: FolderTreeSidebar,
decorators: [withFolderContexts],
args: {
fileCounts: new Map([[ROOT_FOLDER_ID, 0]]),
onRequestNewFolder: () => {},
onRenameFolder: () => {},
onDeleteFolder: () => {},
onMoveFilesIntoFolder: async () => {},
},
} satisfies Meta<typeof FolderTreeSidebar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithFileCounts: Story = {
args: {
fileCounts: new Map([[ROOT_FOLDER_ID, 12]]),
},
};
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
import { createFolderId, FolderRecord } from "@app/types/folder";
const workId = createFolderId();
const invoicesId = createFolderId();
const archivedId = createFolderId();
const folders: FolderRecord[] = [
{
id: workId,
name: "Work",
parentFolderId: null,
color: "#3b82f6",
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: invoicesId,
name: "Invoices",
parentFolderId: workId,
color: "#10b981",
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: archivedId,
name: "Archived",
parentFolderId: null,
color: "#f59e0b",
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
const meta = {
title: "FilesPage/MoveToFolderDialog",
component: MoveToFolderDialog,
} satisfies Meta<typeof MoveToFolderDialog>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
opened: true,
onClose: () => {},
folders,
onConfirm: () => {},
},
};
export const WithDisabledDescendant: Story = {
args: {
opened: true,
onClose: () => {},
folders,
disabledFolderId: workId,
onConfirm: () => {},
},
};
export const WithCreateFolder: Story = {
args: {
opened: true,
onClose: () => {},
folders,
onConfirm: () => {},
onCreateFolder: async (name, parentFolderId) => ({
id: createFolderId(),
name,
parentFolderId,
createdAt: Date.now(),
updatedAt: Date.now(),
}),
},
};
export const Empty: Story = {
args: {
opened: true,
onClose: () => {},
folders: [],
onConfirm: () => {},
},
};
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFile: StirlingFileStub = {
id: "file-1" as FileId,
name: "document.pdf",
type: "application/pdf",
size: 12345,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
};
/** Core-flavor stub: renders nothing (desktop-only menu item). */
const meta = {
title: "FilesPage/OpenInNewWindowMenuItem",
component: OpenInNewWindowMenuItem,
} satisfies Meta<typeof OpenInNewWindowMenuItem>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
file: mockFile,
},
};
@@ -0,0 +1,64 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFile: StirlingFileStub = {
id: "file-3" as FileId,
name: "report-watermarked.pdf",
type: "application/pdf",
size: 110592,
lastModified: 0,
isLeaf: true,
originalFileId: "file-1" as FileId,
versionNumber: 3,
};
/**
* The modal loads its version chain from IndexedDB (empty in Storybook) and
* dispatches file/navigation actions on add-to-workspace and remove, so it
* needs FileContext (also supplies IndexedDBContext) and the tool-registry-
* backed NavigationContext mounted above it.
*/
function withProviders(Story: () => ReactElement) {
return (
<FileContextProvider>
<ToolRegistryProvider>
<NavigationProvider>
<Story />
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
);
}
const meta = {
title: "FilesPage/VersionHistoryModal",
component: VersionHistoryModal,
decorators: [withProviders],
args: {
onClose: () => {},
},
} satisfies Meta<typeof VersionHistoryModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** No prior versions exist in the (empty) storage, so the modal shows the empty state. */
export const Default: Story = {
args: {
opened: true,
file: mockFile,
},
};
export const NoFileSelected: Story = {
args: {
opened: true,
file: null,
},
};
@@ -0,0 +1,98 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { VersionTimeline } from "@app/components/filesPage/VersionTimeline";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId, ToolOperation } from "@app/types/file";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 1024,
lastModified: 0,
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
...overrides,
});
const toolOp = (toolId: ToolOperation["toolId"]): ToolOperation => ({
toolId,
timestamp: 0,
});
const shortChain: StirlingFileStub[] = [
buildFileStub({
id: "file-1" as FileId,
name: "report.pdf",
size: 204800,
versionNumber: 1,
}),
buildFileStub({
id: "file-2" as FileId,
name: "report-compressed.pdf",
size: 102400,
versionNumber: 2,
parentFileId: "file-1" as FileId,
toolHistory: [toolOp("compress")],
}),
buildFileStub({
id: "file-3" as FileId,
name: "report-watermarked.pdf",
size: 110592,
versionNumber: 3,
parentFileId: "file-2" as FileId,
toolHistory: [toolOp("compress"), toolOp("watermark")],
}),
];
const longChain: StirlingFileStub[] = Array.from({ length: 9 }, (_, index) => {
const versionNumber = index + 1;
const toolHistory: ToolOperation[] =
versionNumber === 1
? []
: [toolOp(versionNumber % 2 === 0 ? "compress" : "watermark")];
return buildFileStub({
id: `file-${versionNumber}` as FileId,
name: `report-v${versionNumber}.pdf`,
size: 100000 + versionNumber * 1024,
versionNumber,
parentFileId:
versionNumber > 1 ? (`file-${versionNumber - 1}` as FileId) : undefined,
toolHistory,
});
});
const meta = {
title: "FilesPage/VersionTimeline",
component: VersionTimeline,
args: {
onAddToWorkspace: () => {},
onRemove: () => {},
},
} satisfies Meta<typeof VersionTimeline>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
chain: shortChain,
currentId: "file-3" as FileId,
},
};
export const NoHeader: Story = {
args: {
chain: shortChain,
currentId: "file-3" as FileId,
hideHeader: true,
},
};
export const LongChainCollapsed: Story = {
args: {
chain: longChain,
currentId: "file-9" as FileId,
},
};
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
// The shared preview only loads the portal tokens; the shell reads the editor
// theme tokens (--bg-surface, --onboarding-title, …), so load them here or the
// card renders transparent over the dark overlay.
import "@app/styles/theme.css";
import OnboardingSlideShell, {
ShellHero,
type ShellButton,
} from "@app/components/onboarding/OnboardingSlideShell";
const meta = {
title: "Onboarding/Slide Shell",
component: OnboardingSlideShell,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof OnboardingSlideShell>;
export default meta;
type Story = StoryObj<typeof meta>;
const BUTTONS: ShellButton[] = [
{ key: "back", back: true, action: "back" },
{ key: "skip", label: "Skip", action: "skip" },
{ key: "next", label: "Next", primary: true, action: "next" },
];
/** A single standalone card — no step count, so no progress bar or pill. */
export const Default: Story = {
args: {
hero: <ShellHero appIcon />,
slideKey: "default",
title: "Welcome to Stirling PDF",
body: "Everything you need to view, edit and manage your PDFs in one place.",
stepIndex: 0,
stepCount: 1,
buttons: [{ key: "next", label: "Next", primary: true, action: "next" }],
onAction: () => {},
onClose: () => {},
},
};
/** Mid-flow step: shows the step pill + progress bar, plus a back control. */
export const SteppedWithBack: Story = {
args: {
hero: <ShellHero>2</ShellHero>,
slideKey: "stepped",
title: "Choose your role",
body: "This helps us tailor the tools you see first.",
stepIndex: 2,
stepCount: 5,
buttons: BUTTONS,
onAction: () => {},
onClose: () => {},
},
};
/** Dismiss (close button + escape-to-close) disabled — used for mandatory
* steps like a forced first-login password change. */
export const NotDismissible: Story = {
args: {
hero: <ShellHero appIcon />,
slideKey: "mandatory",
title: "Set a new password",
body: "You're using the default password — choose a new one to continue.",
stepIndex: 0,
stepCount: 3,
buttons: [
{
key: "next",
label: "Continue",
primary: true,
action: "next",
disabled: true,
},
],
onAction: () => {},
onClose: () => {},
allowDismiss: false,
},
};
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { OnboardingStepper } from "@app/components/onboarding/OnboardingStepper";
const meta = {
title: "Onboarding/OnboardingStepper",
component: OnboardingStepper,
args: { totalSteps: 5, activeStep: 2 },
argTypes: {
totalSteps: { control: { type: "number", min: 1, max: 10 } },
activeStep: { control: { type: "number", min: 0, max: 9 } },
},
} satisfies Meta<typeof OnboardingStepper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { args: { totalSteps: 5, activeStep: 2 } };
export const FirstStep: Story = { args: { totalSteps: 5, activeStep: 0 } };
export const LastStep: Story = { args: { totalSteps: 5, activeStep: 4 } };
@@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { StepType } from "@reactour/tour";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
import "@app/components/onboarding/OnboardingTour.css";
/**
* Selectors go unused here since the target elements don't exist on the
* canvas, so reactour just centers the popover instead of anchoring to them.
*/
const SAMPLE_STEPS: StepType[] = [
{
selector: "body",
content: "Welcome to Stirling PDF! Let's take a quick look around.",
},
{
selector: "body",
content: "Here you can upload and manage your <strong>files</strong>.",
},
{
selector: "body",
content: "This is the tools panel where you apply operations to a PDF.",
},
];
function TourStage(props: {
tourType?: string;
isRTL?: boolean;
dimBackground?: boolean;
}) {
const { t } = useTranslation();
return (
<OnboardingTour
tourSteps={SAMPLE_STEPS}
tourType={props.tourType ?? "welcome"}
isRTL={props.isRTL ?? false}
t={t}
isOpen={true}
onAdvance={({ setCurrentStep, currentStep, steps }) => {
const isLast = currentStep === (steps?.length ?? 0) - 1;
if (isLast) return;
setCurrentStep((prev) => prev + 1);
}}
onClose={({ setIsOpen }) => setIsOpen(false)}
dimBackground={props.dimBackground}
/>
);
}
const meta = {
title: "Onboarding/OnboardingTour",
component: TourStage,
} satisfies Meta<typeof TourStage>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Default tour, mask dimmed to 70% opacity. */
export const Default: Story = { args: {} };
/** Admin tour uses a dark mask class instead of the default dim mask. */
export const AdminTour: Story = { args: { tourType: "admin" } };
/** RTL layout swaps the "next" arrow direction. */
export const RTL: Story = { args: { isRTL: true } };
/** `dimBackground={false}` keeps the page fully visible behind the popover. */
export const NoDim: Story = { args: { dimBackground: false } };
@@ -0,0 +1,105 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
// The shared preview only loads the portal tokens; the onboarding modal reads
// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them
// here or the modal surface renders transparent over the dark overlay.
import "@app/styles/theme.css";
import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide";
import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig";
/**
* Renders the "interrupt" onboarding modals — slides shown outside the normal
* step flow (analytics consent, first-login password change, MFA setup, the
* external server-license notice) — each with dismissal disabled, since none
* of these can be skipped by the user.
*/
const meta = {
title: "Onboarding/Static Onboarding Slide",
component: StaticOnboardingSlide,
// Excluded from the automated (Vitest browser) test run: some slides fetch
// remote assets/endpoints (analytics, licensing) that aren't served in the
// headless scan, so they 404 and reject. Still renders in the Storybook UI
// for manual review.
tags: ["!test"],
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof StaticOnboardingSlide>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Opt-in analytics choice, shown once after first login. */
export const AnalyticsChoice: Story = {
args: {
slideId: "analytics-choice",
runtimeState: DEFAULT_RUNTIME_STATE,
params: { analyticsError: null, analyticsLoading: false },
onSkip: () => {},
onAction: () => {},
allowDismiss: false,
},
};
/** Forced password change on first login with the default credentials. */
export const FirstLogin: Story = {
args: {
slideId: "first-login",
runtimeState: {
...DEFAULT_RUNTIME_STATE,
requiresPasswordChange: true,
firstLoginUsername: "admin",
usingDefaultCredentials: true,
},
params: {
firstLoginUsername: "admin",
onPasswordChanged: () => {},
usingDefaultCredentials: true,
},
onSkip: () => {},
onAction: () => {},
allowDismiss: false,
},
};
/** Two-factor setup, triggered when the account requires MFA. */
export const MfaSetup: Story = {
args: {
slideId: "mfa-setup",
runtimeState: { ...DEFAULT_RUNTIME_STATE, requiresMfaSetup: true },
params: { onMfaSetupComplete: () => {} },
onSkip: () => {},
onAction: () => {},
allowDismiss: false,
},
};
/** External license notice — the back button is stripped via
* `transformButtons` since there's no prior slide to return to. */
export const ServerLicenseNotice: Story = {
args: {
slideId: "server-license",
transformButtons: (buttons) =>
buttons.filter((btn) => btn.key !== "license-back"),
runtimeState: {
...DEFAULT_RUNTIME_STATE,
licenseNotice: {
totalUsers: 12,
freeTierLimit: 5,
isOverLimit: true,
requiresLicense: true,
},
},
params: {
osOptions: [],
onDownloadUrlChange: () => {},
licenseNotice: {
totalUsers: 12,
freeTierLimit: 5,
isOverLimit: true,
requiresLicense: true,
},
loginEnabled: true,
},
onSkip: () => {},
onAction: () => {},
allowDismiss: false,
},
};
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide";
// AnalyticsChoiceSlide is a slide-content factory (returns a SlideConfig), not
// a component, so wrap it to render its `body` the way the real onboarding
// shell does.
function AnalyticsChoiceSlideDemo({
analyticsError = null,
}: {
analyticsError?: string | null;
}) {
const slide = AnalyticsChoiceSlide({ analyticsError });
return (
<div
style={{
maxWidth: 420,
padding: 24,
borderRadius: 12,
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
}}
>
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
{slide.body}
</div>
);
}
const meta = {
title: "Onboarding/AnalyticsChoiceSlide",
component: AnalyticsChoiceSlideDemo,
} satisfies Meta<typeof AnalyticsChoiceSlideDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { args: {} };
export const WithError: Story = {
args: { analyticsError: "Failed to save your analytics preference." },
};
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import type { AnimatedCircleConfig } from "@app/types/types";
const circles: AnimatedCircleConfig[] = [
{
size: 320,
color: "rgba(255, 255, 255, 0.4)",
position: "bottom-left",
blur: 40,
},
{
size: 220,
color: "rgba(255, 255, 255, 0.3)",
position: "top-right",
opacity: 0.6,
blur: 30,
},
];
const meta = {
title: "Onboarding/AnimatedSlideBackground",
component: AnimatedSlideBackground,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof AnimatedSlideBackground>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
gradientStops: ["#6E56CF", "#3B82F6"],
circles,
isActive: true,
slideKey: "welcome",
},
};
export const Inactive: Story = {
args: {
...Default.args,
isActive: false,
},
};
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
DesktopInstallTitle,
type OSOption,
} from "@app/components/onboarding/slides/DesktopInstallTitle";
const OS_OPTIONS: OSOption[] = [
{ label: "macOS (Apple Silicon)", url: "#mac-arm", value: "mac-arm" },
{ label: "macOS (Intel)", url: "#mac-intel", value: "mac-intel" },
{ label: "Windows", url: "#windows", value: "windows" },
{ label: "Linux", url: "#linux", value: "linux" },
];
const meta = {
title: "Onboarding/Slides/DesktopInstallTitle",
component: DesktopInstallTitle,
} satisfies Meta<typeof DesktopInstallTitle>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Multiple OS options: title plus a dropdown to switch the download target. */
export const Default: Story = {
args: {
osLabel: "macOS (Apple Silicon)",
osUrl: "#mac-arm",
osOptions: OS_OPTIONS,
onDownloadUrlChange: () => {},
},
};
/** A single detected OS collapses to plain text — no dropdown affordance. */
export const SingleOption: Story = {
args: {
osLabel: "Windows",
osUrl: "#windows",
osOptions: [{ label: "Windows", url: "#windows", value: "windows" }],
onDownloadUrlChange: () => {},
},
};
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide";
// FirstLoginSlide is a slide-content factory (returns a SlideConfig), not a
// component, so wrap it to render its `body` the way the real onboarding shell
// does.
function FirstLoginSlideDemo({
username = "admin",
usingDefaultCredentials = false,
}: {
username?: string;
usingDefaultCredentials?: boolean;
}) {
const slide = FirstLoginSlide({
username,
onPasswordChanged: () => {},
usingDefaultCredentials,
});
return (
<div
style={{
maxWidth: 420,
padding: 24,
borderRadius: 12,
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
}}
>
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
{slide.body}
</div>
);
}
const meta = {
title: "Onboarding/FirstLoginSlide",
component: FirstLoginSlideDemo,
} satisfies Meta<typeof FirstLoginSlideDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { args: { username: "admin" } };
export const UsingDefaultCredentials: Story = {
args: { username: "admin", usingDefaultCredentials: true },
};
@@ -0,0 +1,60 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide";
import type { LicenseNotice } from "@app/types/types";
interface PlanOverviewStageProps {
isAdmin: boolean;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
}
// PlanOverviewSlide returns a SlideConfig (title/body nodes plus background
// config) rather than JSX, so this stage has to render those pieces itself.
function PlanOverviewStage({
isAdmin,
licenseNotice,
loginEnabled,
}: PlanOverviewStageProps) {
const slide = PlanOverviewSlide({ isAdmin, licenseNotice, loginEnabled });
return (
<div style={{ maxWidth: 480, padding: 24 }}>
<h2>{slide.title}</h2>
<div>{slide.body}</div>
</div>
);
}
const meta = {
title: "Onboarding/Slides/PlanOverviewSlide",
component: PlanOverviewStage,
} satisfies Meta<typeof PlanOverviewStage>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Regular user overview — no admin controls, no free-tier notice. */
export const Default: Story = {
args: { isAdmin: false },
};
/** Admin overview with login mode already enabled. */
export const AdminLoginEnabled: Story = {
args: {
isAdmin: true,
loginEnabled: true,
licenseNotice: {
totalUsers: 3,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: false,
},
},
};
/** Admin overview before login mode is enabled — different body copy. */
export const AdminLoginDisabled: Story = {
args: {
isAdmin: true,
loginEnabled: false,
},
};
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ProcessorIntroSlide from "@app/components/onboarding/slides/ProcessorIntroSlide";
// ProcessorIntroSlide is a slide-content factory (returns a SlideConfig), not
// a component, so wrap it to render its `title`/`body` the way the real
// onboarding shell does.
function ProcessorIntroSlideDemo() {
const slide = ProcessorIntroSlide();
return (
<div
style={{
maxWidth: 420,
padding: 24,
borderRadius: 12,
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
}}
>
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
<div style={{ color: "#fff" }}>{slide.body}</div>
</div>
);
}
const meta = {
title: "Onboarding/ProcessorIntroSlide",
component: ProcessorIntroSlideDemo,
} satisfies Meta<typeof ProcessorIntroSlideDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { args: {} };
@@ -0,0 +1,46 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide";
// SecurityCheckSlide is a slide-content factory (returns a SlideConfig), not a
// component, so wrap it to render its `body` the way the real onboarding shell
// does, with local state standing in for the modal's role selection state.
function SecurityCheckSlideDemo({
initialRole = null,
}: {
initialRole?: "admin" | "user" | null;
}) {
const [selectedRole, setSelectedRole] = useState(initialRole);
const slide = SecurityCheckSlide({
selectedRole,
onRoleSelect: setSelectedRole,
});
return (
<div
style={{
maxWidth: 420,
padding: 24,
borderRadius: 12,
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
}}
>
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
{slide.body}
</div>
);
}
const meta = {
title: "Onboarding/SecurityCheckSlide",
component: SecurityCheckSlideDemo,
} satisfies Meta<typeof SecurityCheckSlideDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { args: {} };
export const AdminSelected: Story = { args: { initialRole: "admin" } };
export const UserSelected: Story = { args: { initialRole: "user" } };
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide";
import type { LicenseNotice } from "@app/types/types";
interface ServerLicenseStageProps {
licenseNotice?: LicenseNotice;
}
// ServerLicenseSlide is a factory that returns a SlideConfig (title/body nodes
// plus background config), not JSX itself, so this stage renders those pieces
// directly to preview the slide in isolation.
function ServerLicenseStage({ licenseNotice }: ServerLicenseStageProps) {
const slide = ServerLicenseSlide({ licenseNotice });
return (
<div
style={{
maxWidth: 480,
padding: 24,
borderRadius: 12,
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
}}
>
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
<div style={{ color: "#fff" }}>{slide.body}</div>
</div>
);
}
const meta = {
title: "Onboarding/Slides/ServerLicenseSlide",
component: ServerLicenseStage,
} satisfies Meta<typeof ServerLicenseStage>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Default free-tier notice — under the limit. */
export const Default: Story = {
args: {},
};
/** Under the free-tier limit with a known user count. */
export const UnderLimit: Story = {
args: {
licenseNotice: {
totalUsers: 3,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: false,
},
},
};
/** Over the free-tier limit — prompts to upgrade with a different gradient. */
export const OverLimit: Story = {
args: {
licenseNotice: {
totalUsers: 12,
freeTierLimit: 5,
isOverLimit: true,
requiresLicense: true,
},
},
};
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
// WelcomeSlide is a slide-content factory (returns a SlideConfig), not
// a component, so wrap it to render its `title`/`body` the way the real
// onboarding shell does.
function WelcomeSlideDemo() {
const slide = WelcomeSlide();
return (
<div
style={{
maxWidth: 420,
padding: 24,
borderRadius: 12,
background: `linear-gradient(135deg, ${slide.background.gradientStops[0]}, ${slide.background.gradientStops[1]})`,
}}
>
<h2 style={{ color: "#fff" }}>{slide.title}</h2>
<div style={{ color: "#fff" }}>{slide.body}</div>
</div>
);
}
const meta = {
title: "Onboarding/WelcomeSlide",
component: WelcomeSlideDemo,
} satisfies Meta<typeof WelcomeSlideDemo>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { args: {} };
@@ -0,0 +1,112 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
interface MockGridItem {
id: string;
pageNumber?: number;
originalFileId?: string;
}
const buildItems = (count: number): MockGridItem[] =>
Array.from({ length: count }, (_, index) => ({
id: `page-${index + 1}`,
pageNumber: index + 1,
originalFileId: "file-1",
}));
const renderItem = (
item: MockGridItem,
index: number,
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
boxSelectedIds: string[],
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
zoomLevel?: number,
) => {
const { ref: dndRef, ...restDragProps } = dragHandleProps ?? {};
const isBoxSelected = boxSelectedIds.includes(item.id);
const isDragging = activeDragIds.includes(item.id);
return (
<div
ref={(element: HTMLDivElement | null) => {
if (element) {
refs.current.set(item.id, element);
} else {
refs.current.delete(item.id);
}
dndRef?.(element);
}}
{...restDragProps}
onClick={clearBoxSelection}
style={{
width: `calc(10rem * ${zoomLevel ?? 1})`,
height: `calc(13rem * ${zoomLevel ?? 1})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.5rem",
border: isBoxSelected
? "2px solid var(--mantine-color-blue-6)"
: "1px solid var(--mantine-color-gray-4)",
background: isDragging
? "var(--mantine-color-gray-1)"
: "var(--mantine-color-body)",
opacity: justMoved ? 0.7 : 1,
cursor: "grab",
}}
>
Page {item.pageNumber ?? index + 1}
</div>
);
};
const noopReorder = () => {};
const meta = {
title: "PageEditor/DragDropGrid",
component: DragDropGrid,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof DragDropGrid>;
export default meta;
type Story = StoryObj<typeof meta>;
const ScrollDecorator = (StoryComponent: React.ComponentType) => (
<div
data-scrolling-container="true"
style={{ height: "40rem", overflow: "auto" }}
>
<StoryComponent />
</div>
);
export const Default: Story = {
args: {
items: buildItems(8),
onReorderPages: noopReorder,
renderItem,
},
decorators: [ScrollDecorator],
};
export const Empty: Story = {
args: {
items: [],
onReorderPages: noopReorder,
renderItem,
},
decorators: [ScrollDecorator],
};
export const Zoomed: Story = {
args: {
items: buildItems(6),
onReorderPages: noopReorder,
renderItem,
zoomLevel: 1.5,
},
decorators: [ScrollDecorator],
};
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import PageEditorControls from "@app/components/pageEditor/PageEditorControls";
const meta = {
title: "PageEditor/PageEditorControls",
component: PageEditorControls,
parameters: { layout: "padded" },
} satisfies Meta<typeof PageEditorControls>;
export default meta;
type Story = StoryObj<typeof meta>;
const baseArgs = {
onClosePdf: () => {},
onUndo: () => {},
onRedo: () => {},
canUndo: true,
canRedo: true,
onRotate: () => {},
onDelete: () => {},
onSplit: () => {},
onSplitAll: () => {},
onPageBreak: () => {},
onPageBreakAll: () => {},
onExportAll: () => {},
exportLoading: false,
selectionMode: true,
selectedPageIds: ["page-1", "page-2"],
displayDocument: {
pages: [
{ id: "page-1", pageNumber: 1 },
{ id: "page-2", pageNumber: 2 },
{ id: "page-3", pageNumber: 3 },
],
},
splitPositions: new Set<string>(),
totalPages: 3,
};
export const Default: Story = {
args: baseArgs,
};
export const NoSelection: Story = {
args: {
...baseArgs,
selectedPageIds: [],
canUndo: false,
canRedo: false,
},
};
export const WithExistingSplits: Story = {
args: {
...baseArgs,
splitPositions: new Set<string>(["page-1", "page-2"]),
},
};
@@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import AdvancedSelectionPanel from "@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel";
const meta = {
title: "PageEditor/BulkSelectionPanel/AdvancedSelectionPanel",
component: AdvancedSelectionPanel,
} satisfies Meta<typeof AdvancedSelectionPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
csvInput: "",
setCsvInput: () => {},
onUpdatePagesFromCSV: () => {},
maxPages: 20,
advancedOpened: true,
},
};
export const WithExpression: Story = {
args: {
csvInput: "1-5, odd",
setCsvInput: () => {},
onUpdatePagesFromCSV: () => {},
maxPages: 20,
advancedOpened: true,
},
};
export const Closed: Story = {
args: {
csvInput: "",
setCsvInput: () => {},
onUpdatePagesFromCSV: () => {},
maxPages: 20,
advancedOpened: false,
},
};
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import OperatorsSection from "@app/components/pageEditor/bulkSelectionPanel/OperatorsSection";
const meta = {
title: "PageEditor/BulkSelectionPanel/OperatorsSection",
component: OperatorsSection,
} satisfies Meta<typeof OperatorsSection>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
csvInput: "1,2,3",
onInsertOperator: (op) => console.log("insert operator", op),
},
};
export const EmptyInput: Story = {
args: {
csvInput: "",
onInsertOperator: (op) => console.log("insert operator", op),
},
};
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import SelectPages from "@app/components/pageEditor/bulkSelectionPanel/SelectPages";
const meta = {
title: "PageEditor/SelectPages",
component: SelectPages,
} satisfies Meta<typeof SelectPages>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Select pages",
placeholder: "Page number",
onApply: (value: number) => console.log("apply", value),
maxPages: 10,
},
};
export const WithValidation: Story = {
args: {
title: "Select pages",
placeholder: "Page number",
onApply: (value: number) => console.log("apply", value),
maxPages: 10,
validationFn: (value: number) =>
value > 10 ? "Page number exceeds document length" : null,
},
};
export const Range: Story = {
args: {
title: "Select page range",
placeholder: "Start page",
onApply: (value: number) => console.log("apply", value),
maxPages: 10,
isRange: true,
rangeEndValue: 5,
onRangeEndChange: (value: string | number) =>
console.log("range end", value),
rangeEndPlaceholder: "End page",
},
};
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import SelectedPagesDisplay from "@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay";
const displayDocument = {
pages: [
{ id: "page-1", pageNumber: 1 },
{ id: "page-2", pageNumber: 2 },
{ id: "page-3", pageNumber: 3 },
{ id: "page-4", pageNumber: 4 },
],
};
const meta = {
title: "PageEditor/SelectedPagesDisplay",
component: SelectedPagesDisplay,
} satisfies Meta<typeof SelectedPagesDisplay>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
selectedPageIds: ["page-1", "page-3"],
displayDocument,
syntaxError: null,
},
};
export const SyntaxError: Story = {
args: {
selectedPageIds: ["page-1", "page-3"],
displayDocument,
syntaxError: "Invalid page range: 1-abc",
},
};
@@ -0,0 +1,63 @@
import { useState } from "react";
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import AllToolsNavButton from "@app/components/shared/AllToolsNavButton";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
/**
* The button reads/writes tool selection and panel state via ToolWorkflowContext,
* and derives the home link href from NavigationContext plus the tool registry —
* all four providers must be present for it to render.
*/
function withProviders(Story: () => ReactElement) {
return (
<PreferencesProvider>
<ToolRegistryProvider>
<NavigationProvider>
<ToolWorkflowProvider>
<Story />
</ToolWorkflowProvider>
</NavigationProvider>
</ToolRegistryProvider>
</PreferencesProvider>
);
}
const meta = {
title: "Shared/AllToolsNavButton",
component: AllToolsNavButton,
decorators: [withProviders],
args: {
activeButton: "tools",
setActiveButton: () => {},
},
} satisfies Meta<typeof AllToolsNavButton>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Highlighted when it is the active quick-access button. */
export const Default: Story = {
args: {
activeButton: "tools",
setActiveButton: () => {},
},
};
function InactiveDemo() {
const [activeButton, setActiveButton] = useState("home");
return (
<AllToolsNavButton
activeButton={activeButton}
setActiveButton={setActiveButton}
/>
);
}
/** Not the active button — a different quick-access item is selected. */
export const Inactive: Story = {
render: () => <InactiveDemo />,
};
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AppSwitch } from "@app/components/shared/AppSwitch";
/** The editor ⇄ processor app switcher rendered by both the editor and portal sidebars. */
const meta: Meta<typeof AppSwitch> = {
title: "Shared/AppSwitch",
component: AppSwitch,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Editor: Story = {
args: {
current: "editor",
theme: "light",
onSwitch: () => {},
},
};
export const Processor: Story = {
args: {
current: "processor",
theme: "light",
onSwitch: () => {},
},
};
export const DarkTheme: Story = {
args: {
current: "editor",
theme: "dark",
onSwitch: () => {},
},
};
@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactElement } from "react";
import BulkShareModal from "@app/components/shared/BulkShareModal";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { FileContextProvider } from "@app/contexts/FileContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFiles: StirlingFileStub[] = [
{
id: "story-file-1" as FileId,
name: "quarterly-report.pdf",
type: "application/pdf",
size: 2_400_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "story-file-1",
versionNumber: 1,
},
{
id: "story-file-2" as FileId,
name: "cover-letter.pdf",
type: "application/pdf",
size: 180_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "story-file-2",
versionNumber: 1,
},
];
/**
* BulkShareModal reads useFileActions() from FileContext, which isn't part of
* the shared preview decorators, so it's provided here for every story.
* AppConfig (gating share links on `storageShareLinksEnabled`) is added
* per-story instead, since Default and LinksEnabled need different values.
*/
function withFileContext(Story: () => ReactElement) {
return (
<FileContextProvider>
<Story />
</FileContextProvider>
);
}
const meta = {
title: "Shared/BulkShareModal",
component: BulkShareModal,
parameters: { layout: "fullscreen" },
args: {
opened: true,
onClose: () => {},
files: mockFiles,
},
decorators: [withFileContext],
} satisfies Meta<typeof BulkShareModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Share links disabled by server config — default when no config is loaded. */
export const Default: Story = {};
/** Share links enabled — the role selector and "Generate Link" action are active. */
export const LinksEnabled: Story = {
decorators: [
(Story) => (
<AppConfigProvider
initialConfig={{ storageShareLinksEnabled: true }}
autoFetch={false}
>
<Story />
</AppConfigProvider>
),
],
};
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactElement } from "react";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import { FileContextProvider } from "@app/contexts/FileContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFiles: StirlingFileStub[] = [
{
id: "file-1" as FileId,
name: "quarterly-report.pdf",
type: "application/pdf",
size: 2_400_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "file-1" as FileId,
versionNumber: 1,
},
{
id: "file-2" as FileId,
name: "invoice-march.pdf",
type: "application/pdf",
size: 512_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "file-2" as FileId,
versionNumber: 1,
},
];
/**
* The modal dispatches updateStirlingFileStub on upload, so it needs
* FileContext (also supplies IndexedDBContext) mounted above it.
*/
function withProviders(Story: () => ReactElement) {
return (
<FileContextProvider>
<Story />
</FileContextProvider>
);
}
const meta = {
title: "Shared/BulkUploadToServerModal",
component: BulkUploadToServerModal,
decorators: [withProviders],
args: {
onClose: () => {},
},
} satisfies Meta<typeof BulkUploadToServerModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
opened: true,
files: mockFiles,
},
};
export const SingleFile: Story = {
args: {
opened: true,
files: [mockFiles[0]],
},
};
@@ -0,0 +1,58 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
ButtonToggle,
ButtonToggleOption,
} from "@app/components/shared/ButtonToggle";
const meta: Meta<typeof ButtonToggle> = {
title: "Shared/ButtonToggle",
component: ButtonToggle,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ButtonToggle>;
const options: ButtonToggleOption[] = [
{
value: "automatic",
label: "Automatic",
description: "Detect and redact automatically",
},
{ value: "manual", label: "Manual", description: "Select regions yourself" },
];
function ToggleDemo({
disabled,
size,
}: {
disabled?: boolean;
size?: "xs" | "sm" | "md" | "lg";
}) {
const [value, setValue] = useState("automatic");
return (
<ButtonToggle
options={options}
value={value}
onChange={setValue}
disabled={disabled}
size={size}
/>
);
}
/** Default toggle with two options, each carrying a label + description. */
export const Default: Story = { render: () => <ToggleDemo /> };
/** Disabled state — the selected segment must still be legible. */
export const Disabled: Story = { render: () => <ToggleDemo disabled /> };
/** Small size variant. */
export const Small: Story = { render: () => <ToggleDemo size="sm" /> };
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
GoogleDriveIcon,
OneDriveIcon,
DropboxIcon,
} from "@app/components/shared/CloudStorageIcons";
/** Cloud storage brand icons with brand-color / muted current-color variants. */
const meta: Meta<typeof GoogleDriveIcon> = {
title: "Shared/CloudStorageIcons",
component: GoogleDriveIcon,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const GoogleDrive: Story = {
args: { colored: true },
};
export const GoogleDriveMuted: Story = {
args: { colored: false },
};
export const OneDrive: Story = {
render: (args) => <OneDriveIcon {...args} />,
args: { colored: true },
};
export const Dropbox: Story = {
render: (args) => <DropboxIcon {...args} />,
args: { colored: true },
};
@@ -0,0 +1,86 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@mantine/core";
import DropdownListWithFooter, {
DropdownItem,
} from "@app/components/shared/DropdownListWithFooter";
const meta: Meta<typeof DropdownListWithFooter> = {
title: "Shared/DropdownListWithFooter",
component: DropdownListWithFooter,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "22rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DropdownListWithFooter>;
const items: DropdownItem[] = [
{ value: "single", name: "Single page" },
{ value: "facing", name: "Facing pages" },
{ value: "book", name: "Book view" },
{ value: "continuous", name: "Continuous scroll", disabled: true },
];
function SingleSelectDemo() {
const [value, setValue] = useState("single");
return (
<DropdownListWithFooter
label="Page layout"
items={items}
value={value}
onChange={(v) => setValue(v as string)}
/>
);
}
function MultiSelectDemo() {
const [value, setValue] = useState<string[]>(["single"]);
return (
<DropdownListWithFooter
label="Page layouts"
items={items}
value={value}
onChange={(v) => setValue(v as string[])}
multiSelect
searchable
footer={
<Button
size="xs"
variant="subtle"
fullWidth
onClick={() => setValue([])}
>
Clear selection
</Button>
}
/>
);
}
/** Single-select dropdown with a disabled item. */
export const Default: Story = { render: () => <SingleSelectDemo /> };
/** Multi-select with search box and a footer action. */
export const MultiSelectWithFooter: Story = {
render: () => <MultiSelectDemo />,
};
/** No items available — empty state message inside the dropdown. */
export const Empty: Story = {
render: () => {
return (
<DropdownListWithFooter
label="Page layout"
items={[]}
value=""
onChange={() => {}}
/>
);
},
};
@@ -0,0 +1,61 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import EditableSecretField from "@app/components/shared/EditableSecretField";
const meta: Meta<typeof EditableSecretField> = {
title: "Shared/EditableSecretField",
component: EditableSecretField,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof EditableSecretField>;
function SecretFieldDemo({
initialValue = "",
...rest
}: {
initialValue?: string;
label?: string;
description?: string;
placeholder?: string;
disabled?: boolean;
error?: string;
}) {
const [value, setValue] = useState(initialValue);
return (
<EditableSecretField
label="API key"
description="Used to authenticate requests to the third-party service."
value={value}
onChange={setValue}
{...rest}
/>
);
}
/** Empty value: renders a normal password input. */
export const Default: Story = {
render: () => <SecretFieldDemo />,
};
/** Backend returned a masked value (********): shows a read-only display + Edit button. */
export const Masked: Story = {
render: () => <SecretFieldDemo initialValue="********" />,
};
/** Disabled state — the Edit button on a masked value must still read as inert. */
export const MaskedDisabled: Story = {
render: () => <SecretFieldDemo initialValue="********" disabled />,
};
/** Validation error surfaced under the password input. */
export const WithError: Story = {
render: () => <SecretFieldDemo error="Secret value is required." />,
};
@@ -0,0 +1,60 @@
import { useState, type ComponentProps } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal";
const meta = {
title: "Shared/EncryptedPdfUnlockModal",
component: EncryptedPdfUnlockModal,
args: {
opened: true,
password: "",
isProcessing: false,
remainingCount: 0,
onPasswordChange: () => {},
onUnlock: () => {},
onUnlockAll: () => {},
onSkip: () => {},
},
} satisfies Meta<typeof EncryptedPdfUnlockModal>;
export default meta;
type Story = StoryObj<typeof meta>;
function UnlockDemo(
props: Partial<ComponentProps<typeof EncryptedPdfUnlockModal>>,
) {
const [password, setPassword] = useState("");
return (
<EncryptedPdfUnlockModal
opened
fileName="contract-final.pdf"
password={password}
errorMessage={null}
isProcessing={false}
remainingCount={0}
onPasswordChange={setPassword}
onUnlock={() => {}}
onUnlockAll={() => {}}
onSkip={() => {}}
{...props}
/>
);
}
export const Default: Story = { render: () => <UnlockDemo /> };
export const MultipleFilesRemaining: Story = {
render: () => <UnlockDemo remainingCount={2} />,
};
export const IncorrectPassword: Story = {
render: () => (
<UnlockDemo
password="wrong-password"
errorMessage="Incorrect password. Please try again."
/>
),
};
export const Processing: Story = {
render: () => <UnlockDemo password="secret" isProcessing />,
};
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Text } from "@mantine/core";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
const meta: Meta<typeof ErrorBoundary> = {
title: "Shared/ErrorBoundary",
component: ErrorBoundary,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof ErrorBoundary>;
function ThrowingChild(): never {
throw new Error("Simulated render error for Storybook");
}
/** Normal path — children render untouched when nothing throws. */
export const Default: Story = {
args: {
children: <Text>Protected content renders normally.</Text>,
},
};
/** A child throwing during render is caught, showing the default fallback with a retry button. */
export const CaughtError: Story = {
args: {
children: <ThrowingChild />,
},
};
/** A custom fallback component receives the error and a retry callback. */
export const CustomFallback: Story = {
args: {
children: <ThrowingChild />,
fallback: ({ error, retry }) => (
<Text c="red">
Custom fallback: {error?.message}
<button onClick={retry} style={{ marginLeft: 8 }}>
Retry
</button>
</Text>
),
},
};
@@ -0,0 +1,65 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileCard from "@app/components/shared/FileCard";
import { FileContextProvider } from "@app/contexts/FileContext";
import { StirlingFileStub, FileId } from "@app/types/fileContext";
function makeFile(name: string, type = "application/pdf"): File {
return new File(["%PDF-1.4 storybook fixture"], name, {
type,
lastModified: Date.now(),
});
}
function makeStub(id: string): StirlingFileStub {
return {
id: id as FileId,
name: "Annual-Report-2026.pdf",
type: "application/pdf",
size: 245_760,
lastModified: Date.now(),
isLeaf: true,
originalFileId: id,
versionNumber: 1,
};
}
/** FileCard reads/writes files via FileContext + IndexedDB, so it needs a real provider tree. */
const meta = {
title: "Shared/FileCard",
component: FileCard,
parameters: { layout: "padded" },
decorators: [
(Story) => (
<FileContextProvider>
<Story />
</FileContextProvider>
),
],
} satisfies Meta<typeof FileCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
file: makeFile("Annual-Report-2026.pdf"),
fileStub: makeStub("story-file-1"),
onRemove: () => {},
onView: () => {},
onEdit: () => {},
},
};
export const Selected: Story = {
args: {
...Default.args,
isSelected: true,
onSelect: () => {},
},
};
export const Unsupported: Story = {
args: {
...Default.args,
isSupported: false,
},
};
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileDocIcon } from "@app/components/shared/FileDocIcon";
const meta = {
title: "Shared/FileDocIcon",
component: FileDocIcon,
parameters: { layout: "padded" },
args: { variant: "pdf" },
} satisfies Meta<typeof FileDocIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { variant: "pdf" },
};
/** All file-type variants, each using its own default accent color. */
export const AllVariants: Story = {
render: () => (
<div style={{ display: "flex", gap: "1.5rem", alignItems: "center" }}>
<FileDocIcon variant="pdf" />
<FileDocIcon variant="spreadsheet" />
<FileDocIcon variant="doc" />
<FileDocIcon variant="image" />
<FileDocIcon variant="archive" />
<FileDocIcon variant="code" />
<FileDocIcon variant="generic" />
</div>
),
};
/** Explicit `color` overrides the variant's default accent. */
export const CustomColor: Story = {
args: { variant: "pdf", color: "#e64980" },
};
@@ -0,0 +1,49 @@
import type { CSSProperties } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileDropdownMenu } from "@app/components/shared/FileDropdownMenu";
const viewOptionStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.25rem 0.5rem",
};
const activeFiles = [
{ fileId: "file-1", name: "Contract-Draft-v1.pdf" },
{ fileId: "file-2", name: "Invoice-2026-04.pdf", versionNumber: 2 },
{ fileId: "file-3", name: "Scanned-Document-With-A-Very-Long-Name.pdf" },
];
const meta: Meta<typeof FileDropdownMenu> = {
title: "Shared/FileDropdownMenu",
component: FileDropdownMenu,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
displayName: "Contract-Draft-v1.pdf",
activeFiles,
currentFileIndex: 0,
viewOptionStyle,
onFileSelect: () => {},
onFileRemove: () => {},
},
};
export const Switching: Story = {
args: {
...Default.args,
switchingTo: "viewer",
},
};
export const NoRemove: Story = {
args: {
...Default.args,
onFileRemove: undefined,
},
};
@@ -0,0 +1,104 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileGrid from "@app/components/shared/FileGrid";
import { FileContextProvider } from "@app/contexts/FileContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
/**
* FileGrid renders FileCard entries, which call useFileThumbnail ->
* useIndexedDBThumbnail. That hook reads IndexedDBContext + FileContext,
* neither of which is part of the shared preview decorators, so
* FileContextProvider (which wraps IndexedDBProvider internally) is stood up
* here.
*/
function withFileContext(Story: () => ReactElement) {
return (
<FileContextProvider>
<Story />
</FileContextProvider>
);
}
const buildFile = (name: string, size: number, type: string): File => {
return new File([new Uint8Array(size)], name, {
type,
lastModified: Date.now(),
});
};
const buildRecord = (
id: string,
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: id as FileId,
name: overrides.name ?? "report.pdf",
type: overrides.type ?? "application/pdf",
size: overrides.size ?? 1_240_000,
lastModified: overrides.lastModified ?? Date.now(),
isLeaf: true,
originalFileId: id,
versionNumber: 1,
// Set so useLazyThumbnail short-circuits on the stored thumbnail instead of
// trying to read file bytes out of IndexedDB.
thumbnailUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='160'%3E%3Crect width='120' height='160' fill='%23e9ecef'/%3E%3C/svg%3E",
...overrides,
});
const files = [
{
file: buildFile("report.pdf", 1_240_000, "application/pdf"),
record: buildRecord("file-1", { name: "report.pdf" }),
},
{
file: buildFile("invoice.pdf", 540_000, "application/pdf"),
record: buildRecord("file-2", {
name: "invoice.pdf",
size: 540_000,
}),
},
{
file: buildFile(
"budget.xlsx",
82_000,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
),
record: buildRecord("file-3", {
name: "budget.xlsx",
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
size: 82_000,
thumbnailUrl: undefined,
}),
},
];
const meta = {
title: "Shared/FileGrid",
component: FileGrid,
decorators: [withFileContext],
args: {
files,
onRemove: () => {},
},
} satisfies Meta<typeof FileGrid>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const SearchAndSort: Story = {
args: {
showSearch: true,
showSort: true,
onDeleteAll: () => {},
},
};
export const Empty: Story = {
args: {
files: [],
showSearch: true,
},
};
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import FilePickerModal from "@app/components/shared/FilePickerModal";
const mockStoredFiles = [
{ id: "file-1", name: "invoice.pdf", size: 245_000, thumbnail: null },
{
id: "file-2",
name: "contract-draft.pdf",
size: 1_240_000,
thumbnail: null,
},
{ id: "file-3", name: "scanned-form.pdf", size: 3_400_000, thumbnail: null },
];
const meta = {
title: "Shared/FilePickerModal",
component: FilePickerModal,
parameters: { layout: "padded" },
args: {
opened: true,
onClose: () => {},
onSelectFiles: () => {},
storedFiles: mockStoredFiles,
},
} satisfies Meta<typeof FilePickerModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Storage populated with a few files available to pick from. */
export const Default: Story = {};
/** No files exist in storage yet — shows the empty-state message. */
export const Empty: Story = {
args: {
storedFiles: [],
},
};
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import FilePreview from "@app/components/shared/FilePreview";
import { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFile: StirlingFileStub = {
id: "file-1" as FileId,
name: "annual-report.pdf",
type: "application/pdf",
size: 245_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
};
const meta = {
title: "Shared/FilePreview",
component: FilePreview,
parameters: { layout: "padded" },
decorators: [
(Story) => (
<div style={{ width: "12rem", height: "12rem" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof FilePreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
file: mockFile,
thumbnail: null,
},
};
export const Empty: Story = {
args: {
file: null,
},
};
export const WithNavigation: Story = {
args: {
file: mockFile,
thumbnail: null,
showStacking: true,
showHoverOverlay: true,
showNavigation: true,
totalFiles: 3,
onFileClick: () => {},
onPrevious: () => {},
onNext: () => {},
},
};
@@ -0,0 +1,48 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FileSelectorPicker } from "@app/components/shared/FileSelectorPicker";
import { FileContextProvider } from "@app/contexts/FileContext";
/**
* Reads from FileContext (workbench files) and IndexedDBContext (persisted
* saved files) further up the tree — neither is part of the shared preview
* decorators, so FileContextProvider (which also wraps IndexedDBProvider) is
* stood up here. The popover starts closed, so no IndexedDB read happens
* until a story interacts with it.
*/
function withFileContext(Story: () => ReactElement) {
return (
<FileContextProvider>
<div style={{ maxWidth: "16rem" }}>
<Story />
</div>
</FileContextProvider>
);
}
const meta = {
title: "Shared/FileSelectorPicker",
component: FileSelectorPicker,
parameters: { layout: "padded" },
args: {
onSelect: () => {},
},
decorators: [withFileContext],
} satisfies Meta<typeof FileSelectorPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const CustomPlaceholder: Story = {
args: {
placeholder: "Choose a comparison file",
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};
@@ -0,0 +1,56 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import FileUploadButton from "@app/components/shared/FileUploadButton";
const meta: Meta<typeof FileUploadButton> = {
title: "Shared/FileUploadButton",
component: FileUploadButton,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "22rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof FileUploadButton>;
function UploadDemo({
initialFile,
...rest
}: {
initialFile?: File;
disabled?: boolean;
accept?: string;
placeholder?: string;
}) {
const [file, setFile] = useState<File | undefined>(initialFile);
return (
<FileUploadButton
file={file}
onChange={(next) => setFile(next ?? undefined)}
{...rest}
/>
);
}
/** No file chosen yet — shows the default "Choose File" placeholder. */
export const Default: Story = { render: () => <UploadDemo /> };
/** A file has already been selected — the button shows its name. */
export const WithFileSelected: Story = {
render: () => (
<UploadDemo
initialFile={
new File(["dummy content"], "document.pdf", { type: "application/pdf" })
}
/>
),
};
/** Disabled state — should still be legible but non-interactive. */
export const Disabled: Story = {
render: () => <UploadDemo disabled />,
};
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import FirstLoginModal from "@app/components/shared/FirstLoginModal";
const meta = {
title: "Shared/FirstLoginModal",
component: FirstLoginModal,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof FirstLoginModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
opened: true,
username: "jane.doe",
onPasswordChanged: () => {},
},
};
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import FitText from "@app/components/shared/FitText";
const meta: Meta<typeof FitText> = {
title: "Shared/FitText",
component: FitText,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "12rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Single-line text that shrinks its font size to fit the available width. */
export const Default: Story = {
args: {
text: "Invoice_2026_Quarterly_Report.pdf",
},
};
/** Multi-line clamp with soft-break hints inserted after '/', '-' and '_'. */
export const MultiLine: Story = {
args: {
text: "path/to/some-very/long_document/name_that_needs_multiple_lines.pdf",
lines: 3,
},
};
/** Explicit font size (rem) with a lower minimum shrink scale. */
export const CustomFontSize: Story = {
args: {
text: "Custom Sized Label",
fontSize: 1.5,
minimumFontScale: 0.5,
},
};
@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import Footer from "@app/components/shared/Footer";
const meta = {
title: "Shared/Footer",
component: Footer,
parameters: { layout: "padded" },
} satisfies Meta<typeof Footer>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Defaults: no overrides supplied, only the always-present links render. */
export const Default: Story = {
args: {},
};
/** All optional legal links populated, plus the cookie preferences button. */
export const AllLinksAndCookieBanner: Story = {
args: {
privacyPolicy: "https://example.com/privacy",
termsAndConditions: "https://example.com/terms",
accessibilityStatement: "https://example.com/accessibility",
cookiePolicy: "https://example.com/cookies",
impressum: "https://example.com/impressum",
analyticsEnabled: true,
},
};
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import HoverActionMenu, {
type HoverAction,
} from "@app/components/shared/HoverActionMenu";
import { iconMap } from "@app/components/tools/automate/iconMap";
const { EditIcon, DeleteIcon, DownloadIcon } = iconMap;
const actions: HoverAction[] = [
{
id: "edit",
icon: <EditIcon style={{ fontSize: 16 }} />,
label: "Edit",
onClick: () => {},
},
{
id: "download",
icon: <DownloadIcon style={{ fontSize: 16 }} />,
label: "Download",
onClick: () => {},
},
{
id: "delete",
icon: <DeleteIcon style={{ fontSize: 16 }} />,
label: "Delete",
onClick: () => {},
color: "var(--text-error)",
},
];
const meta: Meta<typeof HoverActionMenu> = {
title: "Shared/HoverActionMenu",
component: HoverActionMenu,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ position: "relative", width: "16rem", height: "4rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof HoverActionMenu>;
/** Visible menu with the standard edit/download/delete action set. */
export const Default: Story = {
args: {
show: true,
actions,
},
};
/** Hidden state (`show: false`) — menu stays mounted but faded/non-interactive. */
export const Hidden: Story = {
args: {
show: false,
actions,
},
};
/** One action disabled with a custom tooltip explaining why. */
export const WithDisabledAction: Story = {
args: {
show: true,
actions: [
actions[0],
actions[1],
{
...actions[2],
disabled: true,
tooltip: "Deletion is restricted by policy",
},
],
},
};
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoBanner } from "@app/components/shared/InfoBanner";
const meta = {
title: "Shared/InfoBanner",
component: InfoBanner,
parameters: { layout: "padded" },
} satisfies Meta<typeof InfoBanner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: "info-rounded",
title: "Heads up",
message: "This document contains form fields that will be flattened.",
},
};
export const Warning: Story = {
args: {
tone: "warning",
icon: "warning-rounded",
title: "Action required",
message: "Some pages could not be processed and were skipped.",
buttonText: "Review",
onButtonClick: () => {},
},
};
export const Compact: Story = {
args: {
compact: true,
icon: "info-rounded",
message: "Autosave is enabled for this file.",
dismissible: false,
},
};
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack";
/** Decorative stack only: window dots + grey bars — no props, no i18n. */
const meta: Meta<typeof LandingDocumentStack> = {
title: "Shared/LandingDocumentStack",
component: LandingDocumentStack,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LanguageSelector from "@app/components/shared/LanguageSelector";
const meta = {
title: "Shared/LanguageSelector",
component: LanguageSelector,
} satisfies Meta<typeof LanguageSelector>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Compact: Story = {
args: {
compact: true,
tooltip: "Change language",
},
};
export const TopStartPosition: Story = {
args: {
position: "top-start",
offset: 4,
},
};
@@ -0,0 +1,13 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
/** Full-screen splash shown while i18next Suspense is loading translations. */
const meta: Meta<typeof LoadingFallback> = {
title: "Shared/LoadingFallback",
component: LoadingFallback,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LocalIcon from "@app/components/shared/LocalIcon";
const meta: Meta<typeof LocalIcon> = {
title: "Shared/LocalIcon",
component: LocalIcon,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: "description",
width: "2rem",
height: "2rem",
},
};
export const NumericSize: Story = {
args: {
icon: "download",
width: 32,
},
};
export const WithFullCollectionPrefix: Story = {
args: {
icon: "material-symbols:error-rounded",
width: "1.5rem",
},
};
@@ -0,0 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import LoginAgreementModal from "@app/components/shared/LoginAgreementModal";
/**
* Renders nothing by default: the modal only opens after fetching
* `/api/v1/config/login-disclaimer` and finding it enabled, which requires a
* live AppConfigProvider/backend. In Storybook (no providers configured) the
* config stays null, so the effect bails out and the component stays hidden.
*/
const meta: Meta<typeof LoginAgreementModal> = {
title: "Shared/LoginAgreementModal",
component: LoginAgreementModal,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
const meta: Meta<typeof MobileUploadModal> = {
title: "Shared/MobileUploadModal",
component: MobileUploadModal,
parameters: { layout: "fullscreen" },
args: {
opened: true,
onClose: () => {},
onFilesReceived: () => {},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** QR code + instructions for scanning a file upload session from a phone. */
export const Default: Story = {};
/** Closed state — modal renders nothing visible. */
export const Closed: Story = {
args: {
opened: false,
},
};
@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import MultiSelectControls from "@app/components/shared/MultiSelectControls";
const meta: Meta<typeof MultiSelectControls> = {
title: "Shared/MultiSelectControls",
component: MultiSelectControls,
parameters: { layout: "padded" },
args: {
selectedCount: 3,
onClearSelection: () => {},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Only the always-present "Clear files" action, since none of the optional handlers are passed. */
export const Default: Story = {};
/** All optional actions supplied — every button in the group renders. */
export const AllActions: Story = {
args: {
onAddToUpload: () => {},
onOpenInFileEditor: () => {},
onOpenInPageEditor: () => {},
onDeleteAll: () => {},
},
};
/** Renders nothing when no files are selected. */
export const NoSelection: Story = {
args: {
selectedCount: 0,
},
};
@@ -0,0 +1,103 @@
import { useEffect, type ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import {
NavigationProvider,
useNavigationGuard,
type NavigationWarningHandlers,
} from "@app/contexts/NavigationContext";
/**
* The modal renders nothing until NavigationContext has unsaved changes AND a
* pending navigation to warn about, so this drives both into place on mount —
* mirroring what a real editor does when it calls requestNavigation() while
* hasUnsavedChanges is true. It also registers any warning handlers the story
* supplies, since the modal only shows the "Apply & Leave"/"Export & Leave"
* buttons when a handler for them is present.
*/
function TriggerWarning({
children,
handlers,
}: {
children: React.ReactNode;
handlers?: NavigationWarningHandlers;
}) {
const {
hasUnsavedChanges,
setHasUnsavedChanges,
requestNavigation,
registerNavigationWarningHandlers,
} = useNavigationGuard();
useEffect(() => {
setHasUnsavedChanges(true);
}, [setHasUnsavedChanges]);
useEffect(() => {
if (handlers) {
registerNavigationWarningHandlers(handlers);
}
}, [handlers, registerNavigationWarningHandlers]);
useEffect(() => {
if (hasUnsavedChanges) {
requestNavigation(() => {});
}
}, [hasUnsavedChanges, requestNavigation]);
return <>{children}</>;
}
function withProviders(
Story: () => ReactElement,
context: { parameters: { navigationHandlers?: NavigationWarningHandlers } },
) {
return (
<ToolRegistryProvider>
<NavigationProvider>
<TriggerWarning handlers={context.parameters.navigationHandlers}>
<Story />
</TriggerWarning>
</NavigationProvider>
</ToolRegistryProvider>
);
}
const meta = {
title: "Shared/NavigationWarningModal",
component: NavigationWarningModal,
decorators: [withProviders],
} satisfies Meta<typeof NavigationWarningModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Unsaved changes plus a pending navigation trigger the confirmation dialog. */
export const Default: Story = {};
/**
* When the active tool registers an "apply and continue" handler (e.g. a
* pending edit that can be committed before leaving), the modal adds a third
* action alongside "Keep Working" and "Discard Changes".
*/
export const WithApplyAndContinue: Story = {
parameters: {
navigationHandlers: {
onApplyAndContinue: async () => {},
} satisfies NavigationWarningHandlers,
},
};
/**
* When the active tool registers an "export and continue" handler (e.g. a
* conversion tool that can export its result before leaving), the modal adds
* an "Export & Leave" action instead.
*/
export const WithExportAndContinue: Story = {
parameters: {
navigationHandlers: {
onExportAndContinue: async () => {},
} satisfies NavigationWarningHandlers,
},
};
@@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ObscuredOverlay from "@app/components/shared/ObscuredOverlay";
const meta: Meta<typeof ObscuredOverlay> = {
title: "Shared/ObscuredOverlay",
component: ObscuredOverlay,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "22rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof meta>;
const Content = () => (
<div style={{ padding: "1rem", background: "var(--mantine-color-gray-1)" }}>
Underlying content that gets obscured.
</div>
);
export const Unobscured: Story = {
args: {
obscured: false,
children: <Content />,
},
};
export const Obscured: Story = {
args: {
obscured: true,
overlayMessage: "This feature requires an upgrade",
buttonText: "Upgrade",
onButtonClick: () => {},
children: <Content />,
},
};
export const RoundedCorners: Story = {
args: {
obscured: true,
overlayMessage: "Locked",
borderRadius: "0.5rem",
children: <Content />,
},
};
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
const meta = {
title: "Shared/PageSelectionSyntaxHint",
component: PageSelectionSyntaxHint,
parameters: { layout: "padded" },
} satisfies Meta<typeof PageSelectionSyntaxHint>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Valid syntax ("1-3,5") renders nothing — no hint shown. */
export const Default: Story = {
args: {
input: "1-3,5",
maxPages: 10,
},
};
/** Malformed expression falls back to CSV parsing and shows the panel-style hint. */
export const SyntaxError: Story = {
args: {
input: "abc",
maxPages: 10,
},
};
/** Same malformed input, compact variant used inline within a tool panel. */
export const CompactSyntaxError: Story = {
args: {
input: "abc",
maxPages: 10,
variant: "compact",
},
};
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PolicyBadges } from "@app/components/shared/PolicyBadges";
import type { FileItemPolicyRef } from "@app/components/shared/PolicyBadges";
const mockPolicies: FileItemPolicyRef[] = [
{ id: "policy-1", name: "Redact PII", accentColor: "#e03131", recent: true },
{ id: "policy-2", name: "Sanitize", accentColor: "#2f9e44", recent: false },
{ id: "policy-3", name: "Watermark", accentColor: "#4263eb", recent: false },
];
const meta = {
title: "Shared/PolicyBadges",
component: PolicyBadges,
parameters: { layout: "padded" },
} satisfies Meta<typeof PolicyBadges>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
policies: mockPolicies,
},
};
export const Enforcing: Story = {
args: {
policies: [
{
id: "policy-1",
name: "Redact PII",
accentColor: "#e03131",
recent: false,
enforcing: true,
},
...mockPolicies.slice(1),
],
},
};
export const Empty: Story = {
args: {
policies: [],
},
};
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PrivateContent } from "@app/components/shared/PrivateContent";
/** Layout-invisible wrapper that tags sensitive content with 'ph-no-capture' to exclude it from analytics. */
const meta: Meta<typeof PrivateContent> = {
title: "Shared/PrivateContent",
component: PrivateContent,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: "sensitive-filename.pdf",
},
};
export const WithCustomClassName: Story = {
args: {
children: "sensitive-filename.pdf",
className: "custom-class",
},
};
@@ -0,0 +1,63 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { FileContextProvider } from "@app/contexts/FileContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFile: StirlingFileStub = {
id: "story-file-1" as FileId,
name: "quarterly-report.pdf",
type: "application/pdf",
size: 2_400_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "story-file-1",
versionNumber: 1,
};
/**
* ShareFileModal reads useFileActions() from FileContext (stood up here since
* it isn't part of the shared preview decorators) and useAppConfig() to gate
* share links on `storageShareLinksEnabled` — wrapped per-story to show both
* the disabled and enabled states.
*/
function withFileContext(Story: () => ReactElement) {
return (
<FileContextProvider>
<Story />
</FileContextProvider>
);
}
const meta = {
title: "Shared/ShareFileModal",
component: ShareFileModal,
parameters: { layout: "fullscreen" },
args: {
opened: true,
onClose: () => {},
file: mockFile,
},
decorators: [withFileContext],
} satisfies Meta<typeof ShareFileModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Share links disabled by server config — default when no config is loaded. */
export const Default: Story = {};
/** Share links enabled — the role selector and "Generate Link" action are active. */
export const LinksEnabled: Story = {
decorators: [
(Story) => (
<AppConfigProvider
initialConfig={{ storageShareLinksEnabled: true }}
autoFetch={false}
>
<Story />
</AppConfigProvider>
),
],
};
@@ -0,0 +1,66 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
import { FileContextProvider } from "@app/contexts/FileContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFile: StirlingFileStub = {
id: "story-file-1" as FileId,
name: "quarterly-report.pdf",
type: "application/pdf",
size: 2_400_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "story-file-1",
versionNumber: 1,
};
/**
* ShareManagementModal reads useFileActions() from FileContext (stood up here
* since it isn't part of the shared preview decorators) and useAppConfig() to
* gate share links on `storageShareLinksEnabled` — wrapped per-story to show
* both the disabled and enabled states.
*/
function withFileContext(Story: () => ReactElement) {
return (
<FileContextProvider>
<Story />
</FileContextProvider>
);
}
const meta = {
title: "Shared/ShareManagementModal",
component: ShareManagementModal,
parameters: { layout: "fullscreen" },
args: {
opened: true,
onClose: () => {},
file: mockFile,
},
decorators: [withFileContext],
} satisfies Meta<typeof ShareManagementModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Share links disabled by server config — default when no config is loaded. */
export const Default: Story = {};
/** Share links enabled — the role selector, link generation and activity panel are active. */
export const LinksEnabled: Story = {
decorators: [
(Story) => (
<AppConfigProvider
initialConfig={{
storageSharingEnabled: true,
storageShareLinksEnabled: true,
}}
autoFetch={false}
>
<Story />
</AppConfigProvider>
),
],
};
@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
const meta: Meta<typeof SkeletonLoader> = {
title: "Shared/SkeletonLoader",
component: SkeletonLoader,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const PageGrid: Story = {
args: { type: "pageGrid", count: 4 },
};
export const FileGrid: Story = {
args: { type: "fileGrid", count: 4 },
};
export const Controls: Story = {
args: { type: "controls" },
};
export const Viewer: Story = {
args: { type: "viewer" },
decorators: [
(S) => (
<div style={{ height: "20rem" }}>
<S />
</div>
),
],
};
export const Block: Story = {
args: { type: "block", width: 120, height: 20 },
};
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ToolChain from "@app/components/shared/ToolChain";
import { ToolOperation } from "@app/types/file";
function op(toolId: ToolOperation["toolId"], timestamp: number): ToolOperation {
return { toolId, timestamp };
}
const shortChain: ToolOperation[] = [op("watermark", 1), op("ocr", 2)];
const longChain: ToolOperation[] = [
op("split", 1),
op("merge", 2),
op("watermark", 3),
op("ocr", 4),
op("rotate", 5),
];
const meta = {
title: "Shared/ToolChain",
component: ToolChain,
parameters: { layout: "padded" },
} satisfies Meta<typeof ToolChain>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Default text style, short chain (no truncation needed). */
export const Default: Story = {
args: {
toolChain: shortChain,
},
};
/** Text style with a long chain — truncates to first → +N → last, with a tooltip for the full chain. */
export const TextTruncated: Story = {
args: {
toolChain: longChain,
displayStyle: "text",
},
};
/** Badge style — shows up to 3 badges, with "..." + final badge and a tooltip when longer. */
export const Badges: Story = {
args: {
toolChain: longChain,
displayStyle: "badges",
},
};
/** Compact style — collapses to a tool count once more than one tool is present. */
export const Compact: Story = {
args: {
toolChain: longChain,
displayStyle: "compact",
},
};
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { iconMap } from "@app/components/tools/automate/iconMap";
import { ToolIcon } from "@app/components/shared/ToolIcon";
const { PictureAsPdfIcon } = iconMap;
const meta = {
title: "Shared/ToolIcon",
component: ToolIcon,
parameters: { layout: "padded" },
} satisfies Meta<typeof ToolIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: <PictureAsPdfIcon />,
},
};
/** Visually unavailable state, for tools the user can't run. */
export const ReducedOpacity: Story = {
args: {
icon: <PictureAsPdfIcon />,
opacity: 0.25,
},
};
/** No right margin, for inline placement. */
export const NoMargin: Story = {
args: {
icon: <PictureAsPdfIcon />,
marginRight: "0",
},
};
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import SettingsIcon from "@mui/icons-material/Settings";
import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader";
const meta: Meta<typeof ToolPanelHeader> = {
title: "Shared/ToolPanelHeader",
component: ToolPanelHeader,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
icon: <SettingsIcon sx={{ fontSize: 18 }} />,
title: "Split",
},
};
/** Trailing close button only renders when `onClose` is supplied. */
export const WithCloseButton: Story = {
args: {
icon: <SettingsIcon sx={{ fontSize: 18 }} />,
title: "Split",
onClose: () => {},
closeLabel: "Close tool panel",
},
};
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import UpdateModal from "@app/components/shared/UpdateModal";
import type { UpdateSummary, MachineInfo } from "@app/services/updateService";
const UPDATE_SUMMARY: UpdateSummary = {
latest_version: "2.5.0",
latest_stable_version: "2.5.0",
max_priority: "normal",
recommended_action: "This update contains important fixes and improvements.",
any_breaking: false,
migration_guides: [
{
version: "2.5.0",
notes: "Config file format changed for custom watermark presets.",
url: "https://docs.stirlingpdf.com/migration/2.5.0",
},
],
};
const MACHINE_INFO: MachineInfo = {
machineType: "Client-win",
activeSecurity: false,
licenseType: "NORMAL",
};
const meta = {
title: "Shared/UpdateModal",
component: UpdateModal,
parameters: { layout: "fullscreen" },
args: {
opened: true,
onClose: () => {},
currentVersion: "2.4.0",
updateSummary: UPDATE_SUMMARY,
machineInfo: MACHINE_INFO,
downloadSizeBytes: 235_000_000,
},
} satisfies Meta<typeof UpdateModal>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Web/server build: no Tauri updater, so the footer offers a plain download link. */
export const Default: Story = {};
/** Desktop app: an update has finished installing and is waiting for a restart. */
export const DesktopInstallReadyToRestart: Story = {
args: {
desktopInstall: {
state: "ready-to-restart",
progress: null,
errorMessage: null,
actions: {
startInstall: async () => true,
restartApp: async () => {},
},
},
},
};
/** Desktop app on a non-admin machine: install probe reported it can't write to
* the install directory, so Install Now is disabled and the docs alert shows. */
export const DesktopInstallBlocked: Story = {
args: {
desktopInstall: {
state: "idle",
progress: null,
errorMessage: null,
actions: {
startInstall: async () => true,
restartApp: async () => {},
},
canInstall: {
canInstall: false,
reason: "Install directory is not writable without elevation.",
},
},
},
};
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { UpdateStartupPopup } from "@app/components/shared/UpdateStartupPopup";
import { AppConfigProvider } from "@app/contexts/AppConfigContext";
/**
* Startup update-check popup — renders null until an update is detected, so
* every story here shows an empty canvas. The stories exercise the gating
* logic (`isUpdatePopupAllowed`) rather than the (invisible) update-found UI,
* which additionally requires a real startup delay + network round trip.
*/
const meta = {
title: "Shared/UpdateStartupPopup",
component: UpdateStartupPopup,
} satisfies Meta<typeof UpdateStartupPopup>;
export default meta;
type Story = StoryObj<typeof meta>;
/** No app config resolved yet — gate is closed, renders nothing. */
export const Default: Story = {};
/** Config resolved but `shouldShowUpdate` is false — gate stays closed. */
export const UpdatesDisabled: Story = {
decorators: [
(StoryComponent) => (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{
appVersion: "1.2.3",
shouldShowUpdate: false,
}}
>
<StoryComponent />
</AppConfigProvider>
),
],
};
/**
* Gate is open (`shouldShowUpdate: true`), so the startup timer would fire and
* check for an update — but the modal itself only appears once that check
* resolves with a newer version, well after the 15s startup delay.
*/
export const UpdatesEnabled: Story = {
decorators: [
(StoryComponent) => (
<AppConfigProvider
autoFetch={false}
bootstrapMode="non-blocking"
initialConfig={{
appVersion: "1.2.3",
shouldShowUpdate: true,
machineType: "docker",
activeSecurity: false,
license: "NORMAL",
}}
>
<StoryComponent />
</AppConfigProvider>
),
],
};
@@ -0,0 +1,62 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import { FileContextProvider } from "@app/contexts/FileContext";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const mockFile: StirlingFileStub = {
id: "file-1" as FileId,
name: "quarterly-report.pdf",
type: "application/pdf",
size: 2_400_000,
lastModified: Date.now(),
isLeaf: true,
originalFileId: "file-1" as FileId,
versionNumber: 1,
};
const mockUploadedFile: StirlingFileStub = {
...mockFile,
id: "file-2" as FileId,
originalFileId: "file-2" as FileId,
remoteStorageId: 2,
};
/**
* The modal dispatches updateStirlingFileStub on upload, so it needs
* FileContext (also supplies IndexedDBContext) mounted above it.
*/
function withProviders(Story: () => ReactElement) {
return (
<FileContextProvider>
<Story />
</FileContextProvider>
);
}
const meta = {
title: "Shared/UploadToServerModal",
component: UploadToServerModal,
decorators: [withProviders],
args: {
onClose: () => {},
},
} satisfies Meta<typeof UploadToServerModal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
opened: true,
file: mockFile,
},
};
export const AlreadyUploaded: Story = {
args: {
opened: true,
file: mockUploadedFile,
},
};
@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import UserSelector from "@app/components/shared/UserSelector";
/**
* Fetches `/api/v1/user/users` on mount — unmocked here, so stories render
* whatever the fetch settles to (loader, then the "no users" empty state).
*/
const meta = {
title: "Shared/UserSelector",
component: UserSelector,
parameters: { layout: "padded" },
args: {
value: [],
onChange: () => {},
},
} satisfies Meta<typeof UserSelector>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Disabled: Story = {
args: { disabled: true },
};
export const CustomPlaceholder: Story = {
args: { placeholder: "Add collaborators..." },
};

Some files were not shown because too many files have changed in this diff Show More