From 22ec0947c908d7fe1815fbcd0f73305c908c8732 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:06:06 +0100 Subject: [PATCH] Storybook coverage: scan harness + stories (#7073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .taskfiles/frontend.yml | 16 ++ frontend/.storybook/main.ts | 6 +- frontend/.storybook/preview.tsx | 47 ++-- frontend/.storybook/vitest.config.ts | 62 +++++ frontend/.storybook/vitest.setup.ts | 10 + .../src/core/components/AppLayout.stories.tsx | 75 ++++++ .../components/StorageStatsCard.stories.tsx | 49 ++++ .../shared/BaseAnnotationTool.stories.tsx | 68 ++++++ .../shared/ColorControl.stories.tsx | 35 +++ .../annotation/shared/ColorPicker.stories.tsx | 45 ++++ .../shared/DrawingCanvas.stories.tsx | 52 +++++ .../shared/DrawingControls.stories.tsx | 39 ++++ .../shared/ImageUploader.stories.tsx | 39 ++++ .../shared/OpacityControl.stories.tsx | 23 ++ .../shared/PropertiesPopover.stories.tsx | 35 +++ .../shared/TextInputWithFont.stories.tsx | 78 +++++++ .../shared/WidthControl.stories.tsx | 35 +++ .../annotation/tools/DrawingTool.stories.tsx | 50 ++++ .../annotation/tools/ImageTool.stories.tsx | 50 ++++ .../fileEditor/FileEditorFileName.stories.tsx | 40 ++++ .../CompactFileDetails.stories.tsx | 68 ++++++ .../fileManager/DragOverlay.stories.tsx | 30 +++ .../fileManager/FileDetails.stories.tsx | 73 ++++++ .../filesPage/DeleteFilesDialog.stories.tsx | 58 +++++ .../filesPage/DeleteFolderDialog.stories.tsx | 39 ++++ .../filesPage/FileDetailsPanel.stories.tsx | 88 +++++++ .../components/filesPage/FileGrid.stories.tsx | 114 +++++++++ .../filesPage/FileOriginBadge.stories.tsx | 35 +++ .../FolderAppearancePicker.stories.tsx | 46 ++++ .../filesPage/FolderNameDialog.stories.tsx | 31 +++ .../filesPage/FolderThumbnail.stories.tsx | 38 +++ .../filesPage/FolderTreeSidebar.stories.tsx | 51 ++++ .../filesPage/MoveToFolderDialog.stories.tsx | 86 +++++++ .../OpenInNewWindowMenuItem.stories.tsx | 29 +++ .../filesPage/VersionHistoryModal.stories.tsx | 64 ++++++ .../filesPage/VersionTimeline.stories.tsx | 98 ++++++++ .../OnboardingSlideShell.stories.tsx | 79 +++++++ .../onboarding/OnboardingStepper.stories.tsx | 21 ++ .../onboarding/OnboardingTour.stories.tsx | 69 ++++++ .../StaticOnboardingSlide.stories.tsx | 105 +++++++++ .../slides/AnalyticsChoiceSlide.stories.tsx | 41 ++++ .../AnimatedSlideBackground.stories.tsx | 44 ++++ .../slides/DesktopInstallTitle.stories.tsx | 39 ++++ .../slides/FirstLoginSlide.stories.tsx | 47 ++++ .../slides/PlanOverviewSlide.stories.tsx | 60 +++++ .../slides/ProcessorIntroSlide.stories.tsx | 33 +++ .../slides/SecurityCheckSlide.stories.tsx | 46 ++++ .../slides/ServerLicenseSlide.stories.tsx | 64 ++++++ .../slides/WelcomeSlide.stories.tsx | 33 +++ .../pageEditor/DragDropGrid.stories.tsx | 112 +++++++++ .../pageEditor/PageEditorControls.stories.tsx | 57 +++++ .../AdvancedSelectionPanel.stories.tsx | 40 ++++ .../OperatorsSection.stories.tsx | 23 ++ .../SelectPages.stories.tsx | 43 ++++ .../SelectedPagesDisplay.stories.tsx | 34 +++ .../shared/AllToolsNavButton.stories.tsx | 63 +++++ .../components/shared/AppSwitch.stories.tsx | 35 +++ .../shared/BulkShareModal.stories.tsx | 75 ++++++ .../BulkUploadToServerModal.stories.tsx | 67 ++++++ .../shared/ButtonToggle.stories.tsx | 58 +++++ .../shared/CloudStorageIcons.stories.tsx | 33 +++ .../shared/DropdownListWithFooter.stories.tsx | 86 +++++++ .../shared/EditableSecretField.stories.tsx | 61 +++++ .../EncryptedPdfUnlockModal.stories.tsx | 60 +++++ .../shared/ErrorBoundary.stories.tsx | 44 ++++ .../components/shared/FileCard.stories.tsx | 65 ++++++ .../components/shared/FileDocIcon.stories.tsx | 35 +++ .../shared/FileDropdownMenu.stories.tsx | 49 ++++ .../components/shared/FileGrid.stories.tsx | 104 +++++++++ .../shared/FilePickerModal.stories.tsx | 37 +++ .../components/shared/FilePreview.stories.tsx | 57 +++++ .../shared/FileSelectorPicker.stories.tsx | 48 ++++ .../shared/FileUploadButton.stories.tsx | 56 +++++ .../shared/FirstLoginModal.stories.tsx | 18 ++ .../components/shared/FitText.stories.tsx | 41 ++++ .../core/components/shared/Footer.stories.tsx | 27 +++ .../shared/HoverActionMenu.stories.tsx | 76 ++++++ .../components/shared/InfoBanner.stories.tsx | 38 +++ .../shared/LandingDocumentStack.stories.tsx | 13 ++ .../shared/LanguageSelector.stories.tsx | 25 ++ .../shared/LoadingFallback.stories.tsx | 13 ++ .../components/shared/LocalIcon.stories.tsx | 32 +++ .../shared/LoginAgreementModal.stories.tsx | 18 ++ .../shared/MobileUploadModal.stories.tsx | 25 ++ .../shared/MultiSelectControls.stories.tsx | 34 +++ .../shared/NavigationWarningModal.stories.tsx | 103 +++++++++ .../shared/ObscuredOverlay.stories.tsx | 49 ++++ .../PageSelectionSyntaxHint.stories.tsx | 35 +++ .../shared/PolicyBadges.stories.tsx | 44 ++++ .../shared/PrivateContent.stories.tsx | 24 ++ .../shared/ShareFileModal.stories.tsx | 63 +++++ .../shared/ShareManagementModal.stories.tsx | 66 ++++++ .../shared/SkeletonLoader.stories.tsx | 37 +++ .../components/shared/ToolChain.stories.tsx | 56 +++++ .../components/shared/ToolIcon.stories.tsx | 35 +++ .../shared/ToolPanelHeader.stories.tsx | 28 +++ .../components/shared/UpdateModal.stories.tsx | 79 +++++++ .../shared/UpdateStartupPopup.stories.tsx | 63 +++++ .../shared/UploadToServerModal.stories.tsx | 62 +++++ .../shared/UserSelector.stories.tsx | 29 +++ .../shared/ZipWarningModal.stories.tsx | 30 +++ .../config/LoginRequiredBanner.stories.tsx | 22 ++ .../shared/config/OverviewHeader.stories.tsx | 12 + .../shared/config/PendingBadge.stories.tsx | 29 +++ .../RestartConfirmationModal.stories.tsx | 26 +++ .../config/SettingsSearchBar.stories.tsx | 62 +++++ .../config/SettingsStickyFooter.stories.tsx | 34 +++ .../configSections/GeneralSection.stories.tsx | 71 ++++++ .../configSections/HelpSection.stories.tsx | 25 ++ .../configSections/HotkeysSection.stories.tsx | 40 ++++ .../configSections/LegalSection.stories.tsx | 74 ++++++ .../configSections/Overview.stories.tsx | 59 +++++ .../configSections/ProviderCard.stories.tsx | 80 +++++++ .../config/configSections/ProviderCard.tsx | 8 +- .../ThirdPartyLicensesSection.stories.tsx | 18 ++ .../filePreview/DocumentStack.stories.tsx | 51 ++++ .../filePreview/DocumentThumbnail.stories.tsx | 49 ++++ .../filePreview/HoverOverlay.stories.tsx | 31 +++ .../filePreview/NavigationArrows.stories.tsx | 34 +++ .../QuickAccessButton.stories.tsx | 46 ++++ .../signing/CreateSessionFlow.stories.tsx | 71 ++++++ .../signing/SharedSigningLauncher.stories.tsx | 104 +++++++++ ...ConfigureSignatureDefaultsStep.stories.tsx | 83 +++++++ .../steps/ReviewSessionStep.stories.tsx | 50 ++++ .../steps/SelectDocumentStep.stories.tsx | 34 +++ .../steps/SelectParticipantsStep.stories.tsx | 27 +++ .../SliderWithInput.stories.tsx | 46 ++++ .../shared/tooltip/TooltipContent.stories.tsx | 63 +++++ .../DrawSignatureCanvas.stories.tsx | 38 +++ .../SignatureTypeSelector.stories.tsx | 37 +++ .../TypeSignatureText.stories.tsx | 61 +++++ .../UploadSignatureImage.stories.tsx | 36 +++ .../toast/ToastRenderer.stories.tsx | 102 ++++++++ .../tools/ToolLoadingFallback.stories.tsx | 20 ++ .../tools/ToolPanelModePrompt.stories.tsx | 51 ++++ .../components/tools/ToolRenderer.stories.tsx | 55 +++++ .../PageNumberPreview.stories.tsx | 26 +++ .../AddPasswordSettings.stories.tsx | 70 ++++++ .../tools/addStamp/StampPreview.stories.tsx | 36 +++ .../addStamp/StampSetupSettings.stories.tsx | 70 ++++++ ...AddWatermarkSingleStepSettings.stories.tsx | 87 +++++++ .../WatermarkFormatting.stories.tsx | 32 +++ .../WatermarkImageFile.stories.tsx | 72 ++++++ .../WatermarkStyleSettings.stories.tsx | 38 +++ .../WatermarkTextStyle.stories.tsx | 41 ++++ .../WatermarkTypeSettings.stories.tsx | 28 +++ .../addWatermark/WatermarkWording.stories.tsx | 53 +++++ .../AdjustContrastBasicSettings.stories.tsx | 57 +++++ .../AdjustContrastColorSettings.stories.tsx | 57 +++++ .../AdjustContrastPreview.stories.tsx | 20 ++ ...justContrastSingleStepSettings.stories.tsx | 60 +++++ .../AdjustPageScaleSettings.stories.tsx | 46 ++++ .../autoRename/AutoRenameSettings.stories.tsx | 30 +++ .../automate/AutomationCreation.stories.tsx | 59 +++++ .../AutomationImportModal.stories.tsx | 31 +++ .../tools/automate/IconSelector.stories.tsx | 28 +++ .../ToolConfigurationModal.stories.tsx | 80 +++++++ .../tools/automate/ToolList.stories.tsx | 55 +++++ .../tools/automate/ToolSelector.stories.tsx | 31 +++ .../BookletImpositionSettings.stories.tsx | 42 ++++ .../CertSignAutomationSettings.stories.tsx | 59 +++++ .../CertificateFilesSettings.stories.tsx | 81 +++++++ .../CertificateFormatSettings.stories.tsx | 33 +++ .../certSign/CertificateSelector.stories.tsx | 82 +++++++ .../CertificateTypeSettings.stories.tsx | 82 +++++++ .../HardwareCertificateSettings.stories.tsx | 79 +++++++ .../SignatureAppearanceSettings.stories.tsx | 67 ++++++ .../SignatureSettingsDisplay.stories.tsx | 40 ++++ .../SignatureSettingsInput.stories.tsx | 59 +++++ .../certSign/WetSignatureInput.stories.tsx | 60 +++++ .../modals/AddParticipantsFlow.stories.tsx | 22 ++ .../modals/CertificateConfigModal.stories.tsx | 26 +++ .../modals/SelectSignatureModal.stories.tsx | 18 ++ .../panels/ParticipantListPanel.stories.tsx | 63 +++++ .../panels/SessionActionsPanel.stories.tsx | 73 ++++++ .../panels/SessionDetailPanel.stories.tsx | 74 ++++++ .../panels/SignControlsPanel.stories.tsx | 51 ++++ .../steps/AddSignaturesStep.stories.tsx | 33 +++ .../CertificateSelectionStep.stories.tsx | 102 ++++++++ .../steps/ReviewSignatureStep.stories.tsx | 57 +++++ .../steps/SignatureCreationStep.stories.tsx | 53 +++++ .../steps/SignaturePlacementStep.stories.tsx | 53 +++++ .../steps/AdvancedOptionsStep.stories.tsx | 36 +++ .../steps/CustomMetadataStep.stories.tsx | 79 +++++++ .../steps/DeleteAllStep.stories.tsx | 53 +++++ .../steps/DocumentDatesStep.stories.tsx | 66 ++++++ .../steps/StandardMetadataStep.stories.tsx | 62 +++++ .../ChangePermissionsSettings.stories.tsx | 48 ++++ .../compare/CompareDocumentPane.stories.tsx | 75 ++++++ .../CompareNavigationDropdown.stories.tsx | 48 ++++ .../ComparePixelWorkbenchView.stories.tsx | 113 +++++++++ .../compress/CompressSettings.stories.tsx | 76 ++++++ .../ConvertFromCbrSettings.stories.tsx | 66 ++++++ .../ConvertFromCbzSettings.stories.tsx | 52 +++++ .../ConvertFromEbookSettings.stories.tsx | 42 ++++ .../ConvertFromEmailSettings.stories.tsx | 62 +++++ .../ConvertFromImageSettings.stories.tsx | 38 +++ .../ConvertFromSvgSettings.stories.tsx | 38 +++ .../ConvertFromWebSettings.stories.tsx | 38 +++ .../convert/ConvertToCbrSettings.stories.tsx | 52 +++++ .../convert/ConvertToCbzSettings.stories.tsx | 25 ++ .../convert/ConvertToEpubSettings.stories.tsx | 54 +++++ .../ConvertToImageSettings.stories.tsx | 38 +++ .../convert/ConvertToPdfaSettings.stories.tsx | 34 +++ .../convert/ConvertToPdfxSettings.stories.tsx | 57 +++++ .../convert/GroupedFormatDropdown.stories.tsx | 55 +++++ .../tools/crop/CropAreaSelector.stories.tsx | 83 +++++++ .../crop/CropAutomationSettings.stories.tsx | 59 +++++ .../crop/CropCoordinateInputs.stories.tsx | 63 +++++ .../BookmarkEditor.stories.tsx | 45 ++++ .../EditTableOfContentsSettings.stories.tsx | 69 ++++++ ...itTableOfContentsWorkbenchView.stories.tsx | 86 +++++++ .../ExtractImagesSettings.stories.tsx | 60 +++++ .../ExtractPagesSettings.stories.tsx | 39 ++++ .../tools/flatten/FlattenSettings.stories.tsx | 48 ++++ .../fullscreen/DetailedToolItem.stories.tsx | 103 +++++++++ .../GetPdfInfoReportView.stories.tsx | 112 +++++++++ .../getPdfInfo/GetPdfInfoResults.stories.tsx | 103 +++++++++ .../sections/ComplianceSection.stories.tsx | 59 +++++ .../sections/KeyValueSection.stories.tsx | 32 +++ .../sections/OtherSection.stories.tsx | 43 ++++ .../sections/PerPageSection.stories.tsx | 72 ++++++ .../sections/SummarySection.stories.tsx | 92 ++++++++ .../TableOfContentsSection.stories.tsx | 31 +++ .../shared/KeyValueList.stories.tsx | 28 +++ .../shared/ScrollableCodeBlock.stories.tsx | 38 +++ .../shared/SectionBlock.stories.tsx | 28 +++ .../tools/merge/MergeFileSorter.stories.tsx | 23 ++ .../tools/merge/MergeSettings.stories.tsx | 44 ++++ .../tools/ocr/AdvancedOCRSettings.stories.tsx | 43 ++++ .../tools/ocr/LanguagePicker.stories.tsx | 63 +++++ .../tools/ocr/OCRSettings.stories.tsx | 43 ++++ .../pageLayout/LayoutPreview.stories.tsx | 44 ++++ .../PageLayoutAdvancedSettings.stories.tsx | 68 ++++++ ...geLayoutMarginsBordersSettings.stories.tsx | 58 +++++ .../pageLayout/PageLayoutPreview.stories.tsx | 40 ++++ .../pageLayout/PageLayoutSettings.stories.tsx | 61 +++++ .../redact/RedactAdvancedSettings.stories.tsx | 40 ++++ .../redact/RedactModeSelector.stories.tsx | 54 +++++ .../RedactSingleStepSettings.stories.tsx | 78 +++++++ .../redact/WordsToRedactInput.stories.tsx | 47 ++++ .../RemoveAnnotationsSettings.stories.tsx | 12 + .../RemoveBlanksSettings.stories.tsx | 41 ++++ .../RemoveCertificateSignSettings.stories.tsx | 28 +++ .../RemovePagesSettings.stories.tsx | 44 ++++ .../RemovePasswordSettings.stories.tsx | 39 ++++ .../ReorganizePagesSettings.stories.tsx | 58 +++++ .../tools/repair/RepairSettings.stories.tsx | 25 ++ .../ReplaceColorSettings.stories.tsx | 35 +++ .../RotateAutomationSettings.stories.tsx | 46 ++++ .../sanitize/SanitizeSettings.stories.tsx | 40 ++++ .../ScannerImageSplitSettings.stories.tsx | 36 +++ .../shared/ErrorNotification.stories.tsx | 25 ++ .../tools/shared/FileMetadata.stories.tsx | 27 +++ .../shared/NavigationControls.stories.tsx | 36 +++ .../tools/shared/NoToolsFound.stories.tsx | 11 + .../shared/NumberInputWithUnit.stories.tsx | 37 +++ .../tools/shared/ResultsPreview.stories.tsx | 46 ++++ .../shared/SubcategoryHeader.stories.tsx | 24 ++ .../tools/shared/ToolStep.stories.tsx | 36 +++ .../tools/showJS/ShowJSView.stories.tsx | 45 ++++ .../tools/sign/PenSizeSelector.stories.tsx | 36 +++ .../sign/SavedSignaturesSection.stories.tsx | 99 ++++++++ .../SingleLargePageSettings.stories.tsx | 27 +++ .../split/SplitAutomationSettings.stories.tsx | 45 ++++ .../tools/split/SplitSettings.stories.tsx | 104 +++++++++ .../TimestampPdfSettings.stories.tsx | 26 +++ .../tools/toolPicker/FavoriteStar.stories.tsx | 32 +++ .../tools/toolPicker/ToolSearch.stories.tsx | 82 +++++++ .../UnlockPdfFormsSettings.stories.tsx | 27 +++ .../ValidateSignatureReportView.stories.tsx | 140 +++++++++++ .../ValidateSignatureSettings.stories.tsx | 43 ++++ .../reportView/FieldBlock.stories.tsx | 21 ++ .../reportView/FileSummaryHeader.stories.tsx | 34 +++ .../reportView/SignatureSection.stories.tsx | 80 +++++++ .../SignatureStatusBadge.stories.tsx | 68 ++++++ .../reportView/ThumbnailPreview.stories.tsx | 29 +++ .../viewer/AnnotationMenuButtons.stories.tsx | 73 ++++++ .../AnnotationSelectionMenu.stories.tsx | 20 ++ .../viewer/AnnotationTypeButtons.stories.tsx | 69 ++++++ .../viewer/CustomSearchLayer.stories.tsx | 27 +++ .../viewer/DocumentReadyWrapper.stories.tsx | 35 +++ .../components/viewer/LinkLayer.stories.tsx | 22 ++ .../viewer/LocalEmbedPDF.stories.tsx | 39 ++++ .../viewer/NonPdfViewer.stories.tsx | 75 ++++++ .../RedactionPendingTracker.stories.tsx | 16 ++ .../viewer/RedactionSelectionMenu.stories.tsx | 16 ++ .../viewer/SearchInterface.stories.tsx | 27 +++ .../viewer/SignatureFieldOverlay.stories.tsx | 22 ++ .../SignaturePlacementOverlay.stories.tsx | 94 ++++++++ .../viewer/SignaturePreviewLayer.stories.tsx | 63 +++++ .../viewer/StampPlacementOverlay.stories.tsx | 95 ++++++++ .../viewer/TextSelectionMenu.stories.tsx | 36 +++ .../viewer/nonpdf/CsvViewer.stories.tsx | 48 ++++ .../viewer/nonpdf/HtmlViewer.stories.tsx | 23 ++ .../viewer/nonpdf/ImageViewer.stories.tsx | 39 ++++ .../viewer/nonpdf/JsonViewer.stories.tsx | 47 ++++ .../viewer/nonpdf/NonPdfBanner.stories.tsx | 30 +++ .../viewer/nonpdf/TextViewer.stories.tsx | 53 +++++ .../editor/src/core/ui/FilePicker.stories.tsx | 41 ++++ .../components/AssistantButton.stories.tsx | 32 +++ .../portal/components/BrandMarks.stories.tsx | 24 ++ .../DownloadEditorModal.stories.tsx | 14 ++ .../components/ErrorBoundary.stories.tsx | 43 ++++ .../components/HomeGreeting.stories.tsx | 12 + .../portal/components/HomeHero.stories.tsx | 32 +++ .../LinkAccountFooterItem.stories.tsx | 21 ++ .../portal/components/LoginScreen.stories.tsx | 12 + .../components/PortalChrome.stories.tsx | 12 + .../account-link/AccountLinkPanel.stories.tsx | 72 ++++++ .../account-link/LinkAccountModal.stories.tsx | 23 ++ .../account-link/LinkGate.stories.tsx | 24 -- .../billing/CardPlaceholder.stories.tsx | 14 ++ .../billing/InvoicesList.stories.tsx | 77 +++++++ .../billing/PrepaidCapacityCard.stories.tsx | 43 ++++ .../billing/PrepayModalHeader.stories.tsx | 30 +++ .../infrastructure/AuditTab.stories.tsx | 16 +- .../PipelineStepSettings.stories.tsx | 76 ++++++ .../pipelines/PipelinesTable.stories.tsx | 43 ++++ .../pipelines/ToolPicker.stories.tsx | 70 ++++++ .../ClassificationLabelsSection.stories.tsx | 12 + .../PolicyExternalApiConfig.stories.tsx | 66 ++++++ .../policies/PolicyPurviewConfig.stories.tsx | 46 ++++ .../PolicyPurviewReadConfig.stories.tsx | 32 +++ .../procurement/CalendlyInline.stories.tsx | 19 ++ .../ProcurementAgreement.stories.tsx | 92 ++++++++ .../procurement/ProcurementBanner.stories.tsx | 79 +++++++ .../procurement/ProcurementExtras.stories.tsx | 113 +++++++++ .../procurement/ProcurementFlow.stories.tsx | 143 ++++++++++++ .../procurement/ProcurementStages.stories.tsx | 77 +++++++ .../sources/ConnectionForm.stories.tsx | 52 +++++ .../sources/ConnectionModal.stories.tsx | 51 ++++ .../sources/ConnectionPicker.stories.tsx | 55 +++++ .../sources/ConnectionTypePicker.stories.tsx | 28 +++ .../sources/S3ConnectionPicker.stories.tsx | 18 ++ .../sources/SourceModal.stories.tsx | 39 ++++ .../sources/SourceTypeIcon.stories.tsx | 27 +++ .../users/PendingInvitations.stories.tsx | 56 +++++ .../src/portal/views/Integrations.stories.tsx | 28 +++ .../portal/views/PipelineBuilder.stories.tsx | 47 ++++ .../proprietary/auth/ui/AuthShell.stories.tsx | 58 +++++ .../auth/ui/EmailPasswordForm.stories.tsx | 70 ++++++ .../auth/ui/ErrorMessage.stories.tsx | 32 +++ .../auth/ui/SpringLoginForm.stories.tsx | 99 ++++++++ .../auth/ui/SupabaseLoginForm.stories.tsx | 66 ++++++ .../agents/StirlingLogoAnimated.stories.tsx | 22 ++ .../agents/StirlingLogoOutline.stories.tsx | 22 ++ .../policies/PolicyPiiField.stories.tsx | 71 ++++++ .../policies/PolicyRedactConfig.stories.tsx | 70 ++++++ .../PolicyWatermarkConfig.stories.tsx | 38 +++ .../ChangeUserPasswordModal.stories.tsx | 36 +++ .../shared/DividerWithText.stories.tsx | 31 +++ .../shared/ManageBillingButton.stories.tsx | 23 ++ .../shared/UpdateSeatsModal.stories.tsx | 30 +++ .../EnterpriseRequiredBanner.stories.tsx | 24 ++ .../GeneralWithLoginLanding.stories.tsx | 74 ++++++ .../shared/config/OverviewHeader.stories.tsx | 49 ++++ .../configSections/AccountSection.stories.tsx | 101 ++++++++ .../AdminAiDocumentsSection.stories.tsx | 98 ++++++++ .../AdminAiGeneralSection.stories.tsx | 82 +++++++ .../AdminAiLimitsSection.stories.tsx | 85 +++++++ .../AdminAiModelsSection.stories.tsx | 106 +++++++++ .../AdminAuditSection.stories.tsx | 100 ++++++++ .../AdminFolderAccessSection.stories.tsx | 95 ++++++++ .../config/configSections/ApiKeys.stories.tsx | 55 +++++ .../LoginAgreementEditor.stories.tsx | 20 ++ .../TeamDetailsSection.stories.tsx | 98 ++++++++ .../configSections/TeamsSection.stories.tsx | 71 ++++++ .../apiKeys/ApiKeySection.stories.tsx | 31 +++ .../apiKeys/RefreshModal.stories.tsx | 25 ++ .../audit/AuditChartsSection.stories.tsx | 26 +++ .../audit/AuditClearDataSection.stories.tsx | 24 ++ .../audit/AuditEventsTable.stories.tsx | 29 +++ .../audit/AuditExportSection.stories.tsx | 32 +++ .../audit/AuditFiltersForm.stories.tsx | 68 ++++++ .../audit/AuditStatsCards.stories.tsx | 31 +++ .../audit/AuditSystemStatus.stories.tsx | 41 ++++ .../plan/AvailablePlansSection.stories.tsx | 80 +++++++ .../plan/FeatureComparisonTable.stories.tsx | 51 ++++ .../configSections/plan/PlanCard.stories.tsx | 130 +++++++++++ .../usage/UsageAnalyticsChart.stories.tsx | 28 +++ .../usage/UsageAnalyticsTable.stories.tsx | 32 +++ .../stripeCheckout/StripeCheckout.stories.tsx | 58 +++++ .../components/PriceDisplay.stories.tsx | 47 ++++ .../components/PricingBadge.stories.tsx | 35 +++ .../stages/EmailStage.stories.tsx | 35 +++ .../stages/ErrorStage.stories.tsx | 27 +++ .../stages/PaymentStage.stories.tsx | 42 ++++ .../stages/PlanSelectionStage.stories.tsx | 103 +++++++++ .../stages/SuccessStage.stories.tsx | 47 ++++ .../workflow/ParticipantView.stories.tsx | 99 ++++++++ frontend/package-lock.json | 217 ++++++++++++++++++ frontend/package.json | 2 + frontend/vitest.config.ts | 7 + 394 files changed, 19750 insertions(+), 46 deletions(-) create mode 100644 frontend/.storybook/vitest.config.ts create mode 100644 frontend/.storybook/vitest.setup.ts create mode 100644 frontend/editor/src/core/components/AppLayout.stories.tsx create mode 100644 frontend/editor/src/core/components/StorageStatsCard.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx create mode 100644 frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx create mode 100644 frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx create mode 100644 frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx create mode 100644 frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx create mode 100644 frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx create mode 100644 frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/AppSwitch.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileCard.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileGrid.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FilePreview.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/FitText.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/Footer.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/InfoBanner.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LocalIcon.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/PrivateContent.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ToolChain.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ToolIcon.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UpdateModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/UserSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/filePreview/NavigationArrows.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/quickAccessBar/QuickAccessButton.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/CreateSessionFlow.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/SharedSigningLauncher.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/ReviewSessionStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/SelectDocumentStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/signing/steps/SelectParticipantsStep.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/sliderWithInput/SliderWithInput.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/tooltip/TooltipContent.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/DrawSignatureCanvas.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/SignatureTypeSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/TypeSignatureText.stories.tsx create mode 100644 frontend/editor/src/core/components/shared/wetSignature/UploadSignatureImage.stories.tsx create mode 100644 frontend/editor/src/core/components/toast/ToastRenderer.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ToolLoadingFallback.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ToolPanelModePrompt.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ToolRenderer.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addPageNumbers/PageNumberPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addPassword/AddPasswordSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addStamp/StampPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addStamp/StampSetupSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkFormatting.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkImageFile.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkStyleSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkTextStyle.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkTypeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/addWatermark/WatermarkWording.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastBasicSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastColorSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustContrast/AdjustContrastSingleStepSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/adjustPageScale/AdjustPageScaleSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/autoRename/AutoRenameSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/AutomationCreation.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/AutomationImportModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/IconSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/ToolConfigurationModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/ToolList.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/automate/ToolSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/bookletImposition/BookletImpositionSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertSignAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateFilesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateFormatSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/CertificateTypeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/HardwareCertificateSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/SignatureAppearanceSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/SignatureSettingsDisplay.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/SignatureSettingsInput.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/WetSignatureInput.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/modals/AddParticipantsFlow.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/modals/CertificateConfigModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/modals/SelectSignatureModal.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/ParticipantListPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SessionActionsPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SessionDetailPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/panels/SignControlsPanel.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/AddSignaturesStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/CertificateSelectionStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/ReviewSignatureStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/SignatureCreationStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/certSign/steps/SignaturePlacementStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/AdvancedOptionsStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/CustomMetadataStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/DeleteAllStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changeMetadata/steps/StandardMetadataStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/changePermissions/ChangePermissionsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compare/CompareNavigationDropdown.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/compress/CompressSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromCbrSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromCbzSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromEbookSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromEmailSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromImageSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromSvgSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertFromWebSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToCbrSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToCbzSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToEpubSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToImageSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfaSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfxSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/convert/GroupedFormatDropdown.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/crop/CropAreaSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/crop/CropAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/crop/CropCoordinateInputs.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/editTableOfContents/BookmarkEditor.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/extractImages/ExtractImagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/extractPages/ExtractPagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/flatten/FlattenSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/fullscreen/DetailedToolItem.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoReportView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/GetPdfInfoResults.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/ComplianceSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/KeyValueSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/OtherSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/PerPageSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/SummarySection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/sections/TableOfContentsSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/shared/KeyValueList.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/shared/ScrollableCodeBlock.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/getPdfInfo/shared/SectionBlock.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/merge/MergeFileSorter.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/merge/MergeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ocr/AdvancedOCRSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ocr/LanguagePicker.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/ocr/OCRSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/LayoutPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutAdvancedSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutMarginsBordersSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/pageLayout/PageLayoutSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/RedactAdvancedSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/RedactModeSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/RedactSingleStepSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/redact/WordsToRedactInput.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removeAnnotations/RemoveAnnotationsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removeBlanks/RemoveBlanksSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removeCertificateSign/RemoveCertificateSignSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removePages/RemovePagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/removePassword/RemovePasswordSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/reorganizePages/ReorganizePagesSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/repair/RepairSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/replaceColor/ReplaceColorSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/rotate/RotateAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/sanitize/SanitizeSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/scannerImageSplit/ScannerImageSplitSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/ErrorNotification.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/FileMetadata.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/NavigationControls.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/NoToolsFound.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/NumberInputWithUnit.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/ResultsPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/SubcategoryHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/shared/ToolStep.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/showJS/ShowJSView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/sign/PenSizeSelector.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/sign/SavedSignaturesSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/singleLargePage/SingleLargePageSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/split/SplitAutomationSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/split/SplitSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/timestampPdf/TimestampPdfSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/toolPicker/FavoriteStar.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/toolPicker/ToolSearch.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/unlockPdfForms/UnlockPdfFormsSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureReportView.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/ValidateSignatureSettings.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/FieldBlock.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/FileSummaryHeader.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureSection.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/SignatureStatusBadge.stories.tsx create mode 100644 frontend/editor/src/core/components/tools/validateSignature/reportView/ThumbnailPreview.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/AnnotationMenuButtons.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/AnnotationSelectionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/AnnotationTypeButtons.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/CustomSearchLayer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/DocumentReadyWrapper.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/LinkLayer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/LocalEmbedPDF.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/NonPdfViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/RedactionPendingTracker.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/RedactionSelectionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SearchInterface.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SignatureFieldOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SignaturePlacementOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/SignaturePreviewLayer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/StampPlacementOverlay.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/TextSelectionMenu.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/CsvViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/HtmlViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/ImageViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/JsonViewer.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/NonPdfBanner.stories.tsx create mode 100644 frontend/editor/src/core/components/viewer/nonpdf/TextViewer.stories.tsx create mode 100644 frontend/editor/src/core/ui/FilePicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/AssistantButton.stories.tsx create mode 100644 frontend/editor/src/portal/components/BrandMarks.stories.tsx create mode 100644 frontend/editor/src/portal/components/DownloadEditorModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/ErrorBoundary.stories.tsx create mode 100644 frontend/editor/src/portal/components/HomeGreeting.stories.tsx create mode 100644 frontend/editor/src/portal/components/HomeHero.stories.tsx create mode 100644 frontend/editor/src/portal/components/LinkAccountFooterItem.stories.tsx create mode 100644 frontend/editor/src/portal/components/LoginScreen.stories.tsx create mode 100644 frontend/editor/src/portal/components/PortalChrome.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/AccountLinkPanel.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.stories.tsx delete mode 100644 frontend/editor/src/portal/components/account-link/LinkGate.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/CardPlaceholder.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/InvoicesList.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/PrepaidCapacityCard.stories.tsx create mode 100644 frontend/editor/src/portal/components/billing/PrepayModalHeader.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/PipelinesTable.stories.tsx create mode 100644 frontend/editor/src/portal/components/pipelines/ToolPicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/ClassificationLabelsSection.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyExternalApiConfig.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyPurviewConfig.stories.tsx create mode 100644 frontend/editor/src/portal/components/policies/PolicyPurviewReadConfig.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/CalendlyInline.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementAgreement.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementBanner.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementExtras.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementFlow.stories.tsx create mode 100644 frontend/editor/src/portal/components/procurement/ProcurementStages.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionForm.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionPicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/ConnectionTypePicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/S3ConnectionPicker.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/SourceModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/sources/SourceTypeIcon.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/PendingInvitations.stories.tsx create mode 100644 frontend/editor/src/portal/views/Integrations.stories.tsx create mode 100644 frontend/editor/src/portal/views/PipelineBuilder.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/AuthShell.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/EmailPasswordForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/ErrorMessage.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/SpringLoginForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/auth/ui/SupabaseLoginForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoAnimated.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/agents/StirlingLogoOutline.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyPiiField.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyRedactConfig.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/ChangeUserPasswordModal.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/DividerWithText.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/ManageBillingButton.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/EnterpriseRequiredBanner.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/OverviewHeader.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AccountSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiDocumentsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiGeneralSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiLimitsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAiModelsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminAuditSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/AdminFolderAccessSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/ApiKeys.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/LoginAgreementEditor.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/TeamDetailsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/TeamsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/ApiKeySection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/apiKeys/RefreshModal.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditChartsSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditClearDataSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditEventsTable.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditExportSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditFiltersForm.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditStatsCards.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/audit/AuditSystemStatus.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/plan/PlanCard.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsChart.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/usage/UsageAnalyticsTable.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/StripeCheckout.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.stories.tsx create mode 100644 frontend/editor/src/proprietary/components/workflow/ParticipantView.stories.tsx create mode 100644 frontend/vitest.config.ts diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 7ac5300a15..3e608855f3 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -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 # ============================================================ diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index be281b9ae3..c0436a3a82 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -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: {}, diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 339fe4f842..cd2e53153d 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -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 ( - - - - - {/* LinkProvider must wrap TierProvider: TierContext derives its tier - from useLink() (matches App.tsx's nesting). */} - - - - - - - - - - - - + + + + + + {/* LinkProvider must wrap TierProvider: TierContext derives its tier + from useLink() (matches App.tsx's nesting). */} + + + + + + + + + + + + + ); }; diff --git a/frontend/.storybook/vitest.config.ts b/frontend/.storybook/vitest.config.ts new file mode 100644 index 0000000000..9535f63a47 --- /dev/null +++ b/frontend/.storybook/vitest.config.ts @@ -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")], + }, + }, + ], + }, +}); diff --git a/frontend/.storybook/vitest.setup.ts b/frontend/.storybook/vitest.setup.ts new file mode 100644 index 0000000000..6265c9bf54 --- /dev/null +++ b/frontend/.storybook/vitest.setup.ts @@ -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); diff --git a/frontend/editor/src/core/components/AppLayout.stories.tsx b/frontend/editor/src/core/components/AppLayout.stories.tsx new file mode 100644 index 0000000000..4d7e6780cf --- /dev/null +++ b/frontend/editor/src/core/components/AppLayout.stories.tsx @@ -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) => ( + + + + + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const sampleContent = ( + + Workbench + Tool panels and file previews render in this area. + +); + +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( + , + ); + return () => setBanner(null); + }, [setBanner]); + return null; +} + +export const WithBanner: Story = { + args: { + children: sampleContent, + }, + decorators: [ + (Story) => ( + <> + + + > + ), + ], +}; diff --git a/frontend/editor/src/core/components/StorageStatsCard.stories.tsx b/frontend/editor/src/core/components/StorageStatsCard.stories.tsx new file mode 100644 index 0000000000..d8c61ec3b3 --- /dev/null +++ b/frontend/editor/src/core/components/StorageStatsCard.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx b/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx new file mode 100644 index 0000000000..9b896a62d4 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/BaseAnnotationTool.stories.tsx @@ -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 }) => ( + + {}} + deactivateDrawMode={() => {}} + activateSignaturePlacementMode={() => {}} + activateDeleteMode={() => {}} + updateDrawSettings={() => {}} + undo={() => {}} + redo={() => {}} + storeImageData={() => {}} + getImageData={() => undefined} + isPlacementMode={false} + signatureConfig={null} + setSignatureConfig={() => {}} + > + {children} + + +); + +// 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) => ( + Tool content +); + +const meta = { + title: "Annotation/BaseAnnotationTool", + component: BaseAnnotationTool, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + config: { + enableImageUpload: true, + showPlaceButton: true, + placeButtonText: "Place Image", + }, + children: , + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx b/frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx new file mode 100644 index 0000000000..fff7ffc224 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/ColorControl.stories.tsx @@ -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 = { + title: "Annotation/ColorControl", + component: ColorControl, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +function ColorControlDemo({ + initialColor = "#ff0000", + disabled, +}: { + initialColor?: string; + disabled?: boolean; +}) { + const [color, setColor] = useState(initialColor); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx b/frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx new file mode 100644 index 0000000000..1ffb5d76d2 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/ColorPicker.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +function ColorPickerDemo( + props: Partial>, +) { + const [color, setColor] = useState(props.selectedColor ?? "#cc0000"); + const [opacity, setOpacity] = useState(props.opacity ?? 100); + return ( + {}} + {...props} + selectedColor={color} + onColorChange={setColor} + opacity={opacity} + onOpacityChange={setOpacity} + /> + ); +} + +/** The base modal: swatches + hex picker, no opacity slider. */ +export const Default: Story = { + render: () => , +}; + +/** With the opacity slider shown, for tools that need translucent fills (e.g. highlight, watermark). */ +export const WithOpacity: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx new file mode 100644 index 0000000000..ebdc7282ce --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/DrawingCanvas.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +function DrawingCanvasDemo( + props: Partial>, +) { + const [penSize, setPenSize] = useState(3); + const [penSizeInput, setPenSizeInput] = useState("3"); + + return ( + {}} + onPenSizeChange={(size) => { + setPenSize(size); + setPenSizeInput(String(size)); + }} + onPenSizeInputChange={setPenSizeInput} + onSignatureDataChange={() => {}} + {...props} + /> + ); +} + +/** Empty canvas preview with the drawing modal closed. */ +export const Default: Story = { render: () => }; + +/** Preview disabled — clicking the canvas should not open the modal. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx b/frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx new file mode 100644 index 0000000000..b847f9046e --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/DrawingControls.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DrawingControls } from "@app/components/annotation/shared/DrawingControls"; + +const meta: Meta = { + title: "Annotation/DrawingControls", + component: DrawingControls, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx b/frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx new file mode 100644 index 0000000000..f8500795ba --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/ImageUploader.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx b/frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx new file mode 100644 index 0000000000..79bb3fddd8 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/OpacityControl.stories.tsx @@ -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 = { + title: "Annotation/OpacityControl", + component: OpacityControl, +}; +export default meta; +type Story = StoryObj; + +function OpacityControlDemo({ disabled }: { disabled?: boolean }) { + const [value, setValue] = useState(80); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx new file mode 100644 index 0000000000..b8b37dd980 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/PropertiesPopover.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx b/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx new file mode 100644 index 0000000000..4390f1b62d --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/TextInputWithFont.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +function TextInputWithFontDemo( + props: Partial>, +) { + 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 ( + + ); +} + +/** Full control set: text, font, size, colour and alignment. */ +export const Default: Story = { + render: () => , +}; + +/** Without the colour picker or alignment control, for tools that don't need them. */ +export const WithoutColorOrAlign: Story = { + render: () => ( + + ), +}; + +/** All fields disabled. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx b/frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx new file mode 100644 index 0000000000..46cc4980ad --- /dev/null +++ b/frontend/editor/src/core/components/annotation/shared/WidthControl.stories.tsx @@ -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 = { + title: "Annotation/WidthControl", + component: WidthControl, +}; +export default meta; +type Story = StoryObj; + +function WidthControlDemo({ + min = 1, + max = 12, + disabled, +}: { + min?: number; + max?: number; + disabled?: boolean; +}) { + const [value, setValue] = useState(Math.round((min + max) / 2)); + return ( + + ); +} + +export const Default: Story = { render: () => }; + +export const Disabled: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx b/frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx new file mode 100644 index 0000000000..002d07663f --- /dev/null +++ b/frontend/editor/src/core/components/annotation/tools/DrawingTool.stories.tsx @@ -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) => ( + + {}} + deactivateDrawMode={() => {}} + activateSignaturePlacementMode={() => {}} + activateDeleteMode={() => {}} + updateDrawSettings={() => {}} + undo={() => {}} + redo={() => {}} + storeImageData={() => {}} + getImageData={() => undefined} + isPlacementMode={false} + signatureConfig={null} + setSignatureConfig={() => {}} + > + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onDrawingChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onDrawingChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx b/frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx new file mode 100644 index 0000000000..ee0ae3ca24 --- /dev/null +++ b/frontend/editor/src/core/components/annotation/tools/ImageTool.stories.tsx @@ -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) => ( + + {}} + deactivateDrawMode={() => {}} + activateSignaturePlacementMode={() => {}} + activateDeleteMode={() => {}} + updateDrawSettings={() => {}} + undo={() => {}} + redo={() => {}} + storeImageData={() => {}} + getImageData={() => undefined} + isPlacementMode={false} + signatureConfig={null} + setSignatureConfig={() => {}} + > + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onImageChange: () => {}, + }, +}; + +export const Disabled: Story = { + args: { + onImageChange: () => {}, + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx b/frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx new file mode 100644 index 0000000000..55645e9a0b --- /dev/null +++ b/frontend/editor/src/core/components/fileEditor/FileEditorFileName.stories.tsx @@ -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 => ({ + 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; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx new file mode 100644 index 0000000000..760b4b7781 --- /dev/null +++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.stories.tsx @@ -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 => ({ + 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; +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx b/frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx new file mode 100644 index 0000000000..a047cd9e94 --- /dev/null +++ b/frontend/editor/src/core/components/fileManager/DragOverlay.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + isVisible: true, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Hidden: Story = { + args: { + isVisible: false, + }, +}; diff --git a/frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx b/frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx new file mode 100644 index 0000000000..38ba423deb --- /dev/null +++ b/frontend/editor/src/core/components/fileManager/FileDetails.stories.tsx @@ -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) => ( + + {}} + onNewFilesSelect={() => {}} + onClose={() => {}} + isFileSupported={() => true} + isOpen + onFileRemove={() => {}} + modalHeight="600px" + refreshRecentFiles={async () => {}} + isLoading={false} + activeFileIds={activeFileIds} + > + + + + ); +} + +const meta = { + title: "FileManager/FileDetails", + component: FileDetails, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +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])], +}; diff --git a/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx new file mode 100644 index 0000000000..db4a285fcc --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFilesDialog.stories.tsx @@ -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 => ({ + 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; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + files: [localFile], + }, +}; + +export const CloudOnly: Story = { + args: { + files: [cloudOnlyFile], + }, +}; + +export const LocalAndCloudChoice: Story = { + args: { + files: [localFile, cloudOnlyFile], + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx new file mode 100644 index 0000000000..891b72c58a --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/DeleteFolderDialog.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx new file mode 100644 index 0000000000..883e2fa8ba --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileDetailsPanel.stories.tsx @@ -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 => ({ + 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; +export default meta; +type Story = StoryObj; + +const singleFile = buildFileStub(); +const fileMap = new Map([ + [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([ + [fileA.id, fileA], + [fileB.id, fileB], + ]), + currentFolder: null, + onClose: () => {}, + onAddToWorkspace: () => {}, + onMove: () => {}, + onRemove: () => {}, + }; + })(), +}; + +export const LocalOnlyWithSaveToServer: Story = { + args: { + ...Default.args, + onSaveToServer: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx new file mode 100644 index 0000000000..d1cb9f32b6 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileGrid.stories.tsx @@ -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 => ({ + 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 ( + + + + ); +} + +const meta = { + title: "FilesPage/FileGrid", + component: FileGrid, + decorators: [withFileContext], + args: { + entries: fileEntries, + selectedFileIds: new Set(), + viewMode: "grid", + onSelectFile: () => {}, + onOpenFolder: () => {}, + onOpenFile: () => {}, + onMoveFiles: () => {}, + onMoveFolder: () => {}, + onRenameFolder: () => {}, + onDeleteFolder: () => {}, + onChangeFolderAppearance: () => {}, + onRemoveFiles: () => {}, + onPromptMoveFiles: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx b/frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx new file mode 100644 index 0000000000..2f3ede8857 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FileOriginBadge.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { FileOriginBadge } from "@app/components/filesPage/FileOriginBadge"; + +const meta: Meta = { + title: "FilesPage/FileOriginBadge", + component: FileOriginBadge, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx new file mode 100644 index 0000000000..4dc8cb3fd1 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderAppearancePicker.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoAppearanceSet: Story = { + args: { + folder: { + ...folder, + color: undefined, + icon: undefined, + }, + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx new file mode 100644 index 0000000000..e059cda62a --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderNameDialog.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx new file mode 100644 index 0000000000..7208834119 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderThumbnail.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx new file mode 100644 index 0000000000..e06bf42685 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/FolderTreeSidebar.stories.tsx @@ -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 ( + + + + + + + + ); +} + +const meta = { + title: "FilesPage/FolderTreeSidebar", + component: FolderTreeSidebar, + decorators: [withFolderContexts], + args: { + fileCounts: new Map([[ROOT_FOLDER_ID, 0]]), + onRequestNewFolder: () => {}, + onRenameFolder: () => {}, + onDeleteFolder: () => {}, + onMoveFilesIntoFolder: async () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithFileCounts: Story = { + args: { + fileCounts: new Map([[ROOT_FOLDER_ID, 12]]), + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx b/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx new file mode 100644 index 0000000000..d01a697f04 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/MoveToFolderDialog.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx b/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx new file mode 100644 index 0000000000..14ba0755b2 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/OpenInNewWindowMenuItem.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + file: mockFile, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx new file mode 100644 index 0000000000..1d5ce95e13 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/VersionHistoryModal.stories.tsx @@ -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 ( + + + + + + + + ); +} + +const meta = { + title: "FilesPage/VersionHistoryModal", + component: VersionHistoryModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx new file mode 100644 index 0000000000..c86f7d8124 --- /dev/null +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.stories.tsx @@ -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 => ({ + 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; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx new file mode 100644 index 0000000000..7878980f0b --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +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: , + 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: 2, + 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: , + 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, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx new file mode 100644 index 0000000000..7784d7dfdf --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingStepper.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +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 } }; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx new file mode 100644 index 0000000000..d3dedcf848 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.stories.tsx @@ -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 files.", + }, + { + 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 ( + { + 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; + +export default meta; + +type Story = StoryObj; + +/** 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 } }; diff --git a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx new file mode 100644 index 0000000000..46aa79429f --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx new file mode 100644 index 0000000000..3a91079712 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/AnalyticsChoiceSlide.stories.tsx @@ -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 ( + + {slide.title} + {slide.body} + + ); +} + +const meta = { + title: "Onboarding/AnalyticsChoiceSlide", + component: AnalyticsChoiceSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; + +export const WithError: Story = { + args: { analyticsError: "Failed to save your analytics preference." }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx new file mode 100644 index 0000000000..d436942893 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/AnimatedSlideBackground.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + gradientStops: ["#6E56CF", "#3B82F6"], + circles, + isActive: true, + slideKey: "welcome", + }, +}; + +export const Inactive: Story = { + args: { + ...Default.args, + isActive: false, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx new file mode 100644 index 0000000000..8618158336 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +/** 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: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx new file mode 100644 index 0000000000..14b9b4009b --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/FirstLoginSlide.stories.tsx @@ -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 ( + + {slide.title} + {slide.body} + + ); +} + +const meta = { + title: "Onboarding/FirstLoginSlide", + component: FirstLoginSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: { username: "admin" } }; + +export const UsingDefaultCredentials: Story = { + args: { username: "admin", usingDefaultCredentials: true }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx new file mode 100644 index 0000000000..2aafbaed68 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/PlanOverviewSlide.stories.tsx @@ -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 ( + + {slide.title} + {slide.body} + + ); +} + +const meta = { + title: "Onboarding/Slides/PlanOverviewSlide", + component: PlanOverviewStage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx new file mode 100644 index 0000000000..b5e6ed4659 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.stories.tsx @@ -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 ( + + {slide.title} + {slide.body} + + ); +} + +const meta = { + title: "Onboarding/ProcessorIntroSlide", + component: ProcessorIntroSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx new file mode 100644 index 0000000000..73981e4bfb --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/SecurityCheckSlide.stories.tsx @@ -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 ( + + {slide.title} + {slide.body} + + ); +} + +const meta = { + title: "Onboarding/SecurityCheckSlide", + component: SecurityCheckSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; + +export const AdminSelected: Story = { args: { initialRole: "admin" } }; + +export const UserSelected: Story = { args: { initialRole: "user" } }; diff --git a/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx new file mode 100644 index 0000000000..fae21c8748 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ServerLicenseSlide.stories.tsx @@ -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 ( + + {slide.title} + {slide.body} + + ); +} + +const meta = { + title: "Onboarding/Slides/ServerLicenseSlide", + component: ServerLicenseStage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** 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, + }, + }, +}; diff --git a/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx new file mode 100644 index 0000000000..f3ae1a76d4 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/WelcomeSlide.stories.tsx @@ -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 ( + + {slide.title} + {slide.body} + + ); +} + +const meta = { + title: "Onboarding/WelcomeSlide", + component: WelcomeSlideDemo, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { args: {} }; diff --git a/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx new file mode 100644 index 0000000000..be7616f1f9 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/DragDropGrid.stories.tsx @@ -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>, + 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 ( + { + 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} + + ); +}; + +const noopReorder = () => {}; + +const meta = { + title: "PageEditor/DragDropGrid", + component: DragDropGrid, + parameters: { layout: "fullscreen" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const ScrollDecorator = (StoryComponent: React.ComponentType) => ( + + + +); + +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], +}; diff --git a/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx b/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx new file mode 100644 index 0000000000..5838a6c7d4 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/PageEditorControls.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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(), + 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(["page-1", "page-2"]), + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx new file mode 100644 index 0000000000..5f3f1138d5 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx new file mode 100644 index 0000000000..b913dbbde5 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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), + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx new file mode 100644 index 0000000000..3f3ff93a94 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx new file mode 100644 index 0000000000..3b5d8421d1 --- /dev/null +++ b/frontend/editor/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx b/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx new file mode 100644 index 0000000000..a4912a750c --- /dev/null +++ b/frontend/editor/src/core/components/shared/AllToolsNavButton.stories.tsx @@ -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 ( + + + + + + + + + + ); +} + +const meta = { + title: "Shared/AllToolsNavButton", + component: AllToolsNavButton, + decorators: [withProviders], + args: { + activeButton: "tools", + setActiveButton: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** 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 ( + + ); +} + +/** Not the active button — a different quick-access item is selected. */ +export const Inactive: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx new file mode 100644 index 0000000000..6d4449fe58 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitch.stories.tsx @@ -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 = { + title: "Shared/AppSwitch", + component: AppSwitch, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx b/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx new file mode 100644 index 0000000000..cfa1f223ee --- /dev/null +++ b/frontend/editor/src/core/components/shared/BulkShareModal.stories.tsx @@ -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 ( + + + + ); +} + +const meta = { + title: "Shared/BulkShareModal", + component: BulkShareModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + files: mockFiles, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** 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) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx b/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx new file mode 100644 index 0000000000..0b800195f5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/BulkUploadToServerModal.stories.tsx @@ -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 ( + + + + ); +} + +const meta = { + title: "Shared/BulkUploadToServerModal", + component: BulkUploadToServerModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + files: mockFiles, + }, +}; + +export const SingleFile: Story = { + args: { + opened: true, + files: [mockFiles[0]], + }, +}; diff --git a/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx b/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx new file mode 100644 index 0000000000..88183305d3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ButtonToggle.stories.tsx @@ -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 = { + title: "Shared/ButtonToggle", + component: ButtonToggle, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +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 ( + + ); +} + +/** Default toggle with two options, each carrying a label + description. */ +export const Default: Story = { render: () => }; + +/** Disabled state — the selected segment must still be legible. */ +export const Disabled: Story = { render: () => }; + +/** Small size variant. */ +export const Small: Story = { render: () => }; diff --git a/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx b/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx new file mode 100644 index 0000000000..9d44784233 --- /dev/null +++ b/frontend/editor/src/core/components/shared/CloudStorageIcons.stories.tsx @@ -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 = { + title: "Shared/CloudStorageIcons", + component: GoogleDriveIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const GoogleDrive: Story = { + args: { colored: true }, +}; + +export const GoogleDriveMuted: Story = { + args: { colored: false }, +}; + +export const OneDrive: Story = { + render: (args) => , + args: { colored: true }, +}; + +export const Dropbox: Story = { + render: (args) => , + args: { colored: true }, +}; diff --git a/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx b/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx new file mode 100644 index 0000000000..c52f1c9298 --- /dev/null +++ b/frontend/editor/src/core/components/shared/DropdownListWithFooter.stories.tsx @@ -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 = { + title: "Shared/DropdownListWithFooter", + component: DropdownListWithFooter, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +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 ( + setValue(v as string)} + /> + ); +} + +function MultiSelectDemo() { + const [value, setValue] = useState(["single"]); + return ( + setValue(v as string[])} + multiSelect + searchable + footer={ + setValue([])} + > + Clear selection + + } + /> + ); +} + +/** Single-select dropdown with a disabled item. */ +export const Default: Story = { render: () => }; + +/** Multi-select with search box and a footer action. */ +export const MultiSelectWithFooter: Story = { + render: () => , +}; + +/** No items available — empty state message inside the dropdown. */ +export const Empty: Story = { + render: () => { + return ( + {}} + /> + ); + }, +}; diff --git a/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx new file mode 100644 index 0000000000..b49fbd2806 --- /dev/null +++ b/frontend/editor/src/core/components/shared/EditableSecretField.stories.tsx @@ -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 = { + title: "Shared/EditableSecretField", + component: EditableSecretField, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +function SecretFieldDemo({ + initialValue = "", + ...rest +}: { + initialValue?: string; + label?: string; + description?: string; + placeholder?: string; + disabled?: boolean; + error?: string; +}) { + const [value, setValue] = useState(initialValue); + return ( + + ); +} + +/** Empty value: renders a normal password input. */ +export const Default: Story = { + render: () => , +}; + +/** Backend returned a masked value (********): shows a read-only display + Edit button. */ +export const Masked: Story = { + render: () => , +}; + +/** Disabled state — the Edit button on a masked value must still read as inert. */ +export const MaskedDisabled: Story = { + render: () => , +}; + +/** Validation error surfaced under the password input. */ +export const WithError: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx new file mode 100644 index 0000000000..dd83b6eece --- /dev/null +++ b/frontend/editor/src/core/components/shared/EncryptedPdfUnlockModal.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +function UnlockDemo( + props: Partial>, +) { + const [password, setPassword] = useState(""); + return ( + {}} + onUnlockAll={() => {}} + onSkip={() => {}} + {...props} + /> + ); +} + +export const Default: Story = { render: () => }; + +export const MultipleFilesRemaining: Story = { + render: () => , +}; + +export const IncorrectPassword: Story = { + render: () => ( + + ), +}; + +export const Processing: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx new file mode 100644 index 0000000000..22a5f06feb --- /dev/null +++ b/frontend/editor/src/core/components/shared/ErrorBoundary.stories.tsx @@ -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 = { + title: "Shared/ErrorBoundary", + component: ErrorBoundary, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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: Protected content renders normally., + }, +}; + +/** A child throwing during render is caught, showing the default fallback with a retry button. */ +export const CaughtError: Story = { + args: { + children: , + }, +}; + +/** A custom fallback component receives the error and a retry callback. */ +export const CustomFallback: Story = { + args: { + children: , + fallback: ({ error, retry }) => ( + + Custom fallback: {error?.message} + + Retry + + + ), + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileCard.stories.tsx b/frontend/editor/src/core/components/shared/FileCard.stories.tsx new file mode 100644 index 0000000000..48edd5854e --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileCard.stories.tsx @@ -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) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx new file mode 100644 index 0000000000..b42f7085a2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDocIcon.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { variant: "pdf" }, +}; + +/** All file-type variants, each using its own default accent color. */ +export const AllVariants: Story = { + render: () => ( + + + + + + + + + + ), +}; + +/** Explicit `color` overrides the variant's default accent. */ +export const CustomColor: Story = { + args: { variant: "pdf", color: "#e64980" }, +}; diff --git a/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx new file mode 100644 index 0000000000..bb6b7d2670 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileDropdownMenu.stories.tsx @@ -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 = { + title: "Shared/FileDropdownMenu", + component: FileDropdownMenu, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileGrid.stories.tsx b/frontend/editor/src/core/components/shared/FileGrid.stories.tsx new file mode 100644 index 0000000000..aea431852a --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileGrid.stories.tsx @@ -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 ( + + + + ); +} + +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 => ({ + 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; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const SearchAndSort: Story = { + args: { + showSearch: true, + showSort: true, + onDeleteAll: () => {}, + }, +}; + +export const Empty: Story = { + args: { + files: [], + showSearch: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx new file mode 100644 index 0000000000..51eac29632 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePickerModal.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +/** 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: [], + }, +}; diff --git a/frontend/editor/src/core/components/shared/FilePreview.stories.tsx b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx new file mode 100644 index 0000000000..30e2fec884 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FilePreview.stories.tsx @@ -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) => ( + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +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: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx new file mode 100644 index 0000000000..3d01996b99 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileSelectorPicker.stories.tsx @@ -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 ( + + + + + + ); +} + +const meta = { + title: "Shared/FileSelectorPicker", + component: FileSelectorPicker, + parameters: { layout: "padded" }, + args: { + onSelect: () => {}, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const CustomPlaceholder: Story = { + args: { + placeholder: "Choose a comparison file", + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx new file mode 100644 index 0000000000..d0411f1be9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FileUploadButton.stories.tsx @@ -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 = { + title: "Shared/FileUploadButton", + component: FileUploadButton, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +function UploadDemo({ + initialFile, + ...rest +}: { + initialFile?: File; + disabled?: boolean; + accept?: string; + placeholder?: string; +}) { + const [file, setFile] = useState(initialFile); + return ( + setFile(next ?? undefined)} + {...rest} + /> + ); +} + +/** No file chosen yet — shows the default "Choose File" placeholder. */ +export const Default: Story = { render: () => }; + +/** A file has already been selected — the button shows its name. */ +export const WithFileSelected: Story = { + render: () => ( + + ), +}; + +/** Disabled state — should still be legible but non-interactive. */ +export const Disabled: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx new file mode 100644 index 0000000000..30941579f4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FirstLoginModal.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + username: "jane.doe", + onPasswordChanged: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/FitText.stories.tsx b/frontend/editor/src/core/components/shared/FitText.stories.tsx new file mode 100644 index 0000000000..63b93d0ed7 --- /dev/null +++ b/frontend/editor/src/core/components/shared/FitText.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import FitText from "@app/components/shared/FitText"; + +const meta: Meta = { + title: "Shared/FitText", + component: FitText, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/Footer.stories.tsx b/frontend/editor/src/core/components/shared/Footer.stories.tsx new file mode 100644 index 0000000000..af1d4a5805 --- /dev/null +++ b/frontend/editor/src/core/components/shared/Footer.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx b/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx new file mode 100644 index 0000000000..dc391d0e9a --- /dev/null +++ b/frontend/editor/src/core/components/shared/HoverActionMenu.stories.tsx @@ -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: , + label: "Edit", + onClick: () => {}, + }, + { + id: "download", + icon: , + label: "Download", + onClick: () => {}, + }, + { + id: "delete", + icon: , + label: "Delete", + onClick: () => {}, + color: "var(--text-error)", + }, +]; + +const meta: Meta = { + title: "Shared/HoverActionMenu", + component: HoverActionMenu, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +/** 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", + }, + ], + }, +}; diff --git a/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx new file mode 100644 index 0000000000..5fad071d05 --- /dev/null +++ b/frontend/editor/src/core/components/shared/InfoBanner.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx b/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx new file mode 100644 index 0000000000..e51e068ff9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LandingDocumentStack.stories.tsx @@ -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 = { + title: "Shared/LandingDocumentStack", + component: LandingDocumentStack, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx b/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx new file mode 100644 index 0000000000..2ffcba96c1 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LanguageSelector.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Compact: Story = { + args: { + compact: true, + tooltip: "Change language", + }, +}; + +export const TopStartPosition: Story = { + args: { + position: "top-start", + offset: 4, + }, +}; diff --git a/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx b/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx new file mode 100644 index 0000000000..1ce948a671 --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoadingFallback.stories.tsx @@ -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 = { + title: "Shared/LoadingFallback", + component: LoadingFallback, + parameters: { layout: "fullscreen" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx b/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx new file mode 100644 index 0000000000..5f38ef168b --- /dev/null +++ b/frontend/editor/src/core/components/shared/LocalIcon.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LocalIcon from "@app/components/shared/LocalIcon"; + +const meta: Meta = { + title: "Shared/LocalIcon", + component: LocalIcon, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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", + }, +}; diff --git a/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx b/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx new file mode 100644 index 0000000000..d8b160692a --- /dev/null +++ b/frontend/editor/src/core/components/shared/LoginAgreementModal.stories.tsx @@ -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 = { + title: "Shared/LoginAgreementModal", + component: LoginAgreementModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx b/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx new file mode 100644 index 0000000000..58e7e86aa4 --- /dev/null +++ b/frontend/editor/src/core/components/shared/MobileUploadModal.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MobileUploadModal from "@app/components/shared/MobileUploadModal"; + +const meta: Meta = { + title: "Shared/MobileUploadModal", + component: MobileUploadModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + onFilesReceived: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx b/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx new file mode 100644 index 0000000000..cb8281399b --- /dev/null +++ b/frontend/editor/src/core/components/shared/MultiSelectControls.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import MultiSelectControls from "@app/components/shared/MultiSelectControls"; + +const meta: Meta = { + title: "Shared/MultiSelectControls", + component: MultiSelectControls, + parameters: { layout: "padded" }, + args: { + selectedCount: 3, + onClearSelection: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx b/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx new file mode 100644 index 0000000000..ac6636125e --- /dev/null +++ b/frontend/editor/src/core/components/shared/NavigationWarningModal.stories.tsx @@ -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 ( + + + + + + + + ); +} + +const meta = { + title: "Shared/NavigationWarningModal", + component: NavigationWarningModal, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** 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, + }, +}; diff --git a/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx b/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx new file mode 100644 index 0000000000..4b8d936cc5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ObscuredOverlay.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ObscuredOverlay from "@app/components/shared/ObscuredOverlay"; + +const meta: Meta = { + title: "Shared/ObscuredOverlay", + component: ObscuredOverlay, + parameters: { layout: "padded" }, + decorators: [ + (S) => ( + + + + ), + ], +}; +export default meta; +type Story = StoryObj; + +const Content = () => ( + + Underlying content that gets obscured. + +); + +export const Unobscured: Story = { + args: { + obscured: false, + children: , + }, +}; + +export const Obscured: Story = { + args: { + obscured: true, + overlayMessage: "This feature requires an upgrade", + buttonText: "Upgrade", + onButtonClick: () => {}, + children: , + }, +}; + +export const RoundedCorners: Story = { + args: { + obscured: true, + overlayMessage: "Locked", + borderRadius: "0.5rem", + children: , + }, +}; diff --git a/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx new file mode 100644 index 0000000000..e14d9dc030 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PageSelectionSyntaxHint.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +/** 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", + }, +}; diff --git a/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx new file mode 100644 index 0000000000..81969bf615 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PolicyBadges.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +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: [], + }, +}; diff --git a/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx b/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx new file mode 100644 index 0000000000..3103eb3962 --- /dev/null +++ b/frontend/editor/src/core/components/shared/PrivateContent.stories.tsx @@ -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 = { + title: "Shared/PrivateContent", + component: PrivateContent, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "sensitive-filename.pdf", + }, +}; + +export const WithCustomClassName: Story = { + args: { + children: "sensitive-filename.pdf", + className: "custom-class", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx b/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx new file mode 100644 index 0000000000..a616480d36 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ShareFileModal.stories.tsx @@ -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 ( + + + + ); +} + +const meta = { + title: "Shared/ShareFileModal", + component: ShareFileModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + file: mockFile, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** 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) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx b/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx new file mode 100644 index 0000000000..3fff68cbf3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ShareManagementModal.stories.tsx @@ -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 ( + + + + ); +} + +const meta = { + title: "Shared/ShareManagementModal", + component: ShareManagementModal, + parameters: { layout: "fullscreen" }, + args: { + opened: true, + onClose: () => {}, + file: mockFile, + }, + decorators: [withFileContext], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** 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) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx b/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx new file mode 100644 index 0000000000..50c754a3cf --- /dev/null +++ b/frontend/editor/src/core/components/shared/SkeletonLoader.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import SkeletonLoader from "@app/components/shared/SkeletonLoader"; + +const meta: Meta = { + title: "Shared/SkeletonLoader", + component: SkeletonLoader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +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) => ( + + + + ), + ], +}; + +export const Block: Story = { + args: { type: "block", width: 120, height: 20 }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolChain.stories.tsx b/frontend/editor/src/core/components/shared/ToolChain.stories.tsx new file mode 100644 index 0000000000..b8ef29f9b5 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolChain.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +/** 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", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx b/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx new file mode 100644 index 0000000000..8994f34157 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolIcon.stories.tsx @@ -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; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + }, +}; + +/** Visually unavailable state, for tools the user can't run. */ +export const ReducedOpacity: Story = { + args: { + icon: , + opacity: 0.25, + }, +}; + +/** No right margin, for inline placement. */ +export const NoMargin: Story = { + args: { + icon: , + marginRight: "0", + }, +}; diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx b/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx new file mode 100644 index 0000000000..bfcaedba4f --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.stories.tsx @@ -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 = { + title: "Shared/ToolPanelHeader", + component: ToolPanelHeader, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + icon: , + title: "Split", + }, +}; + +/** Trailing close button only renders when `onClose` is supplied. */ +export const WithCloseButton: Story = { + args: { + icon: , + title: "Split", + onClose: () => {}, + closeLabel: "Close tool panel", + }, +}; diff --git a/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx b/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx new file mode 100644 index 0000000000..9d7626edbf --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateModal.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +/** 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.", + }, + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx b/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx new file mode 100644 index 0000000000..4abfdece22 --- /dev/null +++ b/frontend/editor/src/core/components/shared/UpdateStartupPopup.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +/** 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) => ( + + + + ), + ], +}; + +/** + * 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) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx b/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx new file mode 100644 index 0000000000..50f1e9e0ad --- /dev/null +++ b/frontend/editor/src/core/components/shared/UploadToServerModal.stories.tsx @@ -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 ( + + + + ); +} + +const meta = { + title: "Shared/UploadToServerModal", + component: UploadToServerModal, + decorators: [withProviders], + args: { + onClose: () => {}, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + file: mockFile, + }, +}; + +export const AlreadyUploaded: Story = { + args: { + opened: true, + file: mockUploadedFile, + }, +}; diff --git a/frontend/editor/src/core/components/shared/UserSelector.stories.tsx b/frontend/editor/src/core/components/shared/UserSelector.stories.tsx new file mode 100644 index 0000000000..760eb0f5ee --- /dev/null +++ b/frontend/editor/src/core/components/shared/UserSelector.stories.tsx @@ -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; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Disabled: Story = { + args: { disabled: true }, +}; + +export const CustomPlaceholder: Story = { + args: { placeholder: "Add collaborators..." }, +}; diff --git a/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx b/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx new file mode 100644 index 0000000000..4f43ee145c --- /dev/null +++ b/frontend/editor/src/core/components/shared/ZipWarningModal.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ZipWarningModal from "@app/components/shared/ZipWarningModal"; + +const meta: Meta = { + title: "Shared/ZipWarningModal", + component: ZipWarningModal, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + fileCount: 42, + zipFileName: "large-archive.zip", + onConfirm: () => {}, + onCancel: () => {}, + }, +}; + +export const SingleFile: Story = { + args: { + opened: true, + fileCount: 1, + zipFileName: "small-archive.zip", + onConfirm: () => {}, + onCancel: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx b/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx new file mode 100644 index 0000000000..7544729824 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/LoginRequiredBanner.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; + +const meta = { + title: "Shared/Config/LoginRequiredBanner", + component: LoginRequiredBanner, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + show: true, + }, +}; + +export const Hidden: Story = { + args: { + show: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx b/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx new file mode 100644 index 0000000000..0dc2b09db3 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/OverviewHeader.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { OverviewHeader } from "@app/components/shared/config/OverviewHeader"; + +const meta = { + title: "Shared/Config/OverviewHeader", + component: OverviewHeader, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx b/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx new file mode 100644 index 0000000000..85df5f4243 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/PendingBadge.stories.tsx @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import PendingBadge from "@app/components/shared/config/PendingBadge"; + +const meta = { + title: "Shared/Config/PendingBadge", + component: PendingBadge, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + show: true, + }, +}; + +export const Hidden: Story = { + args: { + show: false, + }, +}; + +export const LargeSize: Story = { + args: { + show: true, + size: "lg", + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx new file mode 100644 index 0000000000..d9f250da6e --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/RestartConfirmationModal.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal"; + +const meta = { + title: "Shared/Config/RestartConfirmationModal", + component: RestartConfirmationModal, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + opened: true, + onClose: () => {}, + onRestart: () => {}, + }, +}; + +export const Closed: Story = { + args: { + opened: false, + onClose: () => {}, + onRestart: () => {}, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx new file mode 100644 index 0000000000..28d5a1c3e0 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/SettingsSearchBar.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; +import type { ConfigNavSection } from "@app/components/shared/config/configNavSections"; + +const mockConfigNavSections: ConfigNavSection[] = [ + { + title: "Preferences", + items: [ + { + key: "general", + label: "General", + icon: "settings-rounded", + component: null, + }, + { + key: "hotkeys", + label: "Keyboard Shortcuts", + icon: "keyboard-rounded", + component: null, + }, + ], + }, + { + title: "Workspace", + items: [ + { + key: "people", + label: "People", + icon: "group-rounded", + component: null, + }, + { + key: "teams", + label: "Teams", + icon: "groups-rounded", + component: null, + disabled: true, + }, + ], + }, +]; + +const meta = { + title: "Shared/Config/SettingsSearchBar", + component: SettingsSearchBar, + parameters: { layout: "padded" }, + args: { + configNavSections: mockConfigNavSections, + onNavigate: async () => {}, + isMobile: false, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Mobile: Story = { + args: { + isMobile: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx new file mode 100644 index 0000000000..ae94dd7117 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/SettingsStickyFooter.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter"; + +const meta = { + title: "Shared/Config/SettingsStickyFooter", + component: SettingsStickyFooter, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isDirty: true, + saving: false, + loginEnabled: true, + onSave: () => {}, + onDiscard: () => {}, + }, +}; + +export const Saving: Story = { + args: { + ...Default.args, + saving: true, + }, +}; + +export const Hidden: Story = { + args: { + ...Default.args, + isDirty: false, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx new file mode 100644 index 0000000000..08047afb17 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ThemeProvider } from "@app/components/shared/ThemeProvider"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads theme/tool-panel preferences via usePreferences()/useTheme() and server +// config via useAppConfig() — none of which the Storybook preview's own provider +// tree supplies (those are the portal contexts), so wrap here. AppConfigProvider +// uses autoFetch off so stories render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/ConfigSections/GeneralSection", + component: GeneralSection, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** Login enabled, no backend version known yet — Software Updates section and admin banner stay hidden. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Backend version known — shows the Software Updates section with version info. */ +export const WithBackendVersion: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Login disabled — the "For System Administrators" banner shows, prompting the env vars to enable it. */ +export const AdminBanner: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx new file mode 100644 index 0000000000..d7ad738458 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/HelpSection.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HelpSection from "@app/components/shared/config/configSections/HelpSection"; + +const meta = { + title: "Shared/Config/ConfigSections/HelpSection", + component: HelpSection, + parameters: { layout: "padded" }, + args: { + onRequestClose: () => {}, + }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + isAdmin: false, + }, +}; + +export const Admin: Story = { + args: { + isAdmin: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx new file mode 100644 index 0000000000..94420ad5cd --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/HotkeysSection.stories.tsx @@ -0,0 +1,40 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; +import { NavigationProvider } from "@app/contexts/NavigationContext"; +import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; +import { HotkeyProvider } from "@app/contexts/HotkeyContext"; + +/** + * HotkeyContext reads the tool registry and selection state off + * ToolWorkflowContext, so both providers must wrap the story. + */ +function withProviders(Story: () => ReactElement) { + return ( + + + + + + + + + + + + ); +} + +const meta = { + title: "Shared/Config/ConfigSections/HotkeysSection", + component: HotkeysSection, + decorators: [withProviders], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** Full tool list with default keyboard shortcuts assigned. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx new file mode 100644 index 0000000000..6b0c958725 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/LegalSection.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import LegalSection from "@app/components/shared/config/configSections/LegalSection"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads legal document links and the analytics flag via useAppConfig() (the +// preview's own provider tree doesn't supply this — that's the portal +// context), so wrap here. AppConfigProvider uses autoFetch off so stories +// render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/ConfigSections/LegalSection", + component: LegalSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** All optional legal documents configured, analytics disabled — no Cookie Preferences card. */ +export const Default: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Analytics enabled — adds the Cookie Preferences card with its "Manage" button. */ +export const WithAnalyticsEnabled: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** No legal documents configured — only Privacy Policy and Terms show, using the stirling.com fallback links. */ +export const MinimalLinks: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx new file mode 100644 index 0000000000..9b04d088e9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/Overview.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import Overview from "@app/components/shared/config/configSections/Overview"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +// Reads config via useAppConfig() — no props. Wrap in AppConfigProvider with +// autoFetch off so stories render a fixed config instead of hitting the API. +const meta = { + title: "Shared/Config/Overview", + component: Overview, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +/** No config resolved yet (default context state) — shows the loading spinner. */ +export const Default: Story = {}; + +/** Config loaded — renders the basic/security/system/integration sections. */ +export const Loaded: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; + +/** Config resolved but carrying a server-reported warning. */ +export const WithWarning: Story = { + decorators: [ + (StoryComponent) => ( + + + + ), + ], +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx new file mode 100644 index 0000000000..0d7a44c38f --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ProviderCard from "@app/components/shared/config/configSections/ProviderCard"; +import type { Provider } from "@app/components/shared/config/configSections/providerDefinitions"; + +const mockProvider: Provider = { + id: "google", + name: "Google", + icon: "key-rounded", + type: "oauth2", + scope: "Sign-in authentication", + documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth", + fields: [ + { + key: "clientId", + type: "text", + label: "Client ID", + description: "The OAuth2 client ID from Google Cloud Console", + placeholder: "your-client-id.apps.googleusercontent.com", + }, + { + key: "clientSecret", + type: "password", + label: "Client Secret", + description: "The OAuth2 client secret from Google Cloud Console", + }, + { + key: "scopes", + type: "tags", + label: "Scopes", + description: "OAuth2 scopes to request", + defaultValue: ["email", "profile"], + }, + { + key: "autoProvision", + type: "switch", + label: "Auto Provision Users", + description: "Automatically create accounts for new sign-ins", + defaultValue: false, + }, + ], +}; + +const meta = { + title: "Shared/Config/ConfigSections/ProviderCard", + component: ProviderCard, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + provider: mockProvider, + isConfigured: false, + }, +}; + +export const Configured: Story = { + args: { + provider: mockProvider, + isConfigured: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + autoProvision: true, + }, + }, +}; + +export const ReadOnly: Story = { + args: { + provider: mockProvider, + isConfigured: true, + readOnly: true, + settings: { + clientId: "example-client-id.apps.googleusercontent.com", + scopes: ["email", "profile"], + }, + }, +}; diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx index 93999ce438..c24178635c 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -32,10 +32,16 @@ interface ProviderCardProps { readOnly?: boolean; } +// Shared default so an omitted `settings` prop keeps the same identity across +// renders. An inline `settings = {}` would allocate a new object every render, +// and the sync effect below lists `settings` as a dependency — so it would +// re-run and setState on every render, looping until React bails out. +const NO_SETTINGS: Record = {}; + export default function ProviderCard({ provider, isConfigured, - settings = {}, + settings = NO_SETTINGS, onSave, onDisconnect, onChange, diff --git a/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx new file mode 100644 index 0000000000..7d149b2b3b --- /dev/null +++ b/frontend/editor/src/core/components/shared/config/configSections/ThirdPartyLicensesSection.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import ThirdPartyLicensesSection, { + FrontendThirdPartyLicensesSection, +} from "@app/components/shared/config/configSections/ThirdPartyLicensesSection"; + +const meta = { + title: "Shared/Config/ConfigSections/ThirdPartyLicensesSection", + component: ThirdPartyLicensesSection, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Frontend: Story = { + render: () => , +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx new file mode 100644 index 0000000000..f76bc47831 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentStack.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box } from "@mantine/core"; +import DocumentStack from "@app/components/shared/filePreview/DocumentStack"; + +const meta = { + title: "Shared/FilePreview/DocumentStack", + component: DocumentStack, + parameters: { layout: "padded" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const previewContent = ( + +); + +export const SingleFile: Story = { + args: { + totalFiles: 1, + children: previewContent, + }, +}; + +export const TwoFiles: Story = { + args: { + totalFiles: 2, + children: previewContent, + }, +}; + +export const ManyFiles: Story = { + args: { + totalFiles: 5, + children: previewContent, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx new file mode 100644 index 0000000000..d410c3af78 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/DocumentThumbnail.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; + +const mockFile = new File(["dummy content"], "sample-report.pdf", { + type: "application/pdf", +}); + +const meta = { + title: "Shared/FilePreview/DocumentThumbnail", + component: DocumentThumbnail, + parameters: { layout: "padded" }, + args: { + file: mockFile, + }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithThumbnail: Story = { + args: { + thumbnail: + "data:image/svg+xml;utf8," + + encodeURIComponent( + '', + ), + }, +}; + +export const Encrypted: Story = { + args: { + isEncrypted: true, + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + }, +}; diff --git a/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx new file mode 100644 index 0000000000..560d129843 --- /dev/null +++ b/frontend/editor/src/core/components/shared/filePreview/HoverOverlay.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Box, Text } from "@mantine/core"; +import HoverOverlay from "@app/components/shared/filePreview/HoverOverlay"; + +const meta = { + title: "Shared/FilePreview/HoverOverlay", + component: HoverOverlay, + parameters: { layout: "padded" }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + +
Tool panels and file previews render in this area.