From 1cb32178ed6a74b7cacd75d01ff44948034e16da Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 9 Jan 2026 14:55:54 -0500 Subject: [PATCH 01/23] chore: install shepherd.js and react-shepherd for user tour --- frontend/package-lock.json | 47 +++++++++++++++++++++++++++++++++++++- frontend/package.json | 2 ++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5480fd83..2853426d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -27,7 +27,9 @@ "react-resizable-panels": "^3.0.2", "react-router": "^7.12.0", "react-router-dom": "^7.12.0", + "react-shepherd": "^6.1.9", "react-syntax-highlighter": "^16.1.0", + "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", "zarrita": "^0.5.1" }, @@ -1520,6 +1522,13 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@tanstack/eslint-plugin-query": { "version": "5.91.2", "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.91.2.tgz", @@ -3645,6 +3654,15 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -8755,6 +8773,20 @@ "react-dom": ">=18" } }, + "node_modules/react-shepherd": { + "version": "6.1.9", + "resolved": "https://registry.npmjs.org/react-shepherd/-/react-shepherd-6.1.9.tgz", + "integrity": "sha512-kSFs7ER9+tDAQ9a80CGTaWHpuNf/6RNnnAqtPxFqZSt5NnlKi6T8/E93sYMPOibhvdtpG5pIZpeT3JI1+Ppqiw==", + "license": "MIT", + "dependencies": { + "shepherd.js": "14.5.1" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "typescript": "^5.0.0" + } + }, "node_modules/react-syntax-highlighter": { "version": "16.1.0", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.0.tgz", @@ -9327,6 +9359,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shepherd.js": { + "version": "14.5.1", + "resolved": "https://registry.npmjs.org/shepherd.js/-/shepherd.js-14.5.1.tgz", + "integrity": "sha512-VuvPvLG1QjNOLP7AIm2HGyfmxEIz8QdskvWOHwUcxLDibYWjLRBmCWd8LSL5FlwhBW7D/GU+3gNVC/ASxAWdxg==", + "license": "AGPL-3.0", + "dependencies": { + "@floating-ui/dom": "^1.7.0", + "@scarf/scarf": "^1.4.0", + "deepmerge-ts": "^7.1.1" + }, + "engines": { + "node": "18.* || >= 20" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -10225,7 +10271,6 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/frontend/package.json b/frontend/package.json index d8c03168..b823f694 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -48,7 +48,9 @@ "react-resizable-panels": "^3.0.2", "react-router": "^7.12.0", "react-router-dom": "^7.12.0", + "react-shepherd": "^6.1.9", "react-syntax-highlighter": "^16.1.0", + "shepherd.js": "^14.5.1", "tailwindcss": "^3.4.17", "zarrita": "^0.5.1" }, From e20a8be1a1da068c4e56fb7bda8de08d6138b05c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 12 Jan 2026 11:09:05 -0500 Subject: [PATCH 02/23] chore: add shepherd journey provider - required wrapper for react shepherd tours --- frontend/src/layouts/MainLayout.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index e7096f8c..a63d3b67 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -4,6 +4,8 @@ import { Outlet, useParams } from 'react-router'; import { Toaster } from 'react-hot-toast'; import { ErrorBoundary } from 'react-error-boundary'; +import { ShepherdJourneyProvider } from 'react-shepherd'; +import 'shepherd.js/dist/css/shepherd.css'; import { ZonesAndFspMapContextProvider } from '@/contexts/ZonesAndFspMapContext'; import { FileBrowserContextProvider } from '@/contexts/FileBrowserContext'; @@ -26,7 +28,7 @@ const MainLayoutContent = () => { useServerHealthContext(); return ( - <> + { onRetry={checkHealth} open={showWarningOverlay} /> - + ); }; From 40b23423fcf568086fd588a1643d3509d399aa95 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 12 Jan 2026 17:15:17 -0500 Subject: [PATCH 03/23] feat: create tour for 3 documented fileglancer workflows --- frontend/src/components/tours/StartTour.tsx | 367 ++++++++++++++++++++ frontend/src/components/tours/tourSteps.ts | 155 +++++++++ 2 files changed, 522 insertions(+) create mode 100644 frontend/src/components/tours/StartTour.tsx create mode 100644 frontend/src/components/tours/tourSteps.ts diff --git a/frontend/src/components/tours/StartTour.tsx b/frontend/src/components/tours/StartTour.tsx new file mode 100644 index 00000000..1cc6c14d --- /dev/null +++ b/frontend/src/components/tours/StartTour.tsx @@ -0,0 +1,367 @@ +import { Button } from '@material-tailwind/react'; +import type { ButtonProps } from '@material-tailwind/react'; +import { useNavigate } from 'react-router'; +import { useShepherd } from 'react-shepherd'; +import type { Tour } from 'shepherd.js'; +import { tourSteps } from './tourSteps'; +import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; +import type { Zone } from '@/shared.types'; + +// Helper to wait for an element to appear in the DOM +function waitForElement(selector: string, timeoutMs = 3000): Promise { + const start = Date.now(); + return new Promise((resolve, reject) => { + const tick = () => { + if (document.querySelector(selector)) { + return resolve(); + } + if (Date.now() - start > timeoutMs) { + return reject(new Error(`Timeout waiting for ${selector}`)); + } + requestAnimationFrame(tick); + }; + tick(); + }); +} + +interface StartTourProps extends Omit { + readonly variant?: 'button' | 'link'; + readonly size?: 'sm' | 'md' | 'lg'; + readonly children: React.ReactNode; + readonly className?: string; +} + +export default function StartTour({ + variant = 'button', + size = 'md', + children, + className = '', + ...buttonProps +}: StartTourProps) { + const navigate = useNavigate(); + const shepherd = useShepherd(); + const { zonesAndFspQuery } = useZoneAndFspMapContext(); + + // Check if running on Janelia filesystem + const isJaneliaFilesystem = + zonesAndFspQuery.data && + Object.values(zonesAndFspQuery.data).some( + item => + 'mount_path' in item && + (item.mount_path.toLowerCase().includes('nrs') || + item.mount_path.toLowerCase().includes('prfs') || + item.mount_path.toLowerCase().includes('nearline')) + ); + + const tasksEnabled = import.meta.env.VITE_ENABLE_TASKS === 'true'; + + // Helper to set up navigation sidebar step with conditional navigation + const setupNavigationSidebarStep = (tour: Tour) => { + const navSidebarStep = tour.getById('nav-sidebar'); + if (navSidebarStep) { + navSidebarStep.updateStepOptions({ + buttons: [ + { + text: 'Back', + action: function (this: any) { + return this.back(); + }, + classes: 'shepherd-button-secondary' + }, + { + text: 'Next', + action: async function (this: any) { + // Check if user has navigated from /browse + if (window.location.pathname === '/browse') { + // User hasn't navigated, force navigation to a file path + if (isJaneliaFilesystem) { + navigate('/browse/nrs_opendata'); + } else { + const firstZone = Object.values( + zonesAndFspQuery.data || {} + ).find(item => 'fileSharePaths' in item) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + navigate(`/browse/${firstFsp.name}`); + } + } + await waitForElement('[data-tour="file-browser"]'); + } + return this.next(); + } + }, + { + text: 'Exit Tour', + action: function (this: any) { + return this.cancel(); + }, + classes: 'shepherd-button-secondary' + } + ] + }); + } + }; + + // Helper to set up conversion properties step with convert tab opened + const setupConversionStartStep = (tour: Tour) => { + const conversionStartStep = tour.getById('conversion-start'); + if (conversionStartStep) { + conversionStartStep.updateStepOptions({ + buttons: [ + { + text: 'Back', + action: function (this: any) { + return this.back(); + }, + classes: 'shepherd-button-secondary' + }, + { + text: 'Next', + action: async function (this: any) { + // Navigate with openConvertTab state to open properties with Convert tab selected + const currentPath = window.location.pathname; + navigate(currentPath, { + state: { openConvertTab: true }, + replace: true + }); + await waitForElement('[data-tour="open-conversion-request"]'); + return this.next(); + } + }, + { + text: 'Exit Tour', + action: function (this: any) { + return this.cancel(); + }, + classes: 'shepherd-button-secondary' + } + ] + }); + } + }; + + // Helper to set up conversion properties step with convert tab opened + const setupConversionPropertiesStep = (tour: Tour) => { + const conversionPropertiesStep = tour.getById('conversion-properties'); + if (conversionPropertiesStep) { + conversionPropertiesStep.updateStepOptions({ + buttons: [ + { + text: 'Back', + action: function (this: any) { + return this.back(); + }, + classes: 'shepherd-button-secondary' + }, + { + text: 'Next', + action: async function (this: any) { + navigate('/jobs'); + await waitForElement('[data-tour="tasks-page"]'); + return this.next(); + } + }, + { + text: 'Exit Tour', + action: function (this: any) { + return this.cancel(); + }, + classes: 'shepherd-button-secondary' + } + ] + }); + } + }; + + // Helper to set up completion buttons for tour ending steps + const setupCompletionButtons = (tour: Tour) => { + const completionStepIds = [ + 'nav-properties', + 'datalinks-janelia-preferences', + 'datalinks-general-preferences', + 'conversion-jobs' + ]; + + completionStepIds.forEach(stepId => { + const step = tour.getById(stepId); + if (step) { + // Get existing buttons to preserve the back button behavior + const buttons = [ + { + text: 'Back', + action: function (this: any) { + return this.back(); + }, + classes: 'shepherd-button-secondary' + }, + { + text: 'Take Another Tour', + action: function (this: any) { + const currentTour = shepherd.activeTour as Tour; + // Re-setup workflow buttons to ensure they work when returning + setupWorkflowButtons(currentTour); + // Show the workflow selection step + currentTour.show('choose-workflow'); + } + }, + { + text: 'Exit Tour', + action: function (this: any) { + return this.cancel(); + }, + classes: 'shepherd-button-secondary' + } + ]; + + step.updateStepOptions({ buttons }); + } + }); + }; + + // Helper to set up workflow selection buttons + const setupWorkflowButtons = (tour: Tour) => { + const firstStep = tour.getById('choose-workflow'); + if (!firstStep) { + return; + } + + const workflowButtons: any[] = [ + { + text: 'Navigation', + action: async function (this: any) { + const currentTour = shepherd.activeTour as Tour; + navigate('/browse'); + await waitForElement('[data-tour="navigation-input"]'); + setupNavigationSidebarStep(currentTour); + setupCompletionButtons(currentTour); + currentTour.show('nav-navigation-input'); + } + }, + { + text: 'Data Links', + action: async function (this: any) { + const currentTour = shepherd.activeTour as Tour; + if (isJaneliaFilesystem) { + navigate( + '/browse/nrs_opendata/ome-zarr-examples/fused-timeseries.zarr' + ); + await waitForElement('[data-tour="file-browser"]'); + setupCompletionButtons(currentTour); + currentTour.show('datalinks-janelia-start'); + } else { + // Navigate to first FSP of first zone + const firstZone = Object.values(zonesAndFspQuery.data || {}).find( + item => 'fileSharePaths' in item + ) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + navigate(`/browse/${firstFsp.name}`); + await waitForElement('[data-tour="file-browser"]'); + setupCompletionButtons(currentTour); + currentTour.show('datalinks-general-start'); + } + } + } + } + ]; + + // Only add File Conversion option if tasks are enabled + if (tasksEnabled) { + workflowButtons.push({ + text: 'File Conversion', + action: async function (this: any) { + const currentTour = shepherd.activeTour as Tour; + if (isJaneliaFilesystem) { + navigate( + '/browse/nrs_opendata/ome-zarr-examples/fused-timeseries.zarr' + ); + } else { + const firstZone = Object.values(zonesAndFspQuery.data || {}).find( + item => 'fileSharePaths' in item + ) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + navigate(`/browse/${firstFsp.name}`); + } + } + await waitForElement('[data-tour="file-browser"]'); + setupConversionStartStep(currentTour); + setupConversionPropertiesStep(currentTour); + setupCompletionButtons(currentTour); + currentTour.show('conversion-start'); + } + }); + } + + workflowButtons.push({ + text: 'Exit', + action: function (this: any) { + const currentTour = shepherd.activeTour as Tour; + currentTour.cancel(); + }, + classes: 'shepherd-button-secondary' + }); + + firstStep.updateStepOptions({ buttons: workflowButtons }); + }; + + const handleStartTour = () => { + // Get or create the tour instance + let tour = shepherd.activeTour as Tour | undefined; + if (!tour) { + tour = new shepherd.Tour({ + useModalOverlay: true, + defaultStepOptions: { + classes: 'shepherd-theme-default', + scrollTo: true, + cancelIcon: { + enabled: true + } + } + }); + shepherd.activeTour = tour; + } + + // Add steps if not already added + if (!tour.steps || tour.steps.length === 0) { + tour.addSteps(tourSteps); + } + + // Set up workflow selection buttons (do this every time to ensure they work when returning) + setupWorkflowButtons(tour); + + // Set up completion buttons with proper tour context + setupCompletionButtons(tour); + + tour.start(); + }; + + if (variant === 'link') { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/tours/tourSteps.ts b/frontend/src/components/tours/tourSteps.ts new file mode 100644 index 00000000..7f3bca24 --- /dev/null +++ b/frontend/src/components/tours/tourSteps.ts @@ -0,0 +1,155 @@ +import type { StepOptions } from 'shepherd.js'; + +// Helper to create common button configs +const nextButton = { + text: 'Next', + action: function (this: any) { + return this.next(); + } +}; + +const backButton = { + text: 'Back', + action: function (this: any) { + return this.back(); + }, + classes: 'shepherd-button-secondary' +}; + +const exitButton = { + text: 'Exit Tour', + action: function (this: any) { + return this.cancel(); + }, + classes: 'shepherd-button-secondary' +}; + +// Note: This will be replaced dynamically in StartTour.tsx to have proper tour context +const takeAnotherTourButton = { + text: 'Take Another Tour', + action: function (this: any) { + // This is a placeholder - will be replaced in setupCompletionButtons + console.warn('takeAnotherTourButton action not properly initialized'); + } +}; + +export const tourSteps: StepOptions[] = [ + { + id: 'choose-workflow', + title: 'Welcome to Fileglancer!', + text: 'Which workflow would you like to explore?', + buttons: [] // Will be dynamically replaced with branching buttons + }, + + // Navigation workflow steps + { + id: 'nav-navigation-input', + title: 'Navigation', + text: 'Use this navigation bar to quickly jump to any path in the file system. You can type or paste a path here.', + attachTo: { element: '[data-tour="navigation-input"]', on: 'bottom' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'nav-sidebar', + title: 'Sidebar', + text: 'The sidebar shows your zones, file share paths, and favorite folders. Click on any item to navigate to it.', + attachTo: { element: '[data-tour="sidebar"]', on: 'right' }, + buttons: [] // Will be dynamically replaced with navigation logic + }, + { + id: 'nav-file-browser', + title: 'File Browser', + text: 'Browse files and folders here. You can sort by column headers and select items to perform actions.', + attachTo: { element: '[data-tour="file-browser"]', on: 'top' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'nav-properties', + title: 'Properties Panel', + text: 'View file metadata and perform actions like changing file permissions, creating shareable data links, or requesting file conversions.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + }, + + // Data Links workflow - Janelia filesystem + { + id: 'datalinks-janelia-start', + title: 'Data Links', + text: "Navigate to a Zarr file to create a data link you can use to open files in external viewers like Neuroglancer. Let's go to an example Zarr dataset.", + attachTo: { element: '[data-tour="zarr-metadata"]', on: 'bottom' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-janelia-properties', + title: 'Creating Data Links', + text: 'You can create a data link from the properties panel using the data link toggle. Turn it on to create a shareable link.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-janelia-viewer', + title: 'Viewer Links', + text: 'Click on viewer icons in the metadata section to open the Zarr file in external viewers like Neuroglancer.', + attachTo: { element: '[data-tour="data-tool-links"]', on: 'bottom' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-janelia-preferences', + title: 'Automatic Data Links', + text: 'You can enable automatic data link creation on the Preferences page, accessible from the profile menu.', + attachTo: { element: '[data-tour="profile-menu"]', on: 'bottom' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + }, + + // Data Links workflow - Non-Janelia filesystem + { + id: 'datalinks-general-start', + title: 'Data Links', + text: 'You can create data links for any directory. Navigate to a folder you want to share.', + attachTo: { element: '[data-tour="file-browser"]', on: 'top' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-general-properties', + title: 'Creating Data Links', + text: 'Open the properties panel and toggle the data link option to create a shareable link for this directory.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-general-zarr', + title: 'Zarr/N5 Files', + text: "If a file is detected as Zarr or N5, you'll see viewer icons in the metadata at the top of the file browser.", + attachTo: { element: '[data-tour="file-browser"]', on: 'top' }, + buttons: [backButton, nextButton, exitButton] + }, + { + id: 'datalinks-general-preferences', + title: 'Automatic Data Links', + text: 'You can enable automatic data link creation on the Preferences page, accessible from the profile menu.', + attachTo: { element: '[data-tour="profile-menu"]', on: 'bottom' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + }, + + // File Conversion workflow + { + id: 'conversion-start', + title: 'File Conversion', + text: "Navigate to a file you want to convert. Let's go to an example file.", + buttons: [] // Will be dynamically replaced with navigation logic + }, + { + id: 'conversion-properties', + title: 'Request Conversion', + text: 'Select a file and open the properties panel to the "Convert" tab. Click "Open conversion request" to submit a conversion task.', + attachTo: { element: '[data-tour="properties-drawer"]', on: 'left' }, + buttons: [] // Will be dynamically replaced with navigation logic + }, + { + id: 'conversion-jobs', + title: 'Monitor Tasks', + text: 'View the status of your conversion requests on the Tasks page.', + attachTo: { element: '[data-tour="tasks-page"]', on: 'bottom' }, + buttons: [backButton, takeAnotherTourButton, exitButton] + } +]; From 1e28d2b5dd769ecc6c832cecbaf29fe1d80e1527 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 12 Jan 2026 17:15:31 -0500 Subject: [PATCH 04/23] feat: add card to help page to trigger tours --- frontend/src/components/Help.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Help.tsx b/frontend/src/components/Help.tsx index dc08e21c..7e824363 100644 --- a/frontend/src/components/Help.tsx +++ b/frontend/src/components/Help.tsx @@ -5,9 +5,11 @@ import { SiClickup, SiSlack } from 'react-icons/si'; import { IconType } from 'react-icons/lib'; import { LuBookOpenText } from 'react-icons/lu'; import { HiExternalLink } from 'react-icons/hi'; +import { MdTour } from 'react-icons/md'; import useVersionQuery from '@/queries/versionQuery'; import { buildUrl } from '@/utils'; +import StartTour from '@/components/tours/StartTour'; type HelpLink = { icon: IconType; @@ -86,10 +88,26 @@ export default function Help() {
+ {/* Tour Card */} + +
+ + + Take a Tutorial + +
+ + Guided tours of common Fileglancer workflows + +
+ {helpLinks.map(({ icon: Icon, title, description, url }) => (
- + {description} From 749b8fd656dd7cf009e967067877f5f75e91cbd7 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 12 Jan 2026 17:16:10 -0500 Subject: [PATCH 05/23] chore: add data ids to elements for tour anchors --- frontend/src/components/Jobs.tsx | 4 ++-- .../src/components/ui/BrowsePage/DataToolLinks.tsx | 2 +- .../src/components/ui/BrowsePage/FileBrowser.tsx | 12 +++++++----- .../src/components/ui/BrowsePage/NavigateInput.tsx | 1 + frontend/src/components/ui/Navbar/ProfileMenu.tsx | 2 ++ .../ui/PropertiesDrawer/PropertiesDrawer.tsx | 5 +++-- frontend/src/components/ui/Sidebar/Sidebar.tsx | 5 ++++- 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Jobs.tsx b/frontend/src/components/Jobs.tsx index 878fa10b..b1693604 100644 --- a/frontend/src/components/Jobs.tsx +++ b/frontend/src/components/Jobs.tsx @@ -7,7 +7,7 @@ import { jobsColumns } from './ui/Table/jobsColumns'; export default function Jobs() { const { allTicketsQuery } = useTicketContext(); return ( - <> +
Tasks @@ -25,6 +25,6 @@ export default function Jobs() { gridColsClass="grid-cols-[3fr_3fr_1fr_2fr]" loadingState={allTicketsQuery.isPending} /> - +
); } diff --git a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx index 18826eea..b4e34d0c 100644 --- a/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx +++ b/frontend/src/components/ui/BrowsePage/DataToolLinks.tsx @@ -28,7 +28,7 @@ export default function DataToolLinks({ } return ( -
+
{title} diff --git a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx index c520a01f..770c9bdd 100644 --- a/frontend/src/components/ui/BrowsePage/FileBrowser.tsx +++ b/frontend/src/components/ui/BrowsePage/FileBrowser.tsx @@ -243,11 +243,13 @@ export default function FileBrowser({
) : displayFiles.length > 0 ? ( - +
+
+ ) : displayFiles.length === 0 && !fileQuery.data.errorMessage ? (
No files available for display. diff --git a/frontend/src/components/ui/BrowsePage/NavigateInput.tsx b/frontend/src/components/ui/BrowsePage/NavigateInput.tsx index cb3825c8..533ff50d 100644 --- a/frontend/src/components/ui/BrowsePage/NavigateInput.tsx +++ b/frontend/src/components/ui/BrowsePage/NavigateInput.tsx @@ -40,6 +40,7 @@ export default function NavigationInput({ return (
@@ -47,6 +48,7 @@ export default function ProfileMenu() { diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index 570d32db..36dbe8f6 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -120,7 +120,7 @@ export default function PropertiesDrawer({ const tooltipTriggerClasses = 'max-w-[calc(100%-2rem)] truncate'; return ( - <> +
Properties @@ -338,6 +338,7 @@ export default function PropertiesDrawer({ like Neuroglancer.
); } diff --git a/frontend/src/components/ui/Sidebar/Sidebar.tsx b/frontend/src/components/ui/Sidebar/Sidebar.tsx index 68509b2b..9a4879aa 100644 --- a/frontend/src/components/ui/Sidebar/Sidebar.tsx +++ b/frontend/src/components/ui/Sidebar/Sidebar.tsx @@ -18,7 +18,10 @@ export default function Sidebar() { } = useFilteredZonesAndFavorites(); return ( - +
Date: Mon, 12 Jan 2026 17:16:50 -0500 Subject: [PATCH 06/23] style: add custom styles to tour components to match existing styles --- .../components/tours/shepherd-overrides.css | 133 ++++++++++++++++++ frontend/src/layouts/MainLayout.tsx | 1 + 2 files changed, 134 insertions(+) create mode 100644 frontend/src/components/tours/shepherd-overrides.css diff --git a/frontend/src/components/tours/shepherd-overrides.css b/frontend/src/components/tours/shepherd-overrides.css new file mode 100644 index 00000000..c22be632 --- /dev/null +++ b/frontend/src/components/tours/shepherd-overrides.css @@ -0,0 +1,133 @@ +/* Custom Shepherd.js styling overrides to match Material Tailwind dialogs */ +/* This file is imported AFTER shepherd.css to ensure our styles take precedence */ +/* Using CSS custom properties from Tailwind theme instead of @apply */ +/* @apply was not included in the final build CSS when used here */ + +/* Override default Shepherd theme */ +.shepherd-element { + max-width: 600px; + min-width: fit-content; +} + +/* Style the tour content container like FgDialog */ +.shepherd-content { + padding: 1.5rem; + background-color: rgb(var(--color-surface-light)); + box-shadow: + 0 20px 25px -5px rgb(0 0 0 / 0.1), + 0 8px 10px -6px rgb(0 0 0 / 0.1); + border: 1px solid rgb(var(--color-surface)); + min-width: 400px; +} + +/* Style the header */ +.shepherd-has-title .shepherd-content .shepherd-header { + background-color: rgb(var(--color-surface-light)); + padding: 0.75rem 0; + border-bottom: 1px solid rgb(var(--color-surface-dark)); + margin: 0.75rem; +} + +/* Dark mode for content and header */ +.dark .shepherd-content, +.dark .shepherd-has-title .shepherd-content .shepherd-header { + background-color: rgb(var(--color-surface)); + border-color: rgb(var(--color-surface-light)); +} + +/* Style the title */ +.shepherd-title { + font-size: 1.25rem; + font-weight: 600; + color: rgb(var(--color-foreground)); + margin: 0; +} + +.dark .shepherd-title { + color: rgb(var(--color-foreground)); +} + +/* Style the text content */ +.shepherd-text { + color: rgb(var(--color-foreground)); + font-size: 0.875rem; + line-height: 1.625; + margin-bottom: 1.25rem; +} + +.dark .shepherd-text { + color: rgb(var(--color-foreground)); +} + +/* Style the button container to prevent overflow */ +.shepherd-footer { + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; + align-items: center; +} + +/* Style buttons to match Material Tailwind Button component */ +.shepherd-button { + padding: 0.5rem 1rem; + border-radius: calc(var(--radius) - 18px); + font-weight: 500; + font-size: 0.875rem; + transition: all 0.2s ease-in-out; + border: 0; + cursor: pointer; + white-space: nowrap; +} + +/* Primary button (Next, Submit, Complete) */ +.shepherd-button:not(.shepherd-button-secondary) { + background-color: rgb(var(--color-primary)); + color: rgb(var(--color-primary-foreground)); +} + +.shepherd-button:not(.shepherd-button-secondary):hover { + background-color: rgb(var(--color-primary-light)); + color: rgb(var(--color-background)); +} + +/* Secondary button (Back, Exit) */ +.shepherd-button-secondary { + background-color: rgb(var(--color-surface)); + color: rgb(var(--color-foreground)); + border: 1px solid rgb(var(--color-surface-dark)); +} + +.shepherd-button-secondary:hover { + background-color: rgb(var(--color-surface-dark)); + color: rgb(var(--color-surface-foreground)); +} + +.dark .shepherd-button-secondary { + background-color: rgb(var(--color-surface)); + color: rgb(var(--color-foreground)); + border-color: rgb(var(--color-surface-light)); +} + +.dark .shepherd-button-secondary:hover { + background-color: rgb(var(--color-surface-light)); + color: rgb(var(--color-surface-foreground)); +} + +/* Style the cancel icon in header */ +.shepherd-has-title .shepherd-content .shepherd-cancel-icon { + color: rgb(var(--color-secondary)); + font-size: 1.5rem; + font-weight: 500; + cursor: pointer; + border-radius: 9999px; + border: 1px solid rgb(var(--color-secondary)); + transition: all 0.3s cubic-bezier(0.4, 0, 1, 1); + width: 2rem; +} + +.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover { + background-color: rgb(var(--color-secondary)); + color: rgb(var(--color-surface)); +} diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index a63d3b67..88d0b33c 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -6,6 +6,7 @@ import { Toaster } from 'react-hot-toast'; import { ErrorBoundary } from 'react-error-boundary'; import { ShepherdJourneyProvider } from 'react-shepherd'; import 'shepherd.js/dist/css/shepherd.css'; +import '@/components/tours/shepherd-overrides.css'; import { ZonesAndFspMapContextProvider } from '@/contexts/ZonesAndFspMapContext'; import { FileBrowserContextProvider } from '@/contexts/FileBrowserContext'; From b7e31cd161dfbce52e0e90ac7b95db5ff27175e0 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Mon, 12 Jan 2026 17:22:16 -0500 Subject: [PATCH 07/23] fix: tour step for non-janelia file system zarr/n5 description --- frontend/src/components/tours/tourSteps.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/components/tours/tourSteps.ts b/frontend/src/components/tours/tourSteps.ts index 7f3bca24..e65f78a5 100644 --- a/frontend/src/components/tours/tourSteps.ts +++ b/frontend/src/components/tours/tourSteps.ts @@ -119,8 +119,7 @@ export const tourSteps: StepOptions[] = [ { id: 'datalinks-general-zarr', title: 'Zarr/N5 Files', - text: "If a file is detected as Zarr or N5, you'll see viewer icons in the metadata at the top of the file browser.", - attachTo: { element: '[data-tour="file-browser"]', on: 'top' }, + text: "If a file is detected as Zarr or N5, you'll see viewer icons in the metadata displayed at the top of the file browser. You can click on a viewer icon to open the data link in external viewers like Neuroglancer.", buttons: [backButton, nextButton, exitButton] }, { From 1b344ddbe6f72ddf776184570ace16e888cb151d Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:34:13 -0500 Subject: [PATCH 08/23] refactor: simplify StartTour by removing link options - does not look like I will use these --- frontend/src/components/tours/StartTour.tsx | 33 ++------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/tours/StartTour.tsx b/frontend/src/components/tours/StartTour.tsx index 1cc6c14d..65e64bda 100644 --- a/frontend/src/components/tours/StartTour.tsx +++ b/frontend/src/components/tours/StartTour.tsx @@ -24,18 +24,12 @@ function waitForElement(selector: string, timeoutMs = 3000): Promise { }); } -interface StartTourProps extends Omit { - readonly variant?: 'button' | 'link'; - readonly size?: 'sm' | 'md' | 'lg'; +interface StartTourProps extends ButtonProps { readonly children: React.ReactNode; - readonly className?: string; } export default function StartTour({ - variant = 'button', - size = 'md', children, - className = '', ...buttonProps }: StartTourProps) { const navigate = useNavigate(); @@ -336,31 +330,8 @@ export default function StartTour({ tour.start(); }; - if (variant === 'link') { - return ( - - ); - } - return ( - ); From 9e575fc1d08509ce99571e1087de308e140caf44 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:36:22 -0500 Subject: [PATCH 09/23] feat: add WelcomeCard and preference to show card --- frontend/src/components/Browse.tsx | 15 +++++- .../Dashboard/WelcomeTutorialCard.tsx | 54 +++++++++++++++++++ frontend/src/contexts/PreferencesContext.tsx | 11 ++++ frontend/src/queries/preferencesQueries.ts | 5 +- 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx diff --git a/frontend/src/components/Browse.tsx b/frontend/src/components/Browse.tsx index be74b47a..f4651836 100644 --- a/frontend/src/components/Browse.tsx +++ b/frontend/src/components/Browse.tsx @@ -3,6 +3,8 @@ import { useOutletContext } from 'react-router'; import { default as log } from '@/logger'; import type { OutletContextType } from '@/layouts/BrowseLayout'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import FileBrowser from './ui/BrowsePage/FileBrowser'; import Toolbar from './ui/BrowsePage/Toolbar'; import RenameDialog from './ui/Dialogs/Rename'; @@ -11,6 +13,7 @@ import ChangePermissions from './ui/Dialogs/ChangePermissions'; import ConvertFileDialog from './ui/Dialogs/ConvertFile'; import RecentDataLinksCard from './ui/BrowsePage/Dashboard/RecentDataLinksCard'; import RecentlyViewedCard from './ui/BrowsePage/Dashboard/RecentlyViewedCard'; +import WelcomeTutorialCard from './ui/BrowsePage/Dashboard/WelcomeTutorialCard'; import NavigationInput from './ui/BrowsePage/NavigateInput'; import FgDialog from './ui/Dialogs/FgDialog'; @@ -27,6 +30,8 @@ export default function Browse() { } = useOutletContext(); const { fspName } = useFileBrowserContext(); + const { recentlyViewedFolders, showTutorial } = usePreferencesContext(); + const { allProxiedPathsQuery } = useProxiedPathContext(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); @@ -114,11 +119,17 @@ export default function Browse() { {!fspName ? (
+ {showTutorial ? : null}
800 ? '' : 'flex-col'}`} > - - + {recentlyViewedFolders.length === 0 ? null : ( + + )} + {allProxiedPathsQuery.isSuccess && + allProxiedPathsQuery.data.length === 0 ? null : ( + + )}
) : ( diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx new file mode 100644 index 00000000..26cd75d9 --- /dev/null +++ b/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx @@ -0,0 +1,54 @@ +import { Typography } from '@material-tailwind/react'; +import toast from 'react-hot-toast'; + +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import StartTour from '@/components/tours/StartTour'; +import DashboardCard from '@/components/ui/BrowsePage/Dashboard/FgDashboardCard'; + +export default function WelcomeTutorialCard() { + const { showTutorial, toggleShowTutorial } = usePreferencesContext(); + + const handleToggle = async () => { + const result = await toggleShowTutorial(); + if (result.success) { + toast.success( + 'Welcome card hidden. Access Tutorials from the Help page.' + ); + } else { + toast.error(result.error); + } + }; + + return ( + +
+ + Fileglancer helps you browse, visualize, and share scientific imaging + data on network file systems. Get started with a guided tour to learn + the basics! + + + Start Tour +
+ +
+
+ + + Hide this card - you can always access Tutorials from the Help page + +
+
+
+ ); +} diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx index 75e769e3..877e484f 100644 --- a/frontend/src/contexts/PreferencesContext.tsx +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -52,6 +52,7 @@ type PreferencesContextType = { disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; isFilteredByGroups: boolean; + showTutorial: boolean; // Favorites zoneFavorites: Zone[]; @@ -78,6 +79,7 @@ type PreferencesContextType = { toggleDisableHeuristicalLayerTypeDetection: () => Promise>; toggleUseLegacyMultichannelApproach: () => Promise>; toggleFilterByGroups: () => Promise>; + toggleShowTutorial: () => Promise>; handleFavoriteChange: ( item: Zone | FileSharePath | FolderFavorite, type: 'zone' | 'fileSharePath' | 'folder' @@ -233,6 +235,13 @@ export const PreferencesProvider = ({ ); }; + const toggleShowTutorial = async (): Promise> => { + return togglePreference( + 'showTutorial', + preferencesQuery.data?.showTutorial ?? true + ); + }; + function updatePreferenceList( key: string, itemToUpdate: T, @@ -490,6 +499,7 @@ export const PreferencesProvider = ({ useLegacyMultichannelApproach: preferencesQuery.data?.useLegacyMultichannelApproach || false, isFilteredByGroups: preferencesQuery.data?.isFilteredByGroups ?? true, + showTutorial: preferencesQuery.data?.showTutorial ?? true, // Favorites zoneFavorites: preferencesQuery.data?.zoneFavorites || [], @@ -515,6 +525,7 @@ export const PreferencesProvider = ({ toggleDisableHeuristicalLayerTypeDetection, toggleUseLegacyMultichannelApproach, toggleFilterByGroups, + toggleShowTutorial, handleFavoriteChange, handleContextMenuFavorite }; diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts index 30083ca6..a5962069 100644 --- a/frontend/src/queries/preferencesQueries.ts +++ b/frontend/src/queries/preferencesQueries.ts @@ -37,6 +37,7 @@ type PreferencesApiResponse = { disableHeuristicalLayerTypeDetection?: { value: boolean }; useLegacyMultichannelApproach?: { value: boolean }; isFilteredByGroups?: { value: boolean }; + showTutorial?: { value: boolean }; zone?: { value: ZonePreference[] }; fileSharePath?: { value: FileSharePathPreference[] }; folder?: { value: FolderPreference[] }; @@ -66,6 +67,7 @@ export type PreferencesQueryData = { disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; isFilteredByGroups: boolean; + showTutorial: boolean; }; /** @@ -229,7 +231,8 @@ const createTransformPreferences = ( rawData.disableHeuristicalLayerTypeDetection?.value || false, useLegacyMultichannelApproach: rawData.useLegacyMultichannelApproach?.value || false, - isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true + isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true, + showTutorial: rawData.showTutorial?.value ?? true }; }; }; From 71482ae57890cdcb875a0fb5439991c52fac1dfc Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:37:22 -0500 Subject: [PATCH 10/23] tests: add WelcomeCard and showTutorial preference tests --- .../__tests__/componentTests/Browse.test.tsx | 62 +++++++++++ .../WelcomeTutorialCard.test.tsx | 102 ++++++++++++++++++ frontend/src/__tests__/mocks/handlers.ts | 1 + 3 files changed, 165 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/Browse.test.tsx create mode 100644 frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx diff --git a/frontend/src/__tests__/componentTests/Browse.test.tsx b/frontend/src/__tests__/componentTests/Browse.test.tsx new file mode 100644 index 00000000..5e9a2250 --- /dev/null +++ b/frontend/src/__tests__/componentTests/Browse.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { render, screen } from '@/__tests__/test-utils'; +import Browse from '@/components/Browse'; + +// Mock the StartTour component to avoid ShepherdJourneyProvider dependency +vi.mock('@/components/tours/StartTour', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( + + ) +})); + +// Mock useOutletContext since Browse requires it +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router'); + return { + ...actual, + useOutletContext: () => ({ + setShowPermissionsDialog: vi.fn(), + togglePropertiesDrawer: vi.fn(), + toggleSidebar: vi.fn(), + setShowConvertFileDialog: vi.fn(), + showPermissionsDialog: false, + showPropertiesDrawer: false, + showSidebar: false, + showConvertFileDialog: false + }) + }; +}); + +describe('Browse - WelcomeTutorialCard visibility', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows WelcomeTutorialCard on dashboard when showTutorial is true', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect(screen.getByText('Welcome to Fileglancer!')).toBeInTheDocument(); + }); + }); + + it('does not show WelcomeTutorialCard after the user selects to hide it', async () => { + render(, { initialEntries: ['/browse'] }); + await waitFor(() => { + expect(screen.getByText('Welcome to Fileglancer!')).toBeInTheDocument(); + }); + + const user = userEvent.setup(); + const checkbox = screen.getByRole('checkbox', { name: /hide this card/i }); + expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + + await waitFor(() => { + expect( + screen.queryByText('Welcome to Fileglancer!') + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx new file mode 100644 index 00000000..9b228b20 --- /dev/null +++ b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import toast from 'react-hot-toast'; +import { render, screen } from '@/__tests__/test-utils'; +import WelcomeTutorialCard from '@/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard'; + +// Mock the StartTour component to avoid ShepherdJourneyProvider dependency +vi.mock('@/components/tours/StartTour', () => ({ + default: ({ children }: { children: React.ReactNode }) => ( + + ) +})); + +describe('WelcomeTutorialCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders welcome message and tour button', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect(screen.getByText('Welcome to Fileglancer!')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /start tour/i }) + ).toBeInTheDocument(); + }); + }); + + it('displays checkbox with correct label and helper text', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect( + screen.getByLabelText(/hide this card.*preferences/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/you can always start tutorials from the help page/i) + ).toBeInTheDocument(); + }); + }); + + it('checkbox reflects showTutorial preference state', async () => { + render(, { initialEntries: ['/browse'] }); + + const checkbox = await screen.findByLabelText(/hide this card.*preferences/i); + + // showTutorial is true by default, so checkbox should be unchecked (inverse) + expect(checkbox).not.toBeChecked(); + }); + + it('toggles preference when checkbox is clicked and shows success toast', async () => { + render(, { initialEntries: ['/browse'] }); + + const user = userEvent.setup(); + const checkbox = await screen.findByLabelText(/hide this card.*preferences/i); + + await user.click(checkbox); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + 'Welcome card hidden. Re-enable it anytime in Preferences.' + ); + }); + }); + + it('shows error toast when preference update fails', async () => { + const { server } = await import('@/__tests__/mocks/node'); + const { http, HttpResponse } = await import('msw'); + + server.use( + http.put('/api/preference/showTutorial', () => { + return HttpResponse.json({ error: 'Update failed' }, { status: 500 }); + }) + ); + + render(, { initialEntries: ['/browse'] }); + + const user = userEvent.setup(); + const checkbox = await screen.findByLabelText(/hide this card.*preferences/i); + + await user.click(checkbox); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(`500 Internal Server Error: +Update failed`); + }); + }); + + it('contains descriptive text about FileGlancer', async () => { + render(, { initialEntries: ['/browse'] }); + + await waitFor(() => { + expect( + screen.getByText( + /fileglancer helps you browse.*visualize.*share.*scientific imaging data/i + ) + ).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index d8870dc9..e61b2d9b 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -41,6 +41,7 @@ export const handlers = [ disableNeuroglancerStateGeneration: { value: false }, useLegacyMultichannelApproach: { value: false }, isFilteredByGroups: { value: true }, + showTutorial: { value: true }, layout: { value: '' }, zone: { value: [] }, fileSharePath: { value: [] }, From fab324540b4da074924a2962cfb6b4091a5352a9 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:38:11 -0500 Subject: [PATCH 11/23] feat: add tour preference control to preference page --- frontend/src/components/Preferences.tsx | 34 ++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Preferences.tsx b/frontend/src/components/Preferences.tsx index e028df65..b17c6ece 100644 --- a/frontend/src/components/Preferences.tsx +++ b/frontend/src/components/Preferences.tsx @@ -17,7 +17,9 @@ export default function Preferences() { toggleDisableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, toggleDisableHeuristicalLayerTypeDetection, - toggleFilterByGroups + toggleFilterByGroups, + showTutorial, + toggleShowTutorial } = usePreferencesContext(); return ( @@ -182,6 +184,36 @@ export default function Preferences() {
+
+ { + const result = await toggleShowTutorial(); + if (result.success) { + toast.success( + showTutorial + ? 'Tutorial welcome card will no longer be shown on Browse page' + : 'Tutorial welcome card will be shown on Browse page' + ); + } else { + toast.error(result.error); + } + }} + type="checkbox" + /> + + Show tutorial welcome card on Browse page + +
+ + Data Links +
From 0d2049f37f837d82535a3c7eec4f5d76fae8a135 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:38:30 -0500 Subject: [PATCH 12/23] style: reorganize and add section headers to preference pg --- frontend/src/components/Preferences.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Preferences.tsx b/frontend/src/components/Preferences.tsx index b17c6ece..1ac9c085 100644 --- a/frontend/src/components/Preferences.tsx +++ b/frontend/src/components/Preferences.tsx @@ -30,7 +30,7 @@ export default function Preferences() { - + Format to use for file paths: @@ -125,9 +125,13 @@ export default function Preferences() { - Options: + + Options: + + Display +
+ Neuroglancer +
From d923fdc7b765ed058cb8cf29d48859b2a0696452 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:40:34 -0500 Subject: [PATCH 13/23] refactor: export tour button configs for use in StartTour --- frontend/src/components/tours/StartTour.tsx | 67 +++------------------ frontend/src/components/tours/tourSteps.ts | 8 ++- 2 files changed, 14 insertions(+), 61 deletions(-) diff --git a/frontend/src/components/tours/StartTour.tsx b/frontend/src/components/tours/StartTour.tsx index 65e64bda..51700771 100644 --- a/frontend/src/components/tours/StartTour.tsx +++ b/frontend/src/components/tours/StartTour.tsx @@ -3,7 +3,7 @@ import type { ButtonProps } from '@material-tailwind/react'; import { useNavigate } from 'react-router'; import { useShepherd } from 'react-shepherd'; import type { Tour } from 'shepherd.js'; -import { tourSteps } from './tourSteps'; +import { tourSteps, backButton, exitButton } from './tourSteps'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; import type { Zone } from '@/shared.types'; @@ -55,13 +55,7 @@ export default function StartTour({ if (navSidebarStep) { navSidebarStep.updateStepOptions({ buttons: [ - { - text: 'Back', - action: function (this: any) { - return this.back(); - }, - classes: 'shepherd-button-secondary' - }, + backButton, { text: 'Next', action: async function (this: any) { @@ -84,13 +78,7 @@ export default function StartTour({ return this.next(); } }, - { - text: 'Exit Tour', - action: function (this: any) { - return this.cancel(); - }, - classes: 'shepherd-button-secondary' - } + exitButton ] }); } @@ -102,13 +90,7 @@ export default function StartTour({ if (conversionStartStep) { conversionStartStep.updateStepOptions({ buttons: [ - { - text: 'Back', - action: function (this: any) { - return this.back(); - }, - classes: 'shepherd-button-secondary' - }, + backButton, { text: 'Next', action: async function (this: any) { @@ -122,13 +104,7 @@ export default function StartTour({ return this.next(); } }, - { - text: 'Exit Tour', - action: function (this: any) { - return this.cancel(); - }, - classes: 'shepherd-button-secondary' - } + exitButton ] }); } @@ -140,13 +116,7 @@ export default function StartTour({ if (conversionPropertiesStep) { conversionPropertiesStep.updateStepOptions({ buttons: [ - { - text: 'Back', - action: function (this: any) { - return this.back(); - }, - classes: 'shepherd-button-secondary' - }, + backButton, { text: 'Next', action: async function (this: any) { @@ -155,13 +125,7 @@ export default function StartTour({ return this.next(); } }, - { - text: 'Exit Tour', - action: function (this: any) { - return this.cancel(); - }, - classes: 'shepherd-button-secondary' - } + exitButton ] }); } @@ -179,15 +143,8 @@ export default function StartTour({ completionStepIds.forEach(stepId => { const step = tour.getById(stepId); if (step) { - // Get existing buttons to preserve the back button behavior const buttons = [ - { - text: 'Back', - action: function (this: any) { - return this.back(); - }, - classes: 'shepherd-button-secondary' - }, + backButton, { text: 'Take Another Tour', action: function (this: any) { @@ -198,13 +155,7 @@ export default function StartTour({ currentTour.show('choose-workflow'); } }, - { - text: 'Exit Tour', - action: function (this: any) { - return this.cancel(); - }, - classes: 'shepherd-button-secondary' - } + exitButton ]; step.updateStepOptions({ buttons }); diff --git a/frontend/src/components/tours/tourSteps.ts b/frontend/src/components/tours/tourSteps.ts index e65f78a5..68b5fd53 100644 --- a/frontend/src/components/tours/tourSteps.ts +++ b/frontend/src/components/tours/tourSteps.ts @@ -1,6 +1,6 @@ import type { StepOptions } from 'shepherd.js'; -// Helper to create common button configs +// Common button configs const nextButton = { text: 'Next', action: function (this: any) { @@ -8,7 +8,9 @@ const nextButton = { } }; -const backButton = { +// Exported for reuse in StartTour.tsx +// Don't need to export next button as it's dynamically replaced in StartTour +export const backButton = { text: 'Back', action: function (this: any) { return this.back(); @@ -16,7 +18,7 @@ const backButton = { classes: 'shepherd-button-secondary' }; -const exitButton = { +export const exitButton = { text: 'Exit Tour', action: function (this: any) { return this.cancel(); From 329524c46a46a61edddd33414d3e2ebe4acb94a1 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:41:19 -0500 Subject: [PATCH 14/23] feat: provide a sample path to copy in the navigation tour --- frontend/src/components/tours/StartTour.tsx | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/frontend/src/components/tours/StartTour.tsx b/frontend/src/components/tours/StartTour.tsx index 51700771..d1e12f71 100644 --- a/frontend/src/components/tours/StartTour.tsx +++ b/frontend/src/components/tours/StartTour.tsx @@ -49,6 +49,62 @@ export default function StartTour({ const tasksEnabled = import.meta.env.VITE_ENABLE_TASKS === 'true'; + // Helper to set up navigation input step with dynamic text including sample path + const setupNavigationInputStep = (tour: Tour) => { + const navInputStep = tour.getById('nav-navigation-input'); + if (navInputStep) { + let samplePath = ''; + if (isJaneliaFilesystem) { + samplePath = '/nrs/opendata'; + } else { + const firstZone = Object.values(zonesAndFspQuery.data || {}).find( + item => 'fileSharePaths' in item + ) as Zone | undefined; + if (firstZone && firstZone.fileSharePaths.length > 0) { + const firstFsp = firstZone.fileSharePaths[0]; + samplePath = firstFsp.mount_path; + } + } + + navInputStep.updateStepOptions({ + text: `Use this navigation bar to quickly jump to any path in the file system. You can type or paste a path here. Try copying this path: ${samplePath} and hitting "Go."` + }); + + // Set up navigation detection to auto-advance when user navigates + let navigationCheckInterval: NodeJS.Timeout | null = null; + const startPath = window.location.pathname; + + const checkForNavigation = () => { + const currentPath = window.location.pathname; + // If user navigated away from the starting /browse page + if (currentPath !== startPath && currentPath.startsWith('/browse/')) { + if (navigationCheckInterval) { + clearInterval(navigationCheckInterval); + navigationCheckInterval = null; + } + // Auto-advance to next step + if (tour.getCurrentStep()?.id === 'nav-navigation-input') { + tour.next(); + } + } + }; + + // Add event listener when step is shown + navInputStep.on('show', () => { + // Start checking for navigation every 500ms + navigationCheckInterval = setInterval(checkForNavigation, 500); + }); + + // Clean up interval when step is hidden + navInputStep.on('hide', () => { + if (navigationCheckInterval) { + clearInterval(navigationCheckInterval); + navigationCheckInterval = null; + } + }); + } + }; + // Helper to set up navigation sidebar step with conditional navigation const setupNavigationSidebarStep = (tour: Tour) => { const navSidebarStep = tour.getById('nav-sidebar'); @@ -177,6 +233,7 @@ export default function StartTour({ const currentTour = shepherd.activeTour as Tour; navigate('/browse'); await waitForElement('[data-tour="navigation-input"]'); + setupNavigationInputStep(currentTour); setupNavigationSidebarStep(currentTour); setupCompletionButtons(currentTour); currentTour.show('nav-navigation-input'); From d9233704bc6ebc957db5732cdd4eda05983e343c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:41:44 -0500 Subject: [PATCH 15/23] refactor: enhance dashboard card styling flexibility --- .../ui/BrowsePage/Dashboard/FgDashboardCard.tsx | 8 ++++++-- .../ui/BrowsePage/Dashboard/RecentDataLinksCard.tsx | 2 +- .../ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx index 4aacb9fd..dc0767c7 100644 --- a/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx +++ b/frontend/src/components/ui/BrowsePage/Dashboard/FgDashboardCard.tsx @@ -3,13 +3,17 @@ import { Card, Typography } from '@material-tailwind/react'; export default function DashboardCard({ title, - children + children, + className = '' }: { readonly title: string; readonly children: ReactNode; + readonly className?: string; }) { return ( - +
+ {allProxiedPathsQuery.isPending || zonesAndFspQuery.isPending ? ( Array(5) .fill(0) diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx index 6f236b98..6488935d 100644 --- a/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx +++ b/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx @@ -14,7 +14,7 @@ export default function RecentlyViewedCard() { const { recentlyViewedFolders, preferenceQuery } = usePreferencesContext(); return ( - + {preferenceQuery.isPending || zonesAndFspQuery.isPending ? ( Array(5) .fill(0) From c7dc5b27d4d7bc505c5f307f62036ffba81d310e Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 12:42:26 -0500 Subject: [PATCH 16/23] chore: prettier formatting --- .../componentTests/WelcomeTutorialCard.test.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx index 9b228b20..ff5d615e 100644 --- a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx +++ b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx @@ -44,7 +44,9 @@ describe('WelcomeTutorialCard', () => { it('checkbox reflects showTutorial preference state', async () => { render(, { initialEntries: ['/browse'] }); - const checkbox = await screen.findByLabelText(/hide this card.*preferences/i); + const checkbox = await screen.findByLabelText( + /hide this card.*preferences/i + ); // showTutorial is true by default, so checkbox should be unchecked (inverse) expect(checkbox).not.toBeChecked(); @@ -54,7 +56,9 @@ describe('WelcomeTutorialCard', () => { render(, { initialEntries: ['/browse'] }); const user = userEvent.setup(); - const checkbox = await screen.findByLabelText(/hide this card.*preferences/i); + const checkbox = await screen.findByLabelText( + /hide this card.*preferences/i + ); await user.click(checkbox); @@ -78,7 +82,9 @@ describe('WelcomeTutorialCard', () => { render(, { initialEntries: ['/browse'] }); const user = userEvent.setup(); - const checkbox = await screen.findByLabelText(/hide this card.*preferences/i); + const checkbox = await screen.findByLabelText( + /hide this card.*preferences/i + ); await user.click(checkbox); @@ -99,4 +105,4 @@ Update failed`); ).toBeInTheDocument(); }); }); -}); \ No newline at end of file +}); From 2752fb6787ead791afccc2a566c8a524db711c5f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 13:02:38 -0500 Subject: [PATCH 17/23] refactor: change dashboard to show all cards with msgs if empty --- frontend/src/components/Browse.tsx | 14 ++++---------- .../ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx | 10 ++++++++++ .../BrowsePage/Dashboard/WelcomeTutorialCard.tsx | 3 +-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/Browse.tsx b/frontend/src/components/Browse.tsx index f4651836..cc532eff 100644 --- a/frontend/src/components/Browse.tsx +++ b/frontend/src/components/Browse.tsx @@ -4,7 +4,6 @@ import { default as log } from '@/logger'; import type { OutletContextType } from '@/layouts/BrowseLayout'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; -import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import FileBrowser from './ui/BrowsePage/FileBrowser'; import Toolbar from './ui/BrowsePage/Toolbar'; import RenameDialog from './ui/Dialogs/Rename'; @@ -30,8 +29,7 @@ export default function Browse() { } = useOutletContext(); const { fspName } = useFileBrowserContext(); - const { recentlyViewedFolders, showTutorial } = usePreferencesContext(); - const { allProxiedPathsQuery } = useProxiedPathContext(); + const { showTutorial } = usePreferencesContext(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); @@ -123,13 +121,9 @@ export default function Browse() {
800 ? '' : 'flex-col'}`} > - {recentlyViewedFolders.length === 0 ? null : ( - - )} - {allProxiedPathsQuery.isSuccess && - allProxiedPathsQuery.data.length === 0 ? null : ( - - )} + + +
) : ( diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx index 6488935d..045fb6ea 100644 --- a/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx +++ b/frontend/src/components/ui/BrowsePage/Dashboard/RecentlyViewedCard.tsx @@ -28,6 +28,16 @@ export default function RecentlyViewedCard() { {zonesAndFspQuery.error.message}
+ ) : recentlyViewedFolders.length === 0 ? ( +
+ + No recently viewed folders. + + + Start navigating the file system to see your recently viewed folders + appear here. + +
) : (
    {recentlyViewedFolders.map((item, index) => { diff --git a/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx b/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx index 26cd75d9..7f4ced5f 100644 --- a/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx +++ b/frontend/src/components/ui/BrowsePage/Dashboard/WelcomeTutorialCard.tsx @@ -24,8 +24,7 @@ export default function WelcomeTutorialCard() {
    Fileglancer helps you browse, visualize, and share scientific imaging - data on network file systems. Get started with a guided tour to learn - the basics! + data. Get started with a guided tour to learn the basics! Start Tour From 68f180e53ec31b573f7796caa0c296626533068f Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 13:09:58 -0500 Subject: [PATCH 18/23] tests: update to match new wording on WelcomeTutorialCard --- .../componentTests/WelcomeTutorialCard.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx index ff5d615e..68b9e5d4 100644 --- a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx +++ b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx @@ -33,10 +33,10 @@ describe('WelcomeTutorialCard', () => { await waitFor(() => { expect( - screen.getByLabelText(/hide this card.*preferences/i) + screen.getByLabelText(/hide this card.*help page/i) ).toBeInTheDocument(); expect( - screen.getByText(/you can always start tutorials from the help page/i) + screen.getByText(/you can always access tutorials from the help page/i) ).toBeInTheDocument(); }); }); @@ -45,7 +45,7 @@ describe('WelcomeTutorialCard', () => { render(, { initialEntries: ['/browse'] }); const checkbox = await screen.findByLabelText( - /hide this card.*preferences/i + /hide this card.*help page/i ); // showTutorial is true by default, so checkbox should be unchecked (inverse) @@ -57,14 +57,14 @@ describe('WelcomeTutorialCard', () => { const user = userEvent.setup(); const checkbox = await screen.findByLabelText( - /hide this card.*preferences/i + /hide this card.*help page/i ); await user.click(checkbox); await waitFor(() => { expect(toast.success).toHaveBeenCalledWith( - 'Welcome card hidden. Re-enable it anytime in Preferences.' + 'Welcome card hidden. Access Tutorials from the Help page.' ); }); }); @@ -83,7 +83,7 @@ describe('WelcomeTutorialCard', () => { const user = userEvent.setup(); const checkbox = await screen.findByLabelText( - /hide this card.*preferences/i + /hide this card.*help page/i ); await user.click(checkbox); From c6455270c7eedc5963516883eb5c0a7685f88856 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 13 Jan 2026 13:10:17 -0500 Subject: [PATCH 19/23] chore: prettier formatting --- .../componentTests/WelcomeTutorialCard.test.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx index 68b9e5d4..a8d4525b 100644 --- a/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx +++ b/frontend/src/__tests__/componentTests/WelcomeTutorialCard.test.tsx @@ -44,9 +44,7 @@ describe('WelcomeTutorialCard', () => { it('checkbox reflects showTutorial preference state', async () => { render(, { initialEntries: ['/browse'] }); - const checkbox = await screen.findByLabelText( - /hide this card.*help page/i - ); + const checkbox = await screen.findByLabelText(/hide this card.*help page/i); // showTutorial is true by default, so checkbox should be unchecked (inverse) expect(checkbox).not.toBeChecked(); @@ -56,9 +54,7 @@ describe('WelcomeTutorialCard', () => { render(, { initialEntries: ['/browse'] }); const user = userEvent.setup(); - const checkbox = await screen.findByLabelText( - /hide this card.*help page/i - ); + const checkbox = await screen.findByLabelText(/hide this card.*help page/i); await user.click(checkbox); @@ -82,9 +78,7 @@ describe('WelcomeTutorialCard', () => { render(, { initialEntries: ['/browse'] }); const user = userEvent.setup(); - const checkbox = await screen.findByLabelText( - /hide this card.*help page/i - ); + const checkbox = await screen.findByLabelText(/hide this card.*help page/i); await user.click(checkbox); From 72785ce0f880b580e3c70c966413d61c23571047 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 15 Jan 2026 11:25:02 -0500 Subject: [PATCH 20/23] style: make tour arrow more visible --- frontend/src/assets/arrow-up.svg | 18 +++++++++++++ .../components/tours/shepherd-overrides.css | 25 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 frontend/src/assets/arrow-up.svg diff --git a/frontend/src/assets/arrow-up.svg b/frontend/src/assets/arrow-up.svg new file mode 100644 index 00000000..8d5f961d --- /dev/null +++ b/frontend/src/assets/arrow-up.svg @@ -0,0 +1,18 @@ + + + + + + diff --git a/frontend/src/components/tours/shepherd-overrides.css b/frontend/src/components/tours/shepherd-overrides.css index c22be632..56e83820 100644 --- a/frontend/src/components/tours/shepherd-overrides.css +++ b/frontend/src/components/tours/shepherd-overrides.css @@ -131,3 +131,28 @@ background-color: rgb(var(--color-secondary)); color: rgb(var(--color-surface)); } + +/* Style the arrow pointing to the highlighted element */ +.shepherd-element .shepherd-arrow { + content: url(../../assets/arrow-up.svg); + display: inline-block; + transform: scale(1.5); + z-index: 8888; +} + +/* Rotate arrow based on tooltip placement */ + +/* Left of the target placement: arrow points right (90deg) */ +.shepherd-element[data-popper-placement^='left'] .shepherd-arrow { + transform: scale(1.5) rotate(90deg); +} + +/* Top of the target placement: arrow points down (180deg) */ +.shepherd-element[data-popper-placement^='top'] .shepherd-arrow { + transform: scale(1.5) rotate(180deg); +} + +/* Right of the target placement: arrow points left (270deg) */ +.shepherd-element[data-popper-placement^='right'] .shepherd-arrow { + transform: scale(1.5) rotate(270deg); +} From 58d6996c5b8a2e7593efdef8c58b61bf9aacbd12 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 15 Jan 2026 11:25:37 -0500 Subject: [PATCH 21/23] style: add padding around tour target in overlay --- frontend/src/components/tours/StartTour.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/tours/StartTour.tsx b/frontend/src/components/tours/StartTour.tsx index d1e12f71..05f520cb 100644 --- a/frontend/src/components/tours/StartTour.tsx +++ b/frontend/src/components/tours/StartTour.tsx @@ -318,7 +318,9 @@ export default function StartTour({ scrollTo: true, cancelIcon: { enabled: true - } + }, + modalOverlayOpeningPadding: 8, + modalOverlayOpeningRadius: 4 } }); shepherd.activeTour = tour; From 1b3a7aa051204061835691037bd89bda3736b674 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Thu, 15 Jan 2026 11:31:22 -0500 Subject: [PATCH 22/23] fix: remove conflicting min-width so that max-width:600px takes effect --- frontend/src/components/tours/shepherd-overrides.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/tours/shepherd-overrides.css b/frontend/src/components/tours/shepherd-overrides.css index 56e83820..cb724544 100644 --- a/frontend/src/components/tours/shepherd-overrides.css +++ b/frontend/src/components/tours/shepherd-overrides.css @@ -6,7 +6,7 @@ /* Override default Shepherd theme */ .shepherd-element { max-width: 600px; - min-width: fit-content; + width: fit-content; } /* Style the tour content container like FgDialog */ From f31306c350547d8b3ff82df7a08d68e64fb684a8 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Fri, 16 Jan 2026 07:53:57 -0500 Subject: [PATCH 23/23] style: change arrow color to purple --- frontend/src/assets/arrow-up.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/assets/arrow-up.svg b/frontend/src/assets/arrow-up.svg index 8d5f961d..813745d7 100644 --- a/frontend/src/assets/arrow-up.svg +++ b/frontend/src/assets/arrow-up.svg @@ -14,5 +14,5 @@ fill="currentColor" d="m 214.675,9.375 c -12.5,-12.5 -32.8,-12.5 -45.3,0 l -160,160 c -12.5,12.5 -12.5,32.8 0,45.3 12.5,12.5 32.8,12.5 45.3,0 l 105.4,-105.4 v 370.7 c 0,17.7 14.3,32 32,32 17.7,0 32,-14.3 32,-32 v -370.7 l 105.4,105.4 c 12.5,12.5 32.8,12.5 45.3,0 12.5,-12.5 12.5,-32.8 0,-45.3 l -160,-160 z" id="path1" - style="fill:#dc2626;fill-opacity:1" /> + style="fill:#6d28d9;fill-opacity:1" />