diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml
index 2b95e9d515..ef7989e4cd 100644
--- a/frontend/public/locales/en-GB/translation.toml
+++ b/frontend/public/locales/en-GB/translation.toml
@@ -1460,14 +1460,14 @@ inactive = "Inactive"
[adminOnboarding]
adminTools = "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."
-configButton = "Click the Config button to access all system settings and administrative controls."
+configButton = "Open Settings to access all system configuration and administrative controls."
connectionsSection = "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."
databaseSection = "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."
settingsOverview = "This is the Settings Panel . Admin settings are organised by category for easy navigation."
systemCustomization = "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."
teamsAndUsers = "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."
welcome = "Welcome to the Admin Tour ! Let's explore the powerful enterprise features and settings available to system administrators."
-wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."
+wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help."
[adminUserSettings]
actions = "Actions"
@@ -4892,7 +4892,7 @@ toolInterface = "This is the Crop tool interface. As you can se
viewer = "The Viewer lets you read and annotate your PDFs."
viewSwitcher = "Use these controls to select how you want to view your PDFs."
workbench = "This is the Workbench - the main area where you view and edit your PDFs."
-wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again."
+wrapUp = "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help."
[onboarding.buttons]
back = "Back"
diff --git a/frontend/src/core/components/fileEditor/FileEditorStatusDot.tsx b/frontend/src/core/components/fileEditor/FileEditorStatusDot.tsx
new file mode 100644
index 0000000000..d28ee13681
--- /dev/null
+++ b/frontend/src/core/components/fileEditor/FileEditorStatusDot.tsx
@@ -0,0 +1,9 @@
+import { StirlingFileStub } from "@app/types/fileContext";
+
+interface FileEditorStatusDotProps {
+ file: StirlingFileStub;
+}
+
+export function FileEditorStatusDot(_props: FileEditorStatusDotProps) {
+ return null;
+}
diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css
index d09f142524..fcd0335238 100644
--- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css
+++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css
@@ -196,6 +196,26 @@
pointer-events: none;
}
+.thumbBadgesRight {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ z-index: 3;
+ pointer-events: auto;
+}
+
+.statusDot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.15);
+}
+
.ownershipBadge {
background: var(--mantine-color-blue-6);
color: white;
diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
index 50cf8ddc59..0b78d4e261 100644
--- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
+++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx
@@ -27,7 +27,6 @@ import {
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
-import { truncateCenter } from "@app/utils/textUtils";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
import { useFileContext } from "@app/contexts/FileContext";
@@ -44,6 +43,8 @@ import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
+import { truncateCenter } from "@app/utils/textUtils";
+import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
interface FileEditorThumbnailProps {
file: StirlingFileStub;
@@ -303,6 +304,7 @@ const FileEditorThumbnail = ({
label: isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)"),
+ dataTour: "file-card-pin",
onClick: (e) => {
e.stopPropagation();
if (actualFile) {
@@ -504,7 +506,7 @@ const FileEditorThumbnail = ({
}}
/>
- {/* Badges — visible on hover via CSS */}
+ {/* Badges — top-left: version, pin, ownership, encrypted */}
v{file.versionNumber}
@@ -541,6 +543,8 @@ const FileEditorThumbnail = ({
)}
+
+
{/* end thumbUnit */}
diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx
index d215c0e734..c84d21f976 100644
--- a/frontend/src/core/components/onboarding/Onboarding.tsx
+++ b/frontend/src/core/components/onboarding/Onboarding.tsx
@@ -232,12 +232,15 @@ export default function Onboarding() {
loadSampleFile: tourOrch.loadSampleFile,
switchToActiveFiles: tourOrch.switchToActiveFiles,
pinFile: tourOrch.pinFile,
+ revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
+ openSettingsHelpSection: () =>
+ adminTourOrch.navigateToSection("help"),
},
}),
- [t, tourOrch, closeFilesModal, openFilesModal],
+ [t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal],
);
const whatsNewStepsConfig = useMemo(
diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts
index 4d33abb57c..1d8083201c 100644
--- a/frontend/src/core/components/onboarding/adminStepsConfig.ts
+++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts
@@ -4,6 +4,10 @@ import {
addGlowToElements,
removeAllGlows,
} from "@app/components/onboarding/tourGlow";
+import {
+ waitForElement,
+ waitForHighlightable,
+} from "@app/components/onboarding/tourUtils";
export enum AdminTourStep {
WELCOME,
@@ -57,7 +61,7 @@ export function createAdminStepsConfig({
selector: '[data-tour="config-button"]',
content: t(
"adminOnboarding.configButton",
- "Click the Config button to access all system settings and administrative controls.",
+ "Open Settings to access all system configuration and administrative controls.",
),
position: "right",
padding: 10,
@@ -204,15 +208,18 @@ export function createAdminStepsConfig({
},
},
[AdminTourStep.WRAP_UP]: {
- selector: '[data-tour="help-button"]',
+ selector: '[data-tour="admin-help-nav"]',
content: t(
"adminOnboarding.wrapUp",
- "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu.",
+ "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help.",
),
position: "right",
padding: 10,
- action: () => {
+ action: async () => {
removeAllGlows();
+ navigateToSection("help");
+ await waitForElement('[data-tour="admin-help-nav"]', 5000);
+ await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
},
},
};
diff --git a/frontend/src/core/components/onboarding/tourUtils.ts b/frontend/src/core/components/onboarding/tourUtils.ts
new file mode 100644
index 0000000000..e6d8828d39
--- /dev/null
+++ b/frontend/src/core/components/onboarding/tourUtils.ts
@@ -0,0 +1,110 @@
+/**
+ * Waits for a CSS selector to appear in the DOM using MutationObserver.
+ * Resolves immediately if already present; resolves after timeoutMs if it
+ * never appears (no throw — tour steps are best-effort).
+ */
+export function waitForElement(
+ selector: string,
+ timeoutMs = 7000,
+): Promise {
+ return new Promise((resolve) => {
+ if (typeof document === "undefined") {
+ resolve();
+ return;
+ }
+
+ if (document.querySelector(selector)) {
+ resolve();
+ return;
+ }
+
+ const observer = new MutationObserver(() => {
+ if (document.querySelector(selector)) {
+ clearTimeout(timer);
+ observer.disconnect();
+ resolve();
+ }
+ });
+
+ const timer = setTimeout(() => {
+ observer.disconnect();
+ resolve();
+ }, timeoutMs);
+
+ observer.observe(document.body, { childList: true, subtree: true });
+ });
+}
+
+const nudgeReactour = () => {
+ window.dispatchEvent(new Event("resize"));
+ requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
+};
+
+/**
+ * Waits for a CSS selector to be present AND have a non-zero bounding box,
+ * then nudges Reactour to recalculate its spotlight position.
+ *
+ * Uses MutationObserver to detect element insertion, then ResizeObserver to
+ * detect when the element receives layout dimensions.
+ */
+export function waitForHighlightable(
+ selector: string,
+ timeoutMs = 7000,
+): Promise {
+ return new Promise((resolve) => {
+ if (typeof document === "undefined") {
+ resolve();
+ return;
+ }
+
+ let mutationObserver: MutationObserver | null = null;
+ let resizeObserver: ResizeObserver | null = null;
+
+ const cleanup = () => {
+ mutationObserver?.disconnect();
+ resizeObserver?.disconnect();
+ };
+
+ const done = () => {
+ clearTimeout(timer);
+ cleanup();
+ nudgeReactour();
+ resolve();
+ };
+
+ const timer = setTimeout(done, timeoutMs);
+
+ const watchLayout = (el: HTMLElement) => {
+ if (el.getClientRects().length > 0) {
+ done();
+ return;
+ }
+ resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ if (entry.contentRect.width > 0 || entry.contentRect.height > 0) {
+ done();
+ return;
+ }
+ }
+ });
+ resizeObserver.observe(el);
+ };
+
+ const el = document.querySelector(selector);
+ if (el) {
+ watchLayout(el);
+ return;
+ }
+
+ mutationObserver = new MutationObserver(() => {
+ const found = document.querySelector(selector);
+ if (found) {
+ mutationObserver!.disconnect();
+ mutationObserver = null;
+ watchLayout(found);
+ }
+ });
+
+ mutationObserver.observe(document.body, { childList: true, subtree: true });
+ });
+}
diff --git a/frontend/src/core/components/onboarding/userStepsConfig.ts b/frontend/src/core/components/onboarding/userStepsConfig.ts
index f5805cbf68..e8a52803da 100644
--- a/frontend/src/core/components/onboarding/userStepsConfig.ts
+++ b/frontend/src/core/components/onboarding/userStepsConfig.ts
@@ -1,5 +1,9 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
+import {
+ waitForElement,
+ waitForHighlightable,
+} from "@app/components/onboarding/tourUtils";
export enum TourStep {
ALL_TOOLS,
@@ -26,9 +30,11 @@ interface UserStepActions {
loadSampleFile: () => void;
switchToActiveFiles: () => void;
pinFile: () => void;
+ revealFileCardHoverMenu: () => void;
modifyCropSettings: () => void;
executeTool: () => void;
openFilesModal: () => void;
+ openSettingsHelpSection: () => void;
}
interface CreateUserStepsConfigArgs {
@@ -48,9 +54,11 @@ export function createUserStepsConfig({
loadSampleFile,
switchToActiveFiles,
pinFile,
+ revealFileCardHoverMenu,
modifyCropSettings,
executeTool,
openFilesModal,
+ openSettingsHelpSection,
} = actions;
return {
@@ -95,7 +103,7 @@ export function createUserStepsConfig({
),
position: "right",
padding: 10,
- action: () => openFilesModal(),
+ actionAfter: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
@@ -105,6 +113,10 @@ export function createUserStepsConfig({
),
position: "right",
padding: 0,
+ action: async () => {
+ await waitForElement('[data-tour="file-sources"]', 5000);
+ await waitForHighlightable('[data-tour="file-sources"]', 5000);
+ },
actionAfter: () => {
loadSampleFile();
closeFilesModal();
@@ -184,16 +196,22 @@ export function createUserStepsConfig({
),
position: "left",
padding: 10,
- action: () => pinFile(),
+ action: () => revealFileCardHoverMenu(),
+ actionAfter: () => pinFile(),
},
[TourStep.WRAP_UP]: {
- selector: '[data-tour="help-button"]',
+ selector: '[data-tour="admin-help-nav"]',
content: t(
"onboarding.wrapUp",
- "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again.",
+ "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help.",
),
position: "right",
padding: 10,
+ action: async () => {
+ openSettingsHelpSection();
+ await waitForElement('[data-tour="admin-help-nav"]', 5000);
+ await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
+ },
},
};
}
diff --git a/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts
index 4501d89739..5e52ac7549 100644
--- a/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts
+++ b/frontend/src/core/components/onboarding/whatsNewStepsConfig.ts
@@ -1,52 +1,9 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
-
-async function waitForElement(
- selector: string,
- timeoutMs = 7000,
- intervalMs = 100,
-): Promise {
- if (typeof document === "undefined") return;
- const start = Date.now();
- // Immediate hit
- if (document.querySelector(selector)) return;
-
- return new Promise((resolve) => {
- const check = () => {
- if (document.querySelector(selector) || Date.now() - start >= timeoutMs) {
- resolve();
- return;
- }
- setTimeout(check, intervalMs);
- };
- check();
- });
-}
-
-async function waitForHighlightable(
- selector: string,
- timeoutMs = 7000,
- intervalMs = 500,
-): Promise {
- if (typeof document === "undefined") return;
- const start = Date.now();
-
- return new Promise((resolve) => {
- const check = () => {
- const el = document.querySelector(selector);
- const isVisible = !!el && el.getClientRects().length > 0;
- if (isVisible || Date.now() - start >= timeoutMs) {
- // Nudge Reactour to recalc positions in case layout shifted
- window.dispatchEvent(new Event("resize"));
- requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
- resolve();
- return;
- }
- setTimeout(check, intervalMs);
- };
- check();
- });
-}
+import {
+ waitForElement,
+ waitForHighlightable,
+} from "@app/components/onboarding/tourUtils";
export enum WhatsNewTourStep {
QUICK_ACCESS,
@@ -124,17 +81,15 @@ export function createWhatsNewStepsConfig({
),
position: "right",
padding: 10,
- action: async () => {
- openFilesModal();
- await waitForElement('[data-tour="file-sources"]', 5000, 100);
- },
actionAfter: async () => {
+ openFilesModal();
+ await waitForElement('[data-tour="file-sources"]', 5000);
await Promise.resolve(loadSampleFile());
closeFilesModal();
switchToViewer();
// wait for file render and top controls to mount
- await waitForElement('[data-tour="view-switcher"]', 7000, 100);
- await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
+ await waitForElement('[data-tour="view-switcher"]', 7000);
+ await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.RIGHT_RAIL]: {
@@ -150,7 +105,7 @@ export function createWhatsNewStepsConfig({
position: "left",
padding: 10,
action: async () => {
- await waitForElement('[data-tour="right-rail-controls"]', 7000, 100);
+ await waitForElement('[data-tour="right-rail-controls"]', 7000);
selectFirstFile();
},
},
@@ -165,8 +120,8 @@ export function createWhatsNewStepsConfig({
// Ensure the switcher has mounted before this step renders
action: async () => {
switchToViewer();
- await waitForElement('[data-tour="view-switcher"]', 7000, 100);
- await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
+ await waitForElement('[data-tour="view-switcher"]', 7000);
+ await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
@@ -179,8 +134,8 @@ export function createWhatsNewStepsConfig({
padding: 8,
action: async () => {
switchToPageEditor();
- await waitForElement('[data-tour="view-switcher"]', 7000, 100);
- await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
+ await waitForElement('[data-tour="view-switcher"]', 7000);
+ await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
@@ -193,8 +148,8 @@ export function createWhatsNewStepsConfig({
padding: 8,
action: async () => {
switchToActiveFiles();
- await waitForElement('[data-tour="view-switcher"]', 7000, 100);
- await waitForHighlightable('[data-tour="view-switcher"]', 7000, 500);
+ await waitForElement('[data-tour="view-switcher"]', 7000);
+ await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.WRAP_UP]: {
diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx
index 765d099416..142c29314e 100644
--- a/frontend/src/core/components/shared/AppConfigModal.tsx
+++ b/frontend/src/core/components/shared/AppConfigModal.tsx
@@ -108,11 +108,26 @@ const AppConfigModalInner: React.FC = ({
const runningEE = config?.runningEE ?? false;
const loginEnabled = config?.enableLogin ?? false;
+ const handleClose = useCallback(async () => {
+ const canProceed = await confirmIfDirty();
+ if (!canProceed) return;
+
+ // Navigate back to home when closing modal
+ navigate("/", { replace: true });
+ onClose();
+ }, [confirmIfDirty, navigate, onClose]);
+
+ // Synchronous wrapper for contexts (e.g. tour buttons) that need () => void
+ const handleCloseSync = useCallback(() => {
+ void handleClose();
+ }, [handleClose]);
+
// Left navigation structure and icons
const configNavSections = useConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
+ handleCloseSync,
);
const activeLabel = useMemo(() => {
@@ -131,15 +146,6 @@ const AppConfigModalInner: React.FC = ({
return null;
}, [configNavSections, active]);
- const handleClose = useCallback(async () => {
- const canProceed = await confirmIfDirty();
- if (!canProceed) return;
-
- // Navigate back to home when closing modal
- navigate("/", { replace: true });
- onClose();
- }, [confirmIfDirty, navigate, onClose]);
-
const handleNavigation = useCallback(
async (key: NavKey) => {
const canProceed = await confirmIfDirty();
diff --git a/frontend/src/core/components/shared/FileSidebar.tsx b/frontend/src/core/components/shared/FileSidebar.tsx
index 54f8d4b7ac..bcf4f9666e 100644
--- a/frontend/src/core/components/shared/FileSidebar.tsx
+++ b/frontend/src/core/components/shared/FileSidebar.tsx
@@ -316,6 +316,7 @@ const FileSidebar = forwardRef(
style={{ width, minWidth: width, maxWidth: width }}
data-collapsed={collapsed}
data-sidebar="file-sidebar"
+ data-tour="quick-access-bar"
>
{/* Header: hamburger + branding */}
@@ -392,6 +393,7 @@ const FileSidebar = forwardRef
(
{
if (collapsed && onToggleCollapse) onToggleCollapse();
openFilesModal();
@@ -555,6 +557,7 @@ const FileSidebar = forwardRef
(
: undefined
}
data-testid={onOpenSettings ? "config-button" : undefined}
+ data-tour={onOpenSettings ? "config-button" : undefined}
aria-label={
onOpenSettings
? t("fileSidebar.openSettings", "Open settings")
diff --git a/frontend/src/core/components/shared/HoverActionMenu.tsx b/frontend/src/core/components/shared/HoverActionMenu.tsx
index e617d3c792..9bec8bc600 100644
--- a/frontend/src/core/components/shared/HoverActionMenu.tsx
+++ b/frontend/src/core/components/shared/HoverActionMenu.tsx
@@ -11,6 +11,7 @@ export interface HoverAction {
disabled?: boolean;
color?: string;
hidden?: boolean;
+ dataTour?: string;
}
interface HoverActionMenuProps {
@@ -64,6 +65,7 @@ const HoverActionMenu: React.FC = ({
onClick={action.onClick}
c={action.color}
style={{ color: action.color || "var(--right-rail-icon)" }}
+ data-tour={action.dataTour}
>
{action.icon}
diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx
index 0e3b521daa..50a1cba225 100644
--- a/frontend/src/core/components/shared/InfoBanner.tsx
+++ b/frontend/src/core/components/shared/InfoBanner.tsx
@@ -97,7 +97,11 @@ export const InfoBanner: React.FC = ({
radius={0}
style={{
background: background ?? toneStyle.background,
- borderBottom: `1px solid ${borderColor ?? toneStyle.border}`,
+ border: "none",
+ borderBottom:
+ borderColor === "transparent"
+ ? "none"
+ : `1px solid ${borderColor ?? toneStyle.border}`,
minHeight,
display: "flex",
alignItems: "center",
diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx
index d9b9f56d83..68ed7285dd 100644
--- a/frontend/src/core/components/shared/config/configNavSections.tsx
+++ b/frontend/src/core/components/shared/config/configNavSections.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { NavKey } from "@app/components/shared/config/types";
import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection";
import GeneralSection from "@app/components/shared/config/configSections/GeneralSection";
+import HelpSection from "@app/components/shared/config/configSections/HelpSection";
export interface ConfigNavItem {
key: NavKey;
@@ -34,6 +35,7 @@ export const useConfigNavSections = (
_isAdmin: boolean = false,
_runningEE: boolean = false,
_loginEnabled: boolean = false,
+ onRequestClose: () => void = () => {},
): ConfigNavSection[] => {
const { t } = useTranslation();
@@ -55,6 +57,19 @@ export const useConfigNavSections = (
},
],
},
+ {
+ title: t("settings.help.title", "Help"),
+ items: [
+ {
+ key: "help",
+ label: t("settings.help.label", "Tours"),
+ icon: "help-rounded",
+ component: (
+
+ ),
+ },
+ ],
+ },
];
return sections;
diff --git a/frontend/src/core/components/shared/config/configSections/HelpSection.tsx b/frontend/src/core/components/shared/config/configSections/HelpSection.tsx
new file mode 100644
index 0000000000..89299fe3d0
--- /dev/null
+++ b/frontend/src/core/components/shared/config/configSections/HelpSection.tsx
@@ -0,0 +1,86 @@
+import React from "react";
+import { Button, Group, Paper, Stack, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import LocalIcon from "@app/components/shared/LocalIcon";
+import { requestStartTour } from "@app/constants/events";
+
+interface HelpSectionProps {
+ isAdmin: boolean;
+ onRequestClose: () => void;
+}
+
+const HelpSection: React.FC = ({
+ isAdmin,
+ onRequestClose,
+}) => {
+ const { t } = useTranslation();
+
+ const startTour = (tourType: "tools" | "admin") => {
+ onRequestClose();
+ setTimeout(() => requestStartTour(tourType), 300);
+ };
+
+ return (
+
+
+
+
+
+
+ {t("settings.help.toolsTour.title", "Tools Tour")}
+
+
+ {t(
+ "settings.help.toolsTour.description",
+ "Walk through uploading files, picking a tool, and reviewing results.",
+ )}
+
+
+
+ }
+ onClick={() => startTour("tools")}
+ >
+ {t("settings.help.toolsTour.start", "Start")}
+
+
+
+ {isAdmin && (
+
+
+
+ {t("settings.help.adminTour.title", "Admin Tour")}
+
+
+ {t(
+ "settings.help.adminTour.description",
+ "Explore team management, system settings, and enterprise features.",
+ )}
+
+
+
+ }
+ onClick={() => startTour("admin")}
+ >
+ {t("settings.help.adminTour.start", "Start")}
+
+
+ )}
+
+
+
+ );
+};
+
+export default HelpSection;
diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts
index 6a22ed3112..110c7801ee 100644
--- a/frontend/src/core/components/shared/config/types.ts
+++ b/frontend/src/core/components/shared/config/types.ts
@@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [
"adminUsage",
"adminEndpoints",
"adminStorageSharing",
+ "help",
] as const;
// Derive the type from the array
diff --git a/frontend/src/core/contexts/TourOrchestrationContext.tsx b/frontend/src/core/contexts/TourOrchestrationContext.tsx
index 1a338b6b59..eca4229755 100644
--- a/frontend/src/core/contexts/TourOrchestrationContext.tsx
+++ b/frontend/src/core/contexts/TourOrchestrationContext.tsx
@@ -30,6 +30,7 @@ interface TourOrchestrationContextType {
// File operations
selectFirstFile: () => void;
pinFile: () => void;
+ revealFileCardHoverMenu: () => void;
// Crop settings (placeholder for now)
modifyCropSettings: () => void;
@@ -192,6 +193,20 @@ export const TourOrchestrationProvider: React.FC<{
window.dispatchEvent(event);
}, []);
+ const revealFileCardHoverMenu = useCallback(() => {
+ // The hover menu is hidden via CSS until the card is hovered.
+ // Override data-force-visible so the CSS rule makes it opacity:1 / pointer-events:auto,
+ // then nudge Reactour to re-measure the now-visible pin button.
+ const menu = document.querySelector(
+ '[data-hover-action-menu-mode="cssHover"]',
+ ) as HTMLElement | null;
+ if (menu) {
+ menu.setAttribute("data-force-visible", "true");
+ window.dispatchEvent(new Event("resize"));
+ requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
+ }
+ }, []);
+
const executeTool = useCallback(() => {
// Trigger the run button click
const runButton = document.querySelector(
@@ -213,6 +228,7 @@ export const TourOrchestrationProvider: React.FC<{
switchToActiveFiles,
selectFirstFile,
pinFile,
+ revealFileCardHoverMenu,
modifyCropSettings,
executeTool,
};
diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx
index 11b9659687..dd2d88bd14 100644
--- a/frontend/src/core/pages/HomePage.tsx
+++ b/frontend/src/core/pages/HomePage.tsx
@@ -15,6 +15,7 @@ import {
useNavigationActions,
} from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
+import { useLocation } from "react-router-dom";
import AppsIcon from "@mui/icons-material/AppsRounded";
import ToolPanel from "@app/components/tools/ToolPanel";
@@ -57,6 +58,14 @@ export default function HomePage() {
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [fileSidebarCollapsed, setFileSidebarCollapsed] = useState(false);
+ const location = useLocation();
+
+ // Open the config modal whenever the URL is /settings/* (e.g. from the admin
+ // tour's openConfigModal action which navigates to /settings/overview).
+ useEffect(() => {
+ const isSettings = location.pathname.startsWith("/settings");
+ setConfigModalOpen(isSettings);
+ }, [location.pathname]);
const { activeFiles } = useFileContext();
const navigationState = useNavigationState();
diff --git a/frontend/src/core/tests/helpers/api-stubs.ts b/frontend/src/core/tests/helpers/api-stubs.ts
index 4dd0286b1d..b18653d88d 100644
--- a/frontend/src/core/tests/helpers/api-stubs.ts
+++ b/frontend/src/core/tests/helpers/api-stubs.ts
@@ -118,6 +118,8 @@ const DEFAULT_ENDPOINTS_AVAILABILITY = Object.fromEntries(
export interface MockAppApiOptions {
/** Override `enableLogin`. Default `false` — app loads in anonymous mode. */
enableLogin?: boolean;
+ /** Override `isAdmin` in app-config. Default `false`. */
+ isAdmin?: boolean;
/** Override the logged-in user returned by `/auth/me`. */
user?: {
id?: number;
@@ -145,6 +147,7 @@ export async function mockAppApis(
): Promise {
const {
enableLogin = false,
+ isAdmin = false,
user = {
id: 1,
username: "testuser",
@@ -167,6 +170,7 @@ export async function mockAppApis(
route.fulfill({
json: {
enableLogin,
+ isAdmin,
languages,
defaultLocale,
},
diff --git a/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts b/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts
index 9e5f5ad856..fb0c438285 100644
--- a/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts
+++ b/frontend/src/core/tests/stubbed/tour-onboarding.spec.ts
@@ -1,73 +1,232 @@
+import path from "path";
import { test, expect } from "@app/tests/helpers/stub-test-base";
+import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers";
-test.describe("15. Tour/Onboarding", () => {
- test.describe("15.1 Tour Button", () => {
- test("should start and dismiss tour guide", async ({ page }) => {
- // The help/tours button was in the old QuickAccessBar which is not rendered
- // in the redesigned UI (replaced by FileSidebar). Skip until a tour entry
- // point is added to the new layout.
- test.skip(
- true,
- "Help/tour button not yet present in redesigned FileSidebar UI",
- );
+/**
+ * Tour selector integrity tests.
+ *
+ * Each test asserts that a `data-tour="…"` element referenced by one of the
+ * three guided tours (user, admin, whats-new) is actually present in the DOM
+ * at the point in the UI where the tour step would fire. If an element is
+ * renamed or removed the test fails immediately, surfacing the breakage before
+ * it silently breaks the tour for real users.
+ *
+ * Selectors under test come from:
+ * - userStepsConfig.ts
+ * - adminStepsConfig.ts
+ * - whatsNewStepsConfig.ts
+ */
- // Step 1: Click the Tours button (identified by data-tour="help-button")
- const tourButton = page.locator('[data-tour="help-button"]').first();
- await expect(tourButton).toBeVisible({ timeout: 5000 });
- await tourButton.click();
+const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
- // Step 2: Verify the tours menu opens with tour options
- const toursMenu = page.locator(".mantine-Menu-dropdown").first();
- await expect(toursMenu).toBeVisible({ timeout: 5000 });
-
- // Step 3: Click on the "See what's new in V2" tour option to start a tour
- const whatsNewOption = page.getByText(/what.?s new|See what/i).first();
- await expect(whatsNewOption).toBeVisible({ timeout: 5000 });
- await whatsNewOption.click();
-
- // Step 4: Verify the reactour popover appears (tour has started)
- const tourPopover = page.locator(".reactour__popover").first();
- await expect(tourPopover).toBeVisible({ timeout: 10000 });
-
- // Step 5: Dismiss the tour by clicking the close button inside the popover
- const closeButton = tourPopover.locator("button").first();
- if (await closeButton.isVisible({ timeout: 3000 }).catch(() => false)) {
- await closeButton.click();
- } else {
- // Fallback: press Escape to close the tour
- await page.keyboard.press("Escape");
- }
-
- // Step 6: Verify the tour popover disappears
- await expect(tourPopover).not.toBeVisible({ timeout: 5000 });
+// ---------------------------------------------------------------------------
+// 15.1 Static layout — always visible on the main page
+// ---------------------------------------------------------------------------
+test.describe("15.1 Tour selectors — static layout", () => {
+ test("tool-panel is present", async ({ page }) => {
+ await expect(page.locator('[data-tour="tool-panel"]').first()).toBeVisible({
+ timeout: 10_000,
});
});
- test.describe("15.2 Tour Tooltip", () => {
- test("should show and dismiss tour tooltip", async ({ page }) => {
- // Step 1: Check if a tours tooltip with a close button appears near the help button
- // The tooltip uses the custom Tooltip component with showCloseButton enabled
- const tooltipContent = page
- .locator('[data-radix-popper-content-wrapper], [role="tooltip"]')
- .first();
+ test("quick-access-bar (FileSidebar) is present", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="quick-access-bar"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
- // Step 2: If a tooltip with a close button is visible, dismiss it
- const closeButton = tooltipContent.locator("button").first();
- if (await closeButton.isVisible({ timeout: 5000 }).catch(() => false)) {
- await closeButton.click();
+ test("files-button is present", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="files-button"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
- // Step 3: Verify the tooltip content disappears
- await expect(tooltipContent).not.toBeVisible({ timeout: 5000 });
- }
+ test("config-button is present", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="config-button"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
- // Step 4: Refresh the page and verify it loads normally
- await page.reload();
- await page.waitForLoadState("domcontentloaded");
+ test("tool-button-crop is present in tool panel", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="tool-button-crop"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
- // Step 5: Verify the page loaded (search box or navigation visible)
- await expect(page.getByPlaceholder(/search|cari/i).first()).toBeVisible({
- timeout: 10000,
- });
+ // help-button: not yet implemented in the redesigned FileSidebar layout.
+ // Re-enable once a tours/help entry point is added to the new UI.
+ test.skip("help-button is present — TODO: add to new sidebar layout", async ({
+ page,
+ }) => {
+ await expect(page.locator('[data-tour="help-button"]').first()).toBeVisible(
+ { timeout: 10_000 },
+ );
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 15.2 Tour selectors — files modal
+// ---------------------------------------------------------------------------
+test.describe("15.2 Tour selectors — files modal", () => {
+ test("file-sources is present when files modal is open", async ({ page }) => {
+ await page.getByTestId("files-button").click();
+ await expect(
+ page.locator('[data-tour="file-sources"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ await page.keyboard.press("Escape");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 15.3 Tour selectors — workbench elements (require a loaded file)
+// ---------------------------------------------------------------------------
+test.describe("15.3 Tour selectors — workbench with file", () => {
+ test.beforeEach(async ({ page }) => {
+ await uploadFiles(page, SAMPLE_PDF);
+ });
+
+ test("workbench is present", async ({ page }) => {
+ await expect(page.locator('[data-tour="workbench"]').first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test("view-switcher is present in viewer mode", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="view-switcher"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("right-rail-controls is present", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="right-rail-controls"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("right-rail-settings is present", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="right-rail-settings"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 15.4 Tour selectors — active files view (file-card-checkbox, file-card-pin)
+// Two files → app auto-navigates to fileEditor (active files) mode.
+// ---------------------------------------------------------------------------
+test.describe("15.4 Tour selectors — active files view", () => {
+ test.beforeEach(async ({ page }) => {
+ // Two files → getStartupNavigationAction returns workbench:"fileEditor"
+ await uploadFiles(page, [SAMPLE_PDF, SAMPLE_PDF]);
+ });
+
+ test("file-card-checkbox is present in active files view", async ({
+ page,
+ }) => {
+ await expect(
+ page.locator('[data-tour="file-card-checkbox"]').first(),
+ ).toBeVisible({ timeout: 15_000 });
+ });
+
+ test("file-card-pin is in DOM when file cards are rendered", async ({
+ page,
+ }) => {
+ // The pin button lives inside HoverActionMenu (CSS-hover driven, always
+ // attached to DOM). Hover over the first file card to ensure the element
+ // is rendered, then assert it is attached.
+ const fileCard = page.locator('[data-tour="file-card-checkbox"]').first();
+ await expect(fileCard).toBeVisible({ timeout: 15_000 });
+ await fileCard.hover();
+ await expect(
+ page.locator('[data-tour="file-card-pin"]').first(),
+ ).toBeAttached({ timeout: 5_000 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 15.5 Tour selectors — crop tool (crop-settings, run-button)
+// ---------------------------------------------------------------------------
+test.describe("15.5 Tour selectors — crop tool", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/crop", { waitUntil: "domcontentloaded" });
+ await uploadFiles(page, SAMPLE_PDF);
+ });
+
+ test("crop-settings is present", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="crop-settings"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("run-button is present", async ({ page }) => {
+ await expect(page.locator('[data-tour="run-button"]').first()).toBeVisible({
+ timeout: 10_000,
});
});
});
+
+// ---------------------------------------------------------------------------
+// 15.6 Tour selectors — config modal (non-admin)
+// ---------------------------------------------------------------------------
+test.describe("15.6 Tour selectors — config modal", () => {
+ test.beforeEach(async ({ page }) => {
+ await openSettings(page);
+ });
+
+ test("modal-nav is present", async ({ page }) => {
+ await expect(page.locator(".modal-nav").first()).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test("settings-content-area is present", async ({ page }) => {
+ await expect(
+ page.locator('[data-tour="settings-content-area"]').first(),
+ ).toBeVisible({ timeout: 10_000 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 15.7 Tour selectors — admin config modal nav items
+// Requires isAdmin:true in app-config so the proprietary admin sections render.
+// These nav items are EE-only; the test is skipped in core-only builds where
+// the sections are not registered.
+// ---------------------------------------------------------------------------
+test.describe("15.7 Tour selectors — admin modal nav items", () => {
+ test.use({
+ stubOptions: { enableLogin: true, isAdmin: true },
+ });
+
+ const adminNavSections = [
+ "people",
+ "teams",
+ "adminGeneral",
+ "adminFeatures",
+ "adminEndpoints",
+ "adminDatabase",
+ "adminConnections",
+ "adminAudit",
+ "adminUsage",
+ "help",
+ ] as const;
+
+ for (const section of adminNavSections) {
+ test(`admin-${section}-nav is present`, async ({ page }) => {
+ await openSettings(page);
+ const navItem = page
+ .locator(`[data-tour="admin-${section}-nav"]`)
+ .first();
+ const isPresent = await navItem
+ .isVisible({ timeout: 5_000 })
+ .catch(() => false);
+ if (!isPresent) {
+ test.skip(
+ true,
+ `admin-${section}-nav not rendered — section may be EE-only or not yet ported to this build`,
+ );
+ return;
+ }
+ await expect(navItem).toBeVisible();
+ });
+ }
+});
diff --git a/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx
index 46349fd31a..9fa92bfc65 100644
--- a/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx
+++ b/frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx
@@ -1,6 +1,4 @@
import React from "react";
-import { Tooltip } from "@mantine/core";
-import { useTranslation } from "react-i18next";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { truncateCenter } from "@app/utils/textUtils";
@@ -14,57 +12,8 @@ const FileEditorFileName = ({
file,
maxLength = 40,
}: FileEditorFileNameProps) => {
- const { t } = useTranslation();
-
return (
- <>
- {truncateCenter(file.name, maxLength)}
- {!file.localFilePath && (
-
-
-
- )}
- {file.localFilePath && file.isDirty && (
-
-
-
- )}
- {file.localFilePath && !file.isDirty && (
-
-
-
- )}
- >
+ {truncateCenter(file.name, maxLength)}
);
};
diff --git a/frontend/src/desktop/components/fileEditor/FileEditorStatusDot.tsx b/frontend/src/desktop/components/fileEditor/FileEditorStatusDot.tsx
new file mode 100644
index 0000000000..e27b50dadc
--- /dev/null
+++ b/frontend/src/desktop/components/fileEditor/FileEditorStatusDot.tsx
@@ -0,0 +1,36 @@
+import { Tooltip } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+import { StirlingFileStub } from "@app/types/fileContext";
+import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
+
+interface FileEditorStatusDotProps {
+ file: StirlingFileStub;
+}
+
+export function FileEditorStatusDot({ file }: FileEditorStatusDotProps) {
+ const { t } = useTranslation();
+
+ const label = !file.localFilePath
+ ? t("fileNotSavedToDisk", "Not saved to disk")
+ : file.isDirty
+ ? t("unsavedChanges", "Unsaved changes")
+ : t("fileSavedToDisk", "Saved to disk");
+
+ const color = !file.localFilePath
+ ? "var(--mantine-color-red-6)"
+ : file.isDirty
+ ? "var(--mantine-color-yellow-6)"
+ : "var(--mantine-color-green-6)";
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx
index b21ac08c81..1ccfea9e7b 100644
--- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx
+++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx
@@ -31,11 +31,17 @@ export const useConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false,
+ onRequestClose: () => void = () => {},
): ConfigNavSection[] => {
const { t } = useTranslation();
- // Get the core sections (just Preferences)
- const sections = useCoreConfigNavSections(isAdmin, runningEE, loginEnabled);
+ // Get the core sections (Preferences + Help)
+ const sections = useCoreConfigNavSections(
+ isAdmin,
+ runningEE,
+ loginEnabled,
+ onRequestClose,
+ );
// Add account management under Preferences
const preferencesSection = sections.find((section) =>