From 24c1c16f2f35a23fd054dcfc73f7e25ca1555391 Mon Sep 17 00:00:00 2001
From: Varun
Date: Sat, 28 Mar 2026 15:01:24 +0530
Subject: [PATCH 1/7] Add workspace document sync and analytics controls
---
docs-site/astro.config.mjs | 42 ++++
docs-site/package.json | 1 +
docs-site/src/pages/index.astro | 20 +-
package.json | 1 +
src/App.tsx | 31 ++-
src/components/ErrorBoundary.tsx | 5 +
src/components/FlowEditor.tsx | 8 +-
src/components/FlowTabs.tsx | 64 ++---
src/components/HomePage.integration.test.tsx | 123 ++++++++-
src/components/HomePage.tsx | 37 +--
.../SettingsModal/GeneralSettings.tsx | 20 ++
.../SettingsModal/PrivacySettings.tsx | 58 -----
src/components/StudioPlaybackPanel.tsx | 12 +-
src/components/TopNav.tsx | 38 +--
src/components/WelcomeModal.tsx | 168 +++++--------
src/components/command-bar/PagesView.tsx | 34 +--
.../flow-editor/FlowEditorChrome.tsx | 30 +--
.../buildFlowEditorControllerParams.ts | 12 +-
.../flow-editor/flowEditorChromeProps.ts | 48 ++--
.../flow-editor/useFlowEditorController.ts | 12 +-
.../flow-editor/useFlowEditorRuntime.ts | 12 +-
.../useFlowEditorScreenBehavior.ts | 8 +-
.../flow-editor/useFlowEditorScreenModel.ts | 20 +-
.../flow-editor/useFlowEditorScreenState.ts | 25 +-
.../useFlowEditorShellController.test.tsx | 8 +-
.../useFlowEditorShellController.ts | 16 +-
src/components/home/HomeDashboard.tsx | 140 ++++++-----
src/components/home/HomeFlowDialogs.tsx | 2 +-
src/components/home/welcomeModalState.ts | 4 +-
src/components/useExportMenu.ts | 5 +
src/hooks/useAIGeneration.ts | 43 +++-
src/hooks/useAnalyticsPreference.ts | 16 ++
src/hooks/useFlowEditorActions.ts | 26 +-
src/hooks/useFlowEditorCallbacks.ts | 56 ++---
src/hooks/useFlowEditorCollaboration.ts | 8 +-
src/hooks/useFlowHistory.ts | 10 +-
src/hooks/usePlayback.ts | 10 +-
src/i18n/locales/de/translation.json | 17 +-
src/i18n/locales/en/translation.json | 27 +-
src/i18n/locales/es/translation.json | 17 +-
src/i18n/locales/fr/translation.json | 17 +-
src/i18n/locales/ja/translation.json | 17 +-
src/i18n/locales/tr/translation.json | 23 +-
src/i18n/locales/zh/translation.json | 17 +-
src/index.tsx | 8 +
src/services/analytics/analytics.ts | 157 ++++++++++++
.../analytics/analyticsSettings.test.ts | 33 +++
src/services/analytics/analyticsSettings.ts | 50 ++++
.../analytics/surfaceAnalyticsClient.ts | 125 +++++++++
src/services/onboarding/events.ts | 7 +
.../storage/flowDocumentModel.test.ts | 118 +++++++++
src/services/storage/flowDocumentModel.ts | 133 ++++++++++
src/services/storage/localFirstRepository.ts | 125 ++++-----
src/services/storage/localFirstRuntime.ts | 64 +++--
.../storage/persistedDocumentAdapters.test.ts | 77 ++++++
.../storage/persistedDocumentAdapters.ts | 106 ++++++++
src/services/storage/persistenceTypes.ts | 50 ++++
src/services/storage/storageTelemetry.test.ts | 29 ++-
src/services/storage/storageTelemetry.ts | 21 +-
src/store.ts | 5 +
src/store/actions/createTabActions.ts | 77 +++++-
.../actions/createWorkspaceDocumentActions.ts | 238 ++++++++++++++++++
src/store/documentHooks.ts | 84 +++++++
src/store/documentStateSync.test.ts | 66 +++++
src/store/documentStateSync.ts | 48 ++++
src/store/editorPageHooks.ts | 54 ++++
src/store/persistence.ts | 22 ++
src/store/tabHooks.ts | 2 +
src/store/types.ts | 10 +
src/store/workspaceDocumentModel.test.ts | 84 +++++++
src/store/workspaceDocumentModel.ts | 186 ++++++++++++++
tsconfig.tsbuildinfo | 2 +-
web/package.json | 1 +
web/src/components/LandingAnalytics.astro | 33 +++
web/src/components/Layout.astro | 2 +
.../components/landing/FinalCTASection.tsx | 25 +-
web/src/components/landing/Footer.tsx | 9 +
web/src/components/landing/HeroSection.tsx | 8 +-
web/src/components/landing/Navbar.tsx | 15 ++
79 files changed, 2652 insertions(+), 730 deletions(-)
delete mode 100644 src/components/SettingsModal/PrivacySettings.tsx
create mode 100644 src/hooks/useAnalyticsPreference.ts
create mode 100644 src/services/analytics/analytics.ts
create mode 100644 src/services/analytics/analyticsSettings.test.ts
create mode 100644 src/services/analytics/analyticsSettings.ts
create mode 100644 src/services/analytics/surfaceAnalyticsClient.ts
create mode 100644 src/services/storage/flowDocumentModel.test.ts
create mode 100644 src/services/storage/flowDocumentModel.ts
create mode 100644 src/services/storage/persistedDocumentAdapters.test.ts
create mode 100644 src/services/storage/persistedDocumentAdapters.ts
create mode 100644 src/services/storage/persistenceTypes.ts
create mode 100644 src/store/actions/createWorkspaceDocumentActions.ts
create mode 100644 src/store/documentHooks.ts
create mode 100644 src/store/documentStateSync.test.ts
create mode 100644 src/store/documentStateSync.ts
create mode 100644 src/store/editorPageHooks.ts
create mode 100644 src/store/workspaceDocumentModel.test.ts
create mode 100644 src/store/workspaceDocumentModel.ts
create mode 100644 web/src/components/LandingAnalytics.astro
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
index bedef463..e4c0ec02 100644
--- a/docs-site/astro.config.mjs
+++ b/docs-site/astro.config.mjs
@@ -27,6 +27,48 @@ export default defineConfig({
},
sidebar: toStarlightSidebar(),
customCss: ['./src/styles/custom.css'],
+ head: [
+ {
+ tag: 'script',
+ attrs: { type: 'module' },
+ content: `
+ import { initializeSurfaceAnalytics } from '../../src/services/analytics/surfaceAnalyticsClient';
+
+ const analytics = initializeSurfaceAnalytics({
+ surface: 'docs',
+ apiKey: import.meta.env.PUBLIC_POSTHOG_KEY,
+ apiHost: import.meta.env.PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com',
+ enabled: import.meta.env.PUBLIC_ENABLE_ANALYTICS === 'true',
+ });
+
+ analytics.capturePageView('docs_page_viewed');
+
+ document.addEventListener('click', (event) => {
+ const element = event.target instanceof Element ? event.target.closest('a') : null;
+ if (!(element instanceof HTMLAnchorElement)) return;
+
+ const href = element.href || '';
+ const target = element.dataset.analyticsTarget || null;
+ const placement = element.dataset.analyticsPlacement || null;
+ const explicitEvent = element.dataset.analyticsEvent || null;
+
+ if (explicitEvent) {
+ analytics.capture(explicitEvent, { href, target, placement });
+ return;
+ }
+
+ if (href.includes('app.openflowkit.com')) {
+ analytics.capture('docs_open_app_clicked', { href, target: 'app', placement });
+ return;
+ }
+
+ if (href.includes('github.com/Vrun-design/openflowkit')) {
+ analytics.capture('docs_github_clicked', { href, target: 'github', placement });
+ }
+ });
+ `,
+ },
+ ],
}),
],
});
diff --git a/docs-site/package.json b/docs-site/package.json
index 03c68258..810e6176 100644
--- a/docs-site/package.json
+++ b/docs-site/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@astrojs/starlight": "^0.34.0",
"astro": "^5.7.0",
+ "posthog-js": "^1.347.2",
"sharp": "^0.34.1"
},
"overrides": {
diff --git a/docs-site/src/pages/index.astro b/docs-site/src/pages/index.astro
index 10815e3e..963b33d7 100644
--- a/docs-site/src/pages/index.astro
+++ b/docs-site/src/pages/index.astro
@@ -77,8 +77,24 @@ const referenceLinks = [
diagrams, and structured imports. This page is the fastest way to choose the right route.
diff --git a/package.json b/package.json
index 0002adf8..26335d28 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"i18next-http-backend": "^3.0.2",
"lucide-react": "^0.563.0",
+ "posthog-js": "^1.347.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
diff --git a/src/App.tsx b/src/App.tsx
index 078d5192..25f8e558 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -9,7 +9,8 @@ import { CinematicExportProvider } from '@/context/CinematicExportContext';
import { HomePage } from '@/components/HomePage';
import { useFlowStore } from './store';
-import { useTabActions } from '@/store/tabHooks';
+import { useEditorPageActions } from '@/store/editorPageHooks';
+import { useWorkspaceDocumentActions, useWorkspaceRouteResolver } from '@/store/documentHooks';
import { useShortcutHelpOpen } from '@/store/viewHooks';
// Import i18n configuration
@@ -36,13 +37,21 @@ const LazyDiagramViewer = lazy(async () => {
function FlowCanvasRoute(): React.JSX.Element {
const { flowId } = useParams();
const navigate = useNavigate();
- const { setActiveTabId } = useTabActions();
+ const { setActiveDocumentId } = useWorkspaceDocumentActions();
+ const { setActivePageId } = useEditorPageActions();
+ const { resolveTarget } = useWorkspaceRouteResolver();
useEffect(() => {
if (flowId) {
- setActiveTabId(flowId);
+ const target = resolveTarget(flowId);
+ if (!target) {
+ return;
+ }
+
+ setActiveDocumentId(target.documentId);
+ setActivePageId(target.pageId);
}
- }, [flowId, setActiveTabId]);
+ }, [flowId, resolveTarget, setActiveDocumentId, setActivePageId]);
return (
}>
@@ -58,7 +67,7 @@ function FlowCanvasRoute(): React.JSX.Element {
function HomePageRoute(): React.JSX.Element {
const navigate = useNavigate();
const location = useLocation();
- const { addTab } = useTabActions();
+ const { createDocument } = useWorkspaceDocumentActions();
const activeTab = location.pathname === '/settings' ? 'settings' : 'home';
@@ -67,18 +76,18 @@ function HomePageRoute(): React.JSX.Element {
}, []);
const handleLaunch = () => {
- const newTabId = addTab();
- navigate(`/flow/${newTabId}`);
+ const newDocumentId = createDocument();
+ navigate(`/flow/${newDocumentId}`);
};
const handleLaunchWithTemplates = () => {
- const newTabId = addTab();
- navigate(`/flow/${newTabId}`, { state: createFlowEditorTemplatesRouteState() });
+ const newDocumentId = createDocument();
+ navigate(`/flow/${newDocumentId}`, { state: createFlowEditorTemplatesRouteState() });
};
const handleLaunchWithAI = () => {
- const newTabId = addTab();
- navigate(`/flow/${newTabId}`, { state: createFlowEditorAIRouteState() });
+ const newDocumentId = createDocument();
+ navigate(`/flow/${newDocumentId}`, { state: createFlowEditorAIRouteState() });
};
return (
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
index 50f0ec2a..f1eda2c4 100644
--- a/src/components/ErrorBoundary.tsx
+++ b/src/components/ErrorBoundary.tsx
@@ -2,6 +2,7 @@ import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCcw } from 'lucide-react';
import { withTranslation, WithTranslation } from 'react-i18next';
import { createLogger } from '@/lib/logger';
+import { captureAnalyticsException } from '@/services/analytics/analytics';
const logger = createLogger({ scope: 'ErrorBoundary' });
@@ -27,6 +28,10 @@ class ErrorBoundaryComponent extends Component {
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error('Uncaught error.', { error, componentStack: errorInfo.componentStack });
+ captureAnalyticsException(error, {
+ surface: 'react-error-boundary',
+ has_component_stack: Boolean(errorInfo.componentStack),
+ });
}
public render() {
diff --git a/src/components/FlowEditor.tsx b/src/components/FlowEditor.tsx
index fcbedb9e..b7228962 100644
--- a/src/components/FlowEditor.tsx
+++ b/src/components/FlowEditor.tsx
@@ -20,8 +20,8 @@ export function FlowEditor({ onGoHome }: FlowEditorProps) {
const {
nodes,
edges,
- tabs,
- activeTabId,
+ pages,
+ activePageId,
viewSettings,
diffBaseline,
setDiffBaseline,
@@ -53,8 +53,8 @@ export function FlowEditor({ onGoHome }: FlowEditorProps) {
}}
>
void;
- onAddTab: () => void;
- onCloseTab: (tabId: string) => void;
- onRenameTab: (tabId: string, newName: string) => void;
+ pages: EditorPage[];
+ activePageId: string;
+ onSwitchPage: (pageId: string) => void;
+ onAddPage: () => void;
+ onClosePage: (pageId: string) => void;
+ onRenamePage: (pageId: string, newName: string) => void;
}
export const FlowTabs: React.FC = ({
- tabs,
- activeTabId,
- onSwitchTab,
- onAddTab,
- onCloseTab,
- onRenameTab,
+ pages,
+ activePageId,
+ onSwitchPage,
+ onAddPage,
+ onClosePage,
+ onRenamePage,
}) => {
const { t } = useTranslation();
const isBeveled = IS_BEVELED;
@@ -29,14 +29,14 @@ export const FlowTabs: React.FC = ({
const activeTabClassName = `${getSegmentedTabButtonClass(true, 'sm')} h-10 sm:h-9 border-[var(--brand-primary-200)] bg-[var(--brand-primary-50)] text-[var(--brand-primary-700)]`;
const inactiveTabClassName = `${getSegmentedTabButtonClass(false, 'sm')} h-10 sm:h-9 border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 hover:text-slate-700`;
- const handleStartEdit = (tab: FlowTab) => {
- setEditingTabId(tab.id);
- setEditName(tab.name);
+ const handleStartEdit = (page: EditorPage) => {
+ setEditingTabId(page.id);
+ setEditName(page.name);
};
const handleFinishEdit = () => {
if (editingTabId && editName.trim()) {
- onRenameTab(editingTabId, editName.trim());
+ onRenamePage(editingTabId, editName.trim());
}
setEditingTabId(null);
setEditName('');
@@ -54,22 +54,22 @@ export const FlowTabs: React.FC = ({
return (
- {tabs.map((tab) => (
+ {pages.map((page) => (
onSwitchTab(tab.id)}
- onDoubleClick={() => handleStartEdit(tab)}
- title={tab.name}
+ onClick={() => onSwitchPage(page.id)}
+ onDoubleClick={() => handleStartEdit(page)}
+ title={page.name}
>
- {editingTabId === tab.id ? (
+ {editingTabId === page.id ? (
= ({
onClick={(e) => e.stopPropagation()}
/>
) : (
-
{tab.name}
+
{page.name}
)}
{
e.stopPropagation();
- onCloseTab(tab.id);
+ onClosePage(page.id);
}}
- title={t('flowTabs.closeTab')}
+ title={t('flowTabs.closeTab', 'Close page')}
className={`
rounded-full p-1 transition-colors opacity-0 group-hover:opacity-100 hover:bg-slate-200
- ${activeTabId === tab.id ? 'text-[var(--brand-primary-400)] hover:text-[var(--brand-primary)]' : 'text-slate-400 hover:text-slate-600'}
+ ${activePageId === page.id ? 'text-[var(--brand-primary-400)] hover:text-[var(--brand-primary)]' : 'text-slate-400 hover:text-slate-600'}
`}
>
@@ -101,10 +101,10 @@ export const FlowTabs: React.FC = ({
))}
diff --git a/src/components/HomePage.integration.test.tsx b/src/components/HomePage.integration.test.tsx
index 88b4311d..ab9eec5c 100644
--- a/src/components/HomePage.integration.test.tsx
+++ b/src/components/HomePage.integration.test.tsx
@@ -3,6 +3,9 @@ import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HomePage } from './HomePage';
import { useFlowStore } from '@/store';
+import type { FlowTab } from '@/lib/types';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
+import { WELCOME_MODAL_ENABLED_STORAGE_KEY, WELCOME_SEEN_STORAGE_KEY } from './home/welcomeModalState';
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal();
@@ -21,6 +24,8 @@ vi.mock('./LanguageSelector', () => ({
describe('HomePage integration flows', () => {
beforeEach(() => {
localStorage.clear();
+ localStorage.setItem(WELCOME_MODAL_ENABLED_STORAGE_KEY, 'false');
+ localStorage.setItem(WELCOME_SEEN_STORAGE_KEY, 'true');
useFlowStore.setState({});
});
@@ -41,6 +46,17 @@ describe('HomePage integration flows', () => {
});
}
+ function createDocumentFromPages(id: string, name: string, pages: FlowTab[]): FlowDocument {
+ return {
+ id,
+ name,
+ createdAt: '2026-03-27T00:00:00.000Z',
+ updatedAt: pages[0]?.updatedAt ?? '2026-03-27T00:00:00.000Z',
+ activePageId: pages[0]?.id ?? '',
+ pages,
+ };
+ }
+
it('switches from home to settings view via sidebar', async () => {
await renderHomePage();
@@ -53,6 +69,8 @@ describe('HomePage integration flows', () => {
const onLaunchWithTemplates = vi.fn();
const onLaunchWithAI = vi.fn();
useFlowStore.setState({
+ documents: [],
+ activeDocumentId: '',
tabs: [],
activeTabId: null,
nodes: [],
@@ -61,8 +79,8 @@ describe('HomePage integration flows', () => {
await renderHomePage({ onLaunchWithTemplates, onLaunchWithAI });
- fireEvent.click(screen.getByTestId('home-open-templates'));
- fireEvent.click(screen.getByRole('button', { name: 'Generate with Flowpilot' }));
+ fireEvent.click(await screen.findByTestId('home-open-templates'));
+ fireEvent.click(screen.getByTestId('home-generate-with-ai'));
expect(onLaunchWithTemplates).toHaveBeenCalledTimes(1);
expect(onLaunchWithAI).toHaveBeenCalledTimes(1);
@@ -71,6 +89,19 @@ describe('HomePage integration flows', () => {
it('opens persisted flows from the dashboard list', async () => {
const onOpenFlow = vi.fn();
useFlowStore.setState({
+ documents: [
+ createDocumentFromPages('tab-1', 'My Flow', [
+ {
+ id: 'tab-1',
+ name: 'My Flow',
+ diagramType: 'flowchart',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ },
+ ]),
+ ],
+ activeDocumentId: 'tab-1',
tabs: [
{
id: 'tab-1',
@@ -95,6 +126,31 @@ describe('HomePage integration flows', () => {
it('duplicates and deletes flows from the dashboard actions', async () => {
const onOpenFlow = vi.fn();
useFlowStore.setState({
+ documents: [
+ createDocumentFromPages('tab-1', 'Flow One', [
+ {
+ id: 'tab-1',
+ name: 'Flow One',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-07T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ },
+ ]),
+ createDocumentFromPages('tab-2', 'Flow Two', [
+ {
+ id: 'tab-2',
+ name: 'Flow Two',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-06T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ },
+ ]),
+ ],
+ activeDocumentId: 'tab-1',
tabs: [
{
id: 'tab-1',
@@ -134,6 +190,20 @@ describe('HomePage integration flows', () => {
it('renames flows from the dashboard actions with an app-native dialog', async () => {
useFlowStore.setState({
+ documents: [
+ createDocumentFromPages('tab-1', 'Flow One', [
+ {
+ id: 'tab-1',
+ name: 'Flow One',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-07T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ },
+ ]),
+ ],
+ activeDocumentId: 'tab-1',
tabs: [
{
id: 'tab-1',
@@ -163,4 +233,53 @@ describe('HomePage integration flows', () => {
expect(useFlowStore.getState().tabs[0]?.name).toBe('Renamed Flow');
expect(screen.getByText('Renamed Flow')).toBeTruthy();
});
+
+ it('removes the final remaining flow and shows the empty dashboard state when deleted', async () => {
+ useFlowStore.setState({
+ documents: [
+ createDocumentFromPages('tab-1', 'Solo Flow', [
+ {
+ id: 'tab-1',
+ name: 'Solo Flow',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-07T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ },
+ ]),
+ ],
+ activeDocumentId: 'tab-1',
+ tabs: [
+ {
+ id: 'tab-1',
+ name: 'Solo Flow',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-07T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ },
+ ],
+ activeTabId: 'tab-1',
+ nodes: [],
+ edges: [],
+ });
+
+ await renderHomePage();
+
+ const flowCard = screen.getByText('Solo Flow').closest('.group') as HTMLElement;
+ fireEvent.click(within(flowCard).getByLabelText('Delete'));
+
+ const deleteDialog = screen.getByRole('dialog', { name: 'Delete flow' });
+ fireEvent.click(within(deleteDialog).getByRole('button', { name: 'Delete' }));
+
+ const { tabs, activeTabId, nodes, edges } = useFlowStore.getState();
+ expect(tabs).toHaveLength(0);
+ expect(activeTabId).toBe('');
+ expect(nodes).toHaveLength(0);
+ expect(edges).toHaveLength(0);
+ expect(screen.queryByText('Solo Flow')).toBeNull();
+ expect(screen.getByTestId('home-create-new')).toBeTruthy();
+ });
});
diff --git a/src/components/HomePage.tsx b/src/components/HomePage.tsx
index 6d1dfd6b..08c2f348 100644
--- a/src/components/HomePage.tsx
+++ b/src/components/HomePage.tsx
@@ -1,6 +1,6 @@
import React, { Suspense, lazy, useState } from 'react';
import { useFlowStore } from '../store';
-import { useTabActions, useTabsState } from '@/store/tabHooks';
+import { useWorkspaceDocumentActions, useWorkspaceDocumentsState } from '@/store/documentHooks';
import { HomeDashboard, type HomeFlowCard } from './home/HomeDashboard';
import { HomeFlowDeleteDialog, HomeFlowRenameDialog } from './home/HomeFlowDialogs';
import { HomeSettingsView } from './home/HomeSettingsView';
@@ -31,9 +31,9 @@ export const HomePage: React.FC = ({
activeTab: propActiveTab,
onSwitchTab
}) => {
- const { tabs, activeTabId } = useTabsState();
- const { updateTab, closeTab, duplicateTab } = useTabActions();
- const { nodes, edges } = useFlowStore();
+ const { documents } = useWorkspaceDocumentsState();
+ const { renameDocument, deleteDocument, duplicateDocument } = useWorkspaceDocumentActions();
+ const hasWorkspaceDocuments = useFlowStore((state) => state.documents.length > 0);
const [internalActiveTab, setInternalActiveTab] = useState<'home' | 'settings'>('home');
const [activeSettingsTab, setActiveSettingsTab] = useState<'general' | 'shortcuts' | 'ai'>('general');
const [flowPendingRename, setFlowPendingRename] = useState(null);
@@ -41,25 +41,7 @@ export const HomePage: React.FC = ({
const showWelcomeModal = shouldShowWelcomeModal();
const activeTab = propActiveTab || internalActiveTab;
- const flows: HomeFlowCard[] = tabs.map((tab) => {
- const liveNodes = tab.id === activeTabId ? nodes : tab.nodes;
- const liveEdges = tab.id === activeTabId ? edges : tab.edges;
-
- return {
- id: tab.id,
- name: tab.name,
- nodeCount: liveNodes.length,
- edgeCount: liveEdges.length,
- updatedAt: tab.updatedAt,
- isActive: tab.id === activeTabId,
- };
- }).sort((left, right) => {
- if (left.isActive && !right.isActive) return -1;
- if (!left.isActive && right.isActive) return 1;
- const leftTime = Date.parse(left.updatedAt || '');
- const rightTime = Date.parse(right.updatedAt || '');
- return (Number.isNaN(rightTime) ? 0 : rightTime) - (Number.isNaN(leftTime) ? 0 : leftTime);
- });
+ const flows: HomeFlowCard[] = hasWorkspaceDocuments ? documents : [];
const handleTabChange = (tab: 'home' | 'settings'): void => {
if (onSwitchTab) {
@@ -78,9 +60,6 @@ export const HomePage: React.FC = ({
};
const handleDeleteFlow = (flowId: string): void => {
- if (tabs.length <= 1) {
- return;
- }
const flow = flows.find((entry) => entry.id === flowId);
if (!flow) {
return;
@@ -99,7 +78,7 @@ export const HomePage: React.FC = ({
return;
}
- updateTab(flowPendingRename.id, { name: trimmedName });
+ renameDocument(flowPendingRename.id, trimmedName);
setFlowPendingRename(null);
};
@@ -108,7 +87,7 @@ export const HomePage: React.FC = ({
return;
}
- closeTab(flowPendingDelete.id);
+ deleteDocument(flowPendingDelete.id);
setFlowPendingDelete(null);
};
@@ -132,7 +111,7 @@ export const HomePage: React.FC = ({
onOpenFlow={onOpenFlow}
onRenameFlow={handleRenameFlow}
onDuplicateFlow={(flowId) => {
- const newFlowId = duplicateTab(flowId);
+ const newFlowId = duplicateDocument(flowId);
if (newFlowId) {
onOpenFlow(newFlowId);
}
diff --git a/src/components/SettingsModal/GeneralSettings.tsx b/src/components/SettingsModal/GeneralSettings.tsx
index f4ef5b32..d3feb543 100644
--- a/src/components/SettingsModal/GeneralSettings.tsx
+++ b/src/components/SettingsModal/GeneralSettings.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useFlowStore } from '../../store';
import { Switch } from '../ui/Switch';
import { Grid, Magnet, Network, Zap } from 'lucide-react';
+import { useAnalyticsPreference } from '@/hooks/useAnalyticsPreference';
import type { GlobalEdgeOptions } from '@/lib/types';
import { useViewSettings, useVisualSettingsActions } from '@/store/viewHooks';
@@ -10,6 +11,7 @@ export const GeneralSettings = () => {
const { t } = useTranslation();
const viewSettings = useViewSettings();
const globalEdgeOptions = useFlowStore((state) => state.globalEdgeOptions);
+ const [analyticsEnabled, setAnalyticsEnabled] = useAnalyticsPreference();
const {
toggleGrid,
toggleSnap,
@@ -52,6 +54,24 @@ export const GeneralSettings = () => {
+
+
+ {t('settings.analytics', 'Analytics')}
+
+
+ }
+ label={t('settingsModal.analytics.enableTitle', 'Anonymous Launch Analytics')}
+ description={t(
+ 'settingsModal.analytics.enableDescription',
+ 'Track coarse product events and reliability issues only. We do not send diagram content, prompts, file contents, or API keys.'
+ )}
+ checked={analyticsEnabled}
+ onChange={setAnalyticsEnabled}
+ />
+
+
+
{t('commandBar.visuals.title', 'Connection Styles')}
diff --git a/src/components/SettingsModal/PrivacySettings.tsx b/src/components/SettingsModal/PrivacySettings.tsx
deleted file mode 100644
index 19808d6d..00000000
--- a/src/components/SettingsModal/PrivacySettings.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from 'react';
-import { Shield, Lock, Database, UserX, MessageSquare, Fingerprint } from 'lucide-react';
-import { useTranslation } from 'react-i18next';
-
-export const PrivacySettings = () => {
- const { t } = useTranslation();
-
- return (
-
- {/* Mission Statement */}
-
-
-
-
- {t('settingsModal.privacy.manifesto')}
-
-
- {t('settingsModal.privacy.manifestoText')}
-
-
- } text={t('settingsModal.privacy.localFirst')} />
- } text={t('settingsModal.privacy.noAccounts')} />
- } text={t('settingsModal.privacy.ownData')} />
- } text={t('settingsModal.privacy.anonymous')} />
-
-
-
-
- {/* Feedback Call to Action */}
-
-
-
{t('settingsModal.privacy.helpImprove')}
-
-
-
-
- );
-};
-
-const Feature = ({ icon, text }: { icon: React.ReactNode, text: string }) => (
-
-);
diff --git a/src/components/StudioPlaybackPanel.tsx b/src/components/StudioPlaybackPanel.tsx
index 9235a0ae..64aeedc9 100644
--- a/src/components/StudioPlaybackPanel.tsx
+++ b/src/components/StudioPlaybackPanel.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { CheckCircle2, Eye, Film, ListOrdered, MoveDown, MoveUp, Plus, RefreshCw, Trash2 } from 'lucide-react';
-import { useTabActions, useTabsState } from '@/store/tabHooks';
+import { useEditorPageActions, useEditorPagesState } from '@/store/editorPageHooks';
import type { FlowEdge, FlowNode, PlaybackState } from '@/lib/types';
import { createEmptyPlaybackState } from '@/services/playback/model';
import {
@@ -75,13 +75,13 @@ export function StudioPlaybackPanel({
playbackSpeed,
onPlaybackSpeedChange,
}: StudioPlaybackPanelProps): React.ReactElement {
- const { tabs, activeTabId } = useTabsState();
- const { updateTab } = useTabActions();
- const activeTab = tabs.find((tab) => tab.id === activeTabId);
+ const { pages, activePageId } = useEditorPagesState();
+ const { updatePage } = useEditorPageActions();
+ const activePage = pages.find((page) => page.id === activePageId);
// Keep the raw persisted value as a stable reference for memoization.
// The fallback empty state is resolved inside consumers to avoid creating
// a new object on every render (which would break React Compiler deps).
- const persistedPlayback = activeTab?.playback;
+ const persistedPlayback = activePage?.playback;
// Stable empty-state fallback — created once so React Compiler can track it as a
// stable dependency alongside `persistedPlayback` without seeing an inline allocation.
const playback = persistedPlayback ?? createEmptyPlaybackState();
@@ -93,7 +93,7 @@ export function StudioPlaybackPanel({
const scrubValue = currentStepIndex >= 0 ? Math.min(currentStepIndex, scrubMax) : 0;
function commitPlayback(nextPlayback: PlaybackState): void {
- updateTab(activeTabId, { playback: nextPlayback });
+ updatePage(activePageId, { playback: nextPlayback });
}
function applyPreset(preset: PlaybackGenerationPreset): void {
diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx
index ab1035cb..2328c6dd 100644
--- a/src/components/TopNav.tsx
+++ b/src/components/TopNav.tsx
@@ -1,5 +1,5 @@
import React, { Suspense, lazy, useCallback, useEffect } from 'react';
-import type { FlowTab } from '@/lib/types';
+import type { EditorPage } from '@/store/editorPageHooks';
import { FlowTabs } from './FlowTabs';
import { TopNavMenu } from './top-nav/TopNavMenu';
import { TopNavBrand } from './top-nav/TopNavBrand';
@@ -15,12 +15,12 @@ const LazySettingsModal = lazy(async () => {
});
interface TopNavProps {
- tabs: FlowTab[];
- activeTabId: string;
- onSwitchTab: (tabId: string) => void;
- onAddTab: () => void;
- onCloseTab: (tabId: string) => void;
- onRenameTab: (tabId: string, newName: string) => void;
+ pages: EditorPage[];
+ activePageId: string;
+ onSwitchPage: (pageId: string) => void;
+ onAddPage: () => void;
+ onClosePage: (pageId: string) => void;
+ onRenamePage: (pageId: string, newName: string) => void;
// Actions
onExportPNG: (format?: 'png' | 'jpeg') => void;
@@ -61,12 +61,12 @@ interface TopNavProps {
}
export function TopNav({
- tabs,
- activeTabId,
- onSwitchTab,
- onAddTab,
- onCloseTab,
- onRenameTab,
+ pages,
+ activePageId,
+ onSwitchPage,
+ onAddPage,
+ onClosePage,
+ onRenamePage,
onExportPNG,
onCopyImage,
onExportSVG,
@@ -137,12 +137,12 @@ export function TopNav({
{/* Center: Tabs */}
diff --git a/src/components/WelcomeModal.tsx b/src/components/WelcomeModal.tsx
index f6d7e457..80a29526 100644
--- a/src/components/WelcomeModal.tsx
+++ b/src/components/WelcomeModal.tsx
@@ -1,10 +1,11 @@
-import React, { useState } from 'react';
-import { ArrowRight, FileInput, LayoutTemplate, PenSquare, WandSparkles } from 'lucide-react';
+import React, { useState, useEffect } from 'react';
+import { useAnalyticsPreference } from '@/hooks/useAnalyticsPreference';
import { OpenFlowLogo } from './icons/OpenFlowLogo';
+import { Switch } from './ui/Switch';
+import { Button } from './ui/Button';
import { writeLocalStorageString } from '@/services/storage/uiLocalStorage';
import { shouldShowWelcomeModal, WELCOME_SEEN_STORAGE_KEY } from './home/welcomeModalState';
-import { RECOMMENDED_BUILDER_PROMPTS, RECOMMENDED_IMPORT_OPTIONS, RECOMMENDED_STARTER_TEMPLATE_LABELS } from '@/services/onboarding/config';
-import { recordOnboardingEvent } from '@/services/onboarding/events';
+import { WandSparkles, FileCode2, MonitorPlay, Paintbrush } from 'lucide-react';
export interface WelcomeModalProps {
onOpenTemplates: () => void;
@@ -13,128 +14,95 @@ export interface WelcomeModalProps {
onBlankCanvas: () => void;
}
-interface PathOption {
- icon: React.ReactNode;
- title: string;
- description: string;
- onClick: () => void;
- primary?: boolean;
-}
-
-function PathCard({ icon, title, description, onClick, primary }: PathOption): React.JSX.Element {
- return (
-
-
- {icon}
-
-
-
{title}
-
{description}
-
-
-
- );
-}
-
-export function WelcomeModal({ onOpenTemplates, onPromptWithAI, onImport, onBlankCanvas }: WelcomeModalProps): React.JSX.Element | null {
+export function WelcomeModal(_props: WelcomeModalProps): React.JSX.Element | null {
const [isOpen, setIsOpen] = useState(() => shouldShowWelcomeModal());
+ const [analyticsEnabled, setAnalyticsEnabled] = useAnalyticsPreference();
const dismiss = () => {
setIsOpen(false);
writeLocalStorageString(WELCOME_SEEN_STORAGE_KEY, 'true');
};
- const handle = (action: () => void) => () => { dismiss(); action(); };
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ dismiss();
+ }
+ };
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [isOpen]);
if (!isOpen) return null;
- const paths: PathOption[] = [
+ const features = [
{
- icon: ,
- title: 'Start from a template',
- description: `Jump into ${RECOMMENDED_STARTER_TEMPLATE_LABELS.slice(0, 3).join(', ')}.`,
- onClick: handle(() => {
- recordOnboardingEvent('welcome_template_selected', { surface: 'welcome-modal' });
- onOpenTemplates();
- }),
- primary: true,
+ icon: ,
+ title: 'Create amazing diagrams',
+ description: 'Design beautiful, enterprise-grade architecture visually.'
},
{
- icon: ,
- title: 'Prompt with Flowpilot',
- description: `Start from a builder prompt like "${RECOMMENDED_BUILDER_PROMPTS[0]}"`,
- onClick: handle(() => {
- recordOnboardingEvent('welcome_prompt_selected', { surface: 'welcome-modal' });
- onPromptWithAI();
- }),
+ icon: ,
+ title: 'Use AI',
+ description: 'Generate complete architectures with a single intelligent prompt.'
},
{
- icon: ,
- title: 'Import a diagram',
- description: `Bring in ${RECOMMENDED_IMPORT_OPTIONS.map((option) => option.label).join(', ')} sources.`,
- onClick: handle(() => {
- recordOnboardingEvent('welcome_import_selected', { surface: 'welcome-modal' });
- onImport();
- }),
+ icon: ,
+ title: 'Code to diagram',
+ description: 'Instantly construct stunning visual infrastructure from text.'
},
{
- icon: ,
- title: 'Blank canvas',
- description: 'Start clean, then branch into templates, imports, or guided AI edits.',
- onClick: handle(() => {
- recordOnboardingEvent('welcome_blank_selected', { surface: 'welcome-modal' });
- onBlankCanvas();
- }),
- },
+ icon: ,
+ title: 'Export in many formats',
+ description: 'Export into beautiful, fully animated presentation diagrams.'
+ }
];
return (
-
-
-
-
-
-
Welcome to OpenFlowKit
-
- Pick the fastest way to get to a real developer diagram: template, import, prompt, or blank canvas.
-
-
+
+
+
+
+
Welcome to OpenFlowKit
+
-
- {paths.map((path) => (
-
+
+
+ {features.map((f, i) => (
+
+
+ {f.icon}
+
+
+
{f.title}
+
{f.description}
+
+
))}
+
-
-
-
Best First Templates
-
- {RECOMMENDED_STARTER_TEMPLATE_LABELS.join(' • ')}
-
-
-
-
Supported Imports
-
- {RECOMMENDED_IMPORT_OPTIONS.map((option) => option.label).join(' • ')}
-
+
+
+
+ Anonymous Analytics
+ We collect diagnostic data. We never read your diagrams or prompts.
+
-
-
- Press ? for keyboard shortcuts
-
+
+ Get Started
+
diff --git a/src/components/command-bar/PagesView.tsx b/src/components/command-bar/PagesView.tsx
index 57824f63..d9cee49e 100644
--- a/src/components/command-bar/PagesView.tsx
+++ b/src/components/command-bar/PagesView.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { Copy, MoveRight, PanelsTopLeft } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
-import { useTabActions, useTabsState } from '@/store/tabHooks';
+import { useEditorPageActions, useEditorPagesState } from '@/store/editorPageHooks';
import { ViewHeader } from './ViewHeader';
interface PagesViewProps {
@@ -11,19 +11,19 @@ interface PagesViewProps {
export function PagesView({ onClose, handleBack }: PagesViewProps): React.ReactElement {
const navigate = useNavigate();
- const { tabs, activeTabId } = useTabsState();
- const { duplicateActiveTab, copySelectedToTab, moveSelectedToTab, setActiveTabId } = useTabActions();
+ const { pages, activePageId } = useEditorPagesState();
+ const { duplicateActivePage, copySelectedToPage, moveSelectedToPage, setActivePageId } = useEditorPageActions();
function handleDuplicateCurrentPage(): void {
- const newTabId = duplicateActiveTab();
- if (!newTabId) return;
- navigate(`/flow/${newTabId}`);
+ const newPageId = duplicateActivePage();
+ if (!newPageId) return;
+ navigate(`/flow/${newPageId}`);
onClose();
}
- function handleSwitchPage(tabId: string): void {
- setActiveTabId(tabId);
- navigate(`/flow/${tabId}`);
+ function handleSwitchPage(pageId: string): void {
+ setActivePageId(pageId);
+ navigate(`/flow/${pageId}`);
onClose();
}
@@ -42,23 +42,23 @@ export function PagesView({ onClose, handleBack }: PagesViewProps): React.ReactE
- {tabs.map((tab) => {
- const isActive = tab.id === activeTabId;
+ {pages.map((page) => {
+ const isActive = page.id === activePageId;
return (
-
{tab.name}
-
{tab.nodes.length} nodes • {tab.edges.length} edges
+
{page.name}
+
{page.nodes.length} nodes • {page.edges.length} edges
{!isActive && (
handleSwitchPage(tab.id)}
+ onClick={() => handleSwitchPage(page.id)}
className="h-8 rounded-[var(--brand-radius)] border border-slate-300 bg-white px-2 text-[11px]"
>
Switch
@@ -69,14 +69,14 @@ export function PagesView({ onClose, handleBack }: PagesViewProps): React.ReactE
{!isActive && (
copySelectedToTab(tab.id)}
+ onClick={() => copySelectedToPage(page.id)}
className="inline-flex h-7 items-center gap-1 rounded-[var(--brand-radius)] border border-slate-300 bg-white px-2 text-[11px]"
>
Copy Selected Here
moveSelectedToTab(tab.id)}
+ onClick={() => moveSelectedToPage(page.id)}
className="inline-flex h-7 items-center gap-1 rounded-[var(--brand-radius)] border border-slate-300 bg-white px-2 text-[11px]"
>
diff --git a/src/components/flow-editor/FlowEditorChrome.tsx b/src/components/flow-editor/FlowEditorChrome.tsx
index 119b1335..09bb4eda 100644
--- a/src/components/flow-editor/FlowEditorChrome.tsx
+++ b/src/components/flow-editor/FlowEditorChrome.tsx
@@ -1,5 +1,4 @@
import React, { Suspense, lazy } from 'react';
-import type { FlowTab } from '@/lib/types';
import type { NodeData } from '@/lib/types';
import type { FlowEditorPanelsProps } from '@/components/FlowEditorPanels';
import type {
@@ -7,6 +6,7 @@ import type {
FlowEditorCollaborationTopNavState,
} from '@/hooks/useFlowEditorCollaboration';
import { ErrorBoundary } from '@/components/ErrorBoundary';
+import type { EditorPage } from '@/store/editorPageHooks';
const LazyFlowEditorPanels = lazy(async () => {
const module = await import('@/components/FlowEditorPanels');
@@ -55,13 +55,13 @@ function TopNavFallback(): React.ReactElement {
}
export interface FlowEditorChromeProps {
- tabs: FlowTab[];
- activeTabId: string;
+ pages: EditorPage[];
+ activePageId: string;
topNav: {
- onSwitchTab: (tabId: string) => void;
- onAddTab: () => void;
- onCloseTab: (tabId: string) => void;
- onRenameTab: (tabId: string, newName: string) => void;
+ onSwitchPage: (pageId: string) => void;
+ onAddPage: () => void;
+ onClosePage: (pageId: string) => void;
+ onRenamePage: (pageId: string, newName: string) => void;
onExportPNG: (format?: 'png' | 'jpeg') => void;
onCopyImage: (format?: 'png' | 'jpeg') => void;
onExportSVG: () => void;
@@ -134,8 +134,8 @@ export interface FlowEditorChromeProps {
}
export function FlowEditorChrome({
- tabs,
- activeTabId,
+ pages,
+ activePageId,
topNav,
canvas,
shouldRenderPanels,
@@ -150,13 +150,13 @@ export function FlowEditorChrome({
emptyState,
}: FlowEditorChromeProps): React.ReactElement {
const topNavProps = {
- tabs,
- activeTabId,
+ pages,
+ activePageId,
collaboration: topNav.collaboration,
- onSwitchTab: topNav.onSwitchTab,
- onAddTab: topNav.onAddTab,
- onCloseTab: topNav.onCloseTab,
- onRenameTab: topNav.onRenameTab,
+ onSwitchPage: topNav.onSwitchPage,
+ onAddPage: topNav.onAddPage,
+ onClosePage: topNav.onClosePage,
+ onRenamePage: topNav.onRenamePage,
onExportPNG: topNav.onExportPNG,
onCopyImage: topNav.onCopyImage,
onExportSVG: topNav.onExportSVG,
diff --git a/src/components/flow-editor/buildFlowEditorControllerParams.ts b/src/components/flow-editor/buildFlowEditorControllerParams.ts
index 2008c53f..61a034d5 100644
--- a/src/components/flow-editor/buildFlowEditorControllerParams.ts
+++ b/src/components/flow-editor/buildFlowEditorControllerParams.ts
@@ -15,8 +15,8 @@ import type { Location, NavigateFunction } from 'react-router-dom';
interface BuildFlowEditorControllerShellParams {
location: Location;
navigate: NavigateFunction;
- tabs: Array<{ id: string; name: string }>;
- activeTabId: string | null;
+ pages: Array<{ id: string; name: string }>;
+ activePageId: string | null;
snapshots: FlowSnapshot[];
nodes: FlowNode[];
edges: FlowEdge[];
@@ -50,10 +50,10 @@ interface BuildFlowEditorControllerStudioParams {
type BuildFlowEditorControllerPanelsParams = UseFlowEditorPanelsParams;
interface BuildFlowEditorControllerChromeParams {
- handleSwitchTab: (tabId: string) => void;
- handleAddTab: () => void;
- handleCloseTab: (tabId: string) => void;
- handleRenameTab: (tabId: string, newName: string) => void;
+ handleSwitchPage: (pageId: string) => void;
+ handleAddPage: () => void;
+ handleClosePage: (pageId: string) => void;
+ handleRenamePage: (pageId: string, newName: string) => void;
handleExport: (format?: 'png' | 'jpeg') => void;
handleCopyImage: (format?: 'png' | 'jpeg') => void;
handleSvgExport: () => void;
diff --git a/src/components/flow-editor/flowEditorChromeProps.ts b/src/components/flow-editor/flowEditorChromeProps.ts
index 8c599c6b..832a86f9 100644
--- a/src/components/flow-editor/flowEditorChromeProps.ts
+++ b/src/components/flow-editor/flowEditorChromeProps.ts
@@ -4,10 +4,10 @@ import type { FlowNode } from '@/lib/types';
import type { FlowEditorChromeProps } from './FlowEditorChrome';
interface BuildTopNavParams {
- handleSwitchTab: (tabId: string) => void;
- handleAddTab: () => void;
- handleCloseTab: (tabId: string) => void;
- handleRenameTab: (tabId: string, newName: string) => void;
+ handleSwitchPage: (pageId: string) => void;
+ handleAddPage: () => void;
+ handleClosePage: (pageId: string) => void;
+ handleRenamePage: (pageId: string, newName: string) => void;
handleExport: (format?: 'png' | 'jpeg') => void;
handleCopyImage: (format?: 'png' | 'jpeg') => void;
handleSvgExport: () => void;
@@ -70,10 +70,10 @@ interface BuildEmptyStateParams {
}
export function buildFlowEditorTopNavProps({
- handleSwitchTab,
- handleAddTab,
- handleCloseTab,
- handleRenameTab,
+ handleSwitchPage,
+ handleAddPage,
+ handleClosePage,
+ handleRenamePage,
handleExport,
handleCopyImage,
handleSvgExport,
@@ -98,10 +98,10 @@ export function buildFlowEditorTopNavProps({
collaborationTopNavState,
}: BuildTopNavParams): FlowEditorChromeProps['topNav'] {
return {
- onSwitchTab: handleSwitchTab,
- onAddTab: handleAddTab,
- onCloseTab: handleCloseTab,
- onRenameTab: handleRenameTab,
+ onSwitchPage: handleSwitchPage,
+ onAddPage: handleAddPage,
+ onClosePage: handleClosePage,
+ onRenamePage: handleRenamePage,
onExportPNG: handleExport,
onCopyImage: handleCopyImage,
onExportSVG: handleSvgExport,
@@ -216,10 +216,10 @@ interface UseFlowEditorChromePropsParams extends BuildTopNavParams, BuildToolbar
export function useFlowEditorChromeProps(params: UseFlowEditorChromePropsParams): Pick {
const {
- handleSwitchTab,
- handleAddTab,
- handleCloseTab,
- handleRenameTab,
+ handleSwitchPage,
+ handleAddPage,
+ handleClosePage,
+ handleRenamePage,
handleExport,
handleCopyImage,
handleSvgExport,
@@ -271,10 +271,10 @@ export function useFlowEditorChromeProps(params: UseFlowEditorChromePropsParams)
} = params;
const topNav = useMemo(() => buildFlowEditorTopNavProps({
- handleSwitchTab,
- handleAddTab,
- handleCloseTab,
- handleRenameTab,
+ handleSwitchPage,
+ handleAddPage,
+ handleClosePage,
+ handleRenamePage,
handleExport,
handleCopyImage,
handleSvgExport,
@@ -298,10 +298,10 @@ export function useFlowEditorChromeProps(params: UseFlowEditorChromePropsParams)
startPlayback,
collaborationTopNavState,
}), [
- handleSwitchTab,
- handleAddTab,
- handleCloseTab,
- handleRenameTab,
+ handleSwitchPage,
+ handleAddPage,
+ handleClosePage,
+ handleRenamePage,
handleExport,
handleCopyImage,
handleSvgExport,
diff --git a/src/components/flow-editor/useFlowEditorController.ts b/src/components/flow-editor/useFlowEditorController.ts
index 5fd6955c..a66de8c4 100644
--- a/src/components/flow-editor/useFlowEditorController.ts
+++ b/src/components/flow-editor/useFlowEditorController.ts
@@ -23,8 +23,8 @@ export interface UseFlowEditorShellParams {
location: Location;
navigate: NavigateFunction;
fileInputRef: React.RefObject;
- tabs: Array<{ id: string; name: string }>;
- activeTabId: string | null;
+ pages: Array<{ id: string; name: string }>;
+ activePageId: string | null;
snapshots: FlowSnapshot[];
nodes: FlowNode[];
edges: FlowEdge[];
@@ -131,10 +131,10 @@ export interface UseFlowEditorPanelsParams {
}
export interface UseFlowEditorChromeParams {
- handleSwitchTab: (tabId: string) => void;
- handleAddTab: () => void;
- handleCloseTab: (tabId: string) => void;
- handleRenameTab: (tabId: string, newName: string) => void;
+ handleSwitchPage: (pageId: string) => void;
+ handleAddPage: () => void;
+ handleClosePage: (pageId: string) => void;
+ handleRenamePage: (pageId: string, newName: string) => void;
handleExport: (format?: 'png' | 'jpeg') => void;
handleCopyImage: (format?: 'png' | 'jpeg') => void;
handleSvgExport: () => void;
diff --git a/src/components/flow-editor/useFlowEditorRuntime.ts b/src/components/flow-editor/useFlowEditorRuntime.ts
index 18484b8f..3dd6fbda 100644
--- a/src/components/flow-editor/useFlowEditorRuntime.ts
+++ b/src/components/flow-editor/useFlowEditorRuntime.ts
@@ -12,8 +12,8 @@ type SetFlowEdges = (payload: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[]))
interface UseFlowEditorRuntimeParams {
collaborationEnabled: boolean;
- activeTabId: string;
- activeTabName?: string;
+ activePageId: string;
+ activePageName?: string;
nodes: FlowNode[];
edges: FlowEdge[];
editorSurfaceRef: RefObject;
@@ -29,8 +29,8 @@ interface UseFlowEditorRuntimeParams {
export function useFlowEditorRuntime({
collaborationEnabled,
- activeTabId,
- activeTabName,
+ activePageId,
+ activePageName,
nodes,
edges,
editorSurfaceRef,
@@ -52,7 +52,7 @@ export function useFlowEditorRuntime({
remotePresence,
} = useFlowEditorCollaboration({
collaborationEnabled,
- activeTabId,
+ activePageId,
nodes,
edges,
editorSurfaceRef,
@@ -70,7 +70,7 @@ export function useFlowEditorRuntime({
const actions = useFlowEditorActions({
nodes,
edges,
- activeTabName,
+ activePageName,
recordHistory,
setNodes,
setEdges,
diff --git a/src/components/flow-editor/useFlowEditorScreenBehavior.ts b/src/components/flow-editor/useFlowEditorScreenBehavior.ts
index d8d8b348..6f123958 100644
--- a/src/components/flow-editor/useFlowEditorScreenBehavior.ts
+++ b/src/components/flow-editor/useFlowEditorScreenBehavior.ts
@@ -19,11 +19,11 @@ export function useFlowEditorScreenBehavior(params: {
);
const callbacks = useFlowEditorCallbacks({
- addTab: screenState.addTab,
- closeTab: screenState.closeTab,
- updateTab: screenState.updateTab,
+ addPage: screenState.addPage,
+ closePage: screenState.closePage,
+ updatePage: screenState.updatePage,
navigate: screenState.navigate,
- tabsLength: screenState.tabs.length,
+ pagesLength: screenState.pages.length,
cannotCloseLastTabMessage: t('flowEditor.cannotCloseLastTab'),
setNodes: screenState.setNodes,
setEdges: screenState.setEdges,
diff --git a/src/components/flow-editor/useFlowEditorScreenModel.ts b/src/components/flow-editor/useFlowEditorScreenModel.ts
index 53f032de..b5d573f5 100644
--- a/src/components/flow-editor/useFlowEditorScreenModel.ts
+++ b/src/components/flow-editor/useFlowEditorScreenModel.ts
@@ -112,8 +112,8 @@ export function useFlowEditorScreenModel({ onGoHome }: UseFlowEditorScreenModelP
clearShareViewerUrl,
} = useFlowEditorRuntime({
collaborationEnabled: screenState.collaborationEnabled,
- activeTabId: screenState.activeTabId,
- activeTabName: screenState.activeTabName,
+ activePageId: screenState.activePageId,
+ activePageName: screenState.activePageName,
nodes: screenState.nodes,
edges: screenState.edges,
editorSurfaceRef: screenState.reactFlowWrapper,
@@ -131,8 +131,8 @@ export function useFlowEditorScreenModel({ onGoHome }: UseFlowEditorScreenModelP
shell: {
location: screenState.location,
navigate: screenState.navigate,
- tabs: screenState.tabs,
- activeTabId: screenState.activeTabId,
+ pages: screenState.pages,
+ activePageId: screenState.activePageId,
snapshots: screenState.snapshots,
nodes: screenState.nodes,
edges: screenState.edges,
@@ -237,10 +237,10 @@ export function useFlowEditorScreenModel({ onGoHome }: UseFlowEditorScreenModelP
editorMode: screenState.editorMode,
},
chrome: {
- handleSwitchTab: callbacks.handleSwitchTab,
- handleAddTab: callbacks.handleAddTab,
- handleCloseTab: callbacks.handleCloseTab,
- handleRenameTab: callbacks.handleRenameTab,
+ handleSwitchPage: callbacks.handleSwitchPage,
+ handleAddPage: callbacks.handleAddPage,
+ handleClosePage: callbacks.handleClosePage,
+ handleRenamePage: callbacks.handleRenamePage,
handleExport,
handleCopyImage,
handleSvgExport,
@@ -301,8 +301,8 @@ export function useFlowEditorScreenModel({ onGoHome }: UseFlowEditorScreenModelP
return {
nodes: screenState.nodes,
edges: screenState.edges,
- tabs: screenState.tabs,
- activeTabId: screenState.activeTabId,
+ pages: screenState.pages,
+ activePageId: screenState.activePageId,
viewSettings: screenState.viewSettings,
diffBaseline: screenState.diffBaseline,
setDiffBaseline: screenState.setDiffBaseline,
diff --git a/src/components/flow-editor/useFlowEditorScreenState.ts b/src/components/flow-editor/useFlowEditorScreenState.ts
index 92c6dbe1..b457f769 100644
--- a/src/components/flow-editor/useFlowEditorScreenState.ts
+++ b/src/components/flow-editor/useFlowEditorScreenState.ts
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { useReactFlow } from '@/lib/reactflowCompat';
import { useFlowStore } from '@/store';
+import { useEditorPageActions, useEditorPagesState } from '@/store/editorPageHooks';
import { useSnapshots } from '@/hooks/useSnapshots';
import { useFlowHistory } from '@/hooks/useFlowHistory';
import { useFlowEditorUIState } from '@/hooks/useFlowEditorUIState';
@@ -16,27 +17,24 @@ export function useFlowEditorScreenState() {
const navigate = useNavigate();
const collaborationEnabled = isRolloutFlagEnabled('collaborationEnabled');
- const storeState = useFlowStore(useShallow((state) => ({
+ const canvasState = useFlowStore(useShallow((state) => ({
nodes: state.nodes,
edges: state.edges,
setNodes: state.setNodes,
setEdges: state.setEdges,
- tabs: state.tabs,
- activeTabId: state.activeTabId,
- addTab: state.addTab,
- closeTab: state.closeTab,
- updateTab: state.updateTab,
toggleGrid: state.toggleGrid,
toggleSnap: state.toggleSnap,
})));
+ const { pages, activePageId } = useEditorPagesState();
+ const { addPage, closePage, updatePage } = useEditorPageActions();
const viewSettings = useViewSettings();
const { setShortcutsHelpOpen } = useShortcutHelpActions();
const [diffBaseline, setDiffBaseline] = useState(null);
const selectionState = useSelectionState();
const selectionActions = useSelectionActions();
- const activeTabName = useMemo(
- () => storeState.tabs.find((tab) => tab.id === storeState.activeTabId)?.name,
- [storeState.tabs, storeState.activeTabId],
+ const activePageName = useMemo(
+ () => pages.find((page) => page.id === activePageId)?.name,
+ [pages, activePageId],
);
const snapshotsState = useSnapshots();
const uiState = useFlowEditorUIState();
@@ -48,14 +46,19 @@ export function useFlowEditorScreenState() {
location,
navigate,
collaborationEnabled,
- ...storeState,
+ ...canvasState,
+ pages,
+ activePageId,
+ addPage,
+ closePage,
+ updatePage,
viewSettings,
setShortcutsHelpOpen,
diffBaseline,
setDiffBaseline,
...selectionState,
...selectionActions,
- activeTabName,
+ activePageName,
...snapshotsState,
...uiState,
reactFlowWrapper,
diff --git a/src/components/flow-editor/useFlowEditorShellController.test.tsx b/src/components/flow-editor/useFlowEditorShellController.test.tsx
index 2a1710ea..8a4625a1 100644
--- a/src/components/flow-editor/useFlowEditorShellController.test.tsx
+++ b/src/components/flow-editor/useFlowEditorShellController.test.tsx
@@ -38,8 +38,8 @@ describe('useFlowEditorShellController', () => {
},
navigate,
fileInputRef,
- tabs: [{ id: 'tab-1', diagramType: 'mindmap' }],
- activeTabId: 'tab-1',
+ pages: [{ id: 'tab-1', diagramType: 'mindmap' }],
+ activePageId: 'tab-1',
snapshots: [],
nodes: [createNode('node-1')],
edges: [],
@@ -70,8 +70,8 @@ describe('useFlowEditorShellController', () => {
},
navigate: vi.fn(),
fileInputRef: { current: null },
- tabs: [{ id: 'tab-1', diagramType: 'mindmap' }],
- activeTabId: 'tab-1',
+ pages: [{ id: 'tab-1', diagramType: 'mindmap' }],
+ activePageId: 'tab-1',
snapshots: [],
nodes: [createNode('node-1', true), createNode('node-2')],
edges: [{ id: 'edge-1', source: 'node-1', target: 'node-2' }],
diff --git a/src/components/flow-editor/useFlowEditorShellController.ts b/src/components/flow-editor/useFlowEditorShellController.ts
index 49adeb3e..7853e657 100644
--- a/src/components/flow-editor/useFlowEditorShellController.ts
+++ b/src/components/flow-editor/useFlowEditorShellController.ts
@@ -32,8 +32,8 @@ interface UseFlowEditorShellControllerParams {
location: LocationLike;
navigate: NavigateLike;
fileInputRef: RefObject;
- tabs: TabLike[];
- activeTabId: string | null;
+ pages: TabLike[];
+ activePageId: string | null;
snapshots: FlowSnapshot[];
nodes: FlowNode[];
edges: FlowEdge[];
@@ -64,8 +64,8 @@ export function useFlowEditorShellController({
location,
navigate,
fileInputRef,
- tabs,
- activeTabId,
+ pages,
+ activePageId,
snapshots,
nodes,
edges,
@@ -78,8 +78,8 @@ export function useFlowEditorShellController({
onLayout,
}: UseFlowEditorShellControllerParams): UseFlowEditorShellControllerResult {
const storageGuardTrigger = useMemo(
- () => `${tabs.length}:${snapshots.length}:${nodes.length}:${edges.length}`,
- [tabs.length, snapshots.length, nodes.length, edges.length]
+ () => `${pages.length}:${snapshots.length}:${nodes.length}:${edges.length}`,
+ [pages.length, snapshots.length, nodes.length, edges.length]
);
useStoragePressureGuard({
trigger: storageGuardTrigger,
@@ -107,8 +107,8 @@ export function useFlowEditorShellController({
}, [fileInputRef, location.hash, location.pathname, location.search, location.state, navigate]);
const activeTab = useMemo(
- () => tabs.find((tab) => tab.id === activeTabId),
- [tabs, activeTabId]
+ () => pages.find((tab) => tab.id === activePageId),
+ [pages, activePageId]
);
const handleLayoutWithContext = useCallback(() => {
diff --git a/src/components/home/HomeDashboard.tsx b/src/components/home/HomeDashboard.tsx
index 914a0736..352a443f 100644
--- a/src/components/home/HomeDashboard.tsx
+++ b/src/components/home/HomeDashboard.tsx
@@ -1,7 +1,8 @@
import React from 'react';
-import { Copy, Layout, Pencil, Plus, Trash2 } from 'lucide-react';
+import { Copy, Layout, Pencil, Plus, Trash2, LayoutTemplate, WandSparkles, FileInput, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../ui/Button';
+import { Tooltip } from '../Tooltip';
export interface HomeFlowCard {
id: string;
@@ -59,65 +60,84 @@ export function HomeDashboard({
-
{t('home.recentFiles', 'Recent Files')}
+
+
{t('home.recentFiles', 'Recent Files')}
+
+
+
+
+
+
{flows.length > 0 && (
{flows.length} {t('home.files', 'files')}
)}
{flows.length === 0 ? (
-
-
-
{t('home.createFirstFlow', 'Create your first flow')}
-
- Build a useful diagram fast: start from a template, import Mermaid or SQL, or open Flowpilot on a blank canvas.
-
-
-
- Browse Templates
-
-
- Import Diagram
-
-
- Generate with Flowpilot
-
-
- Blank Canvas
-
-
-
-
-
Use builder templates
-
Start from release trains, cloud architectures, mind maps, and technical workflows.
-
-
-
Import real source material
-
Bring in Mermaid, SQL, OpenAPI, JSON, or OpenFlow sources and keep editing visually.
-
-
-
Local-first by default
-
Diagram data stays in this browser unless you explicitly export or share it.
+
+
+
+ {/* Super-delicate background gradient inside card */}
+
+
+
+
+
+ {/* Sleek Icon */}
+
+
+
+ Create your first flow
+
+
+ Design enterprise-grade architectures instantly. Start from a blank canvas, describe your infrastructure with our AI builder, or use a tailored template.
+
+
+ {/* Action Grid strictly inside the card */}
+
+
+ Blank Canvas
+
+
+
+ Flowpilot AI
+
+
+
+ Templates
+
+
+
+
+
+ Or import an existing file
+
+
+
@@ -138,14 +158,14 @@ export function HomeDashboard({
-
+
{
event.stopPropagation();
onRenameFlow(flow.id);
}}
- className="rounded-[var(--radius-sm)] border border-slate-200 bg-white p-1.5 text-slate-400 hover:bg-slate-50 hover:text-slate-700"
+ className="rounded-[var(--radius-sm)] border border-slate-200 bg-white/95 p-1.5 text-slate-500 shadow-sm hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-primary)]"
aria-label={t('common.rename', 'Rename')}
>
@@ -156,7 +176,7 @@ export function HomeDashboard({
event.stopPropagation();
onDuplicateFlow(flow.id);
}}
- className="rounded-[var(--radius-sm)] border border-slate-200 bg-white p-1.5 text-slate-400 hover:bg-slate-50 hover:text-slate-700"
+ className="rounded-[var(--radius-sm)] border border-slate-200 bg-white/95 p-1.5 text-slate-500 shadow-sm hover:bg-slate-50 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand-primary)]"
aria-label={t('common.duplicate', 'Duplicate')}
>
@@ -167,7 +187,7 @@ export function HomeDashboard({
event.stopPropagation();
onDeleteFlow(flow.id);
}}
- className="rounded-[var(--radius-sm)] border border-slate-200 bg-white p-1.5 text-slate-400 hover:bg-red-50 hover:text-red-600"
+ className="rounded-[var(--radius-sm)] border border-slate-200 bg-white/95 p-1.5 text-slate-500 shadow-sm hover:bg-red-50 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
aria-label={t('common.delete', 'Delete')}
>
@@ -192,9 +212,7 @@ export function HomeDashboard({
)}
-
- {t('home.localStorageHint', 'Autosaved on this device. We do not upload your diagram data to our servers.')}
-
+
);
diff --git a/src/components/home/HomeFlowDialogs.tsx b/src/components/home/HomeFlowDialogs.tsx
index 15e591c4..e6224899 100644
--- a/src/components/home/HomeFlowDialogs.tsx
+++ b/src/components/home/HomeFlowDialogs.tsx
@@ -73,7 +73,7 @@ export function HomeFlowRenameDialog({
{t('home.renameFlow.title', 'Rename flow')}
- {t('home.renameFlow.description', 'Update the name shown on your dashboard and tabs.')}
+ {t('home.renameFlow.description', 'Update the name shown on your dashboard and in the editor.')}
diff --git a/src/components/home/welcomeModalState.ts b/src/components/home/welcomeModalState.ts
index ce68b534..26953358 100644
--- a/src/components/home/welcomeModalState.ts
+++ b/src/components/home/welcomeModalState.ts
@@ -4,8 +4,8 @@ export const WELCOME_SEEN_STORAGE_KEY = 'hasSeenWelcome_v1';
export const WELCOME_MODAL_ENABLED_STORAGE_KEY = 'openflowkit_show_welcome_modal';
export function shouldShowWelcomeModal(): boolean {
- const welcomeEnabled = readLocalStorageString(WELCOME_MODAL_ENABLED_STORAGE_KEY) === 'true';
- if (!welcomeEnabled) {
+ const welcomeEnabled = readLocalStorageString(WELCOME_MODAL_ENABLED_STORAGE_KEY);
+ if (welcomeEnabled === 'false') {
return false;
}
return !readLocalStorageString(WELCOME_SEEN_STORAGE_KEY);
diff --git a/src/components/useExportMenu.ts b/src/components/useExportMenu.ts
index 350a3849..245c33f0 100644
--- a/src/components/useExportMenu.ts
+++ b/src/components/useExportMenu.ts
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type { RefObject } from 'react';
+import { captureAnalyticsEvent } from '@/services/analytics/analytics';
import { recordOnboardingEvent } from '@/services/onboarding/events';
interface UseExportMenuParams {
@@ -95,6 +96,10 @@ export function useExportMenu({
function recordSelection(key: string, action: ExportActionKey): void {
recordOnboardingEvent('first_export_completed', { format: `${key}:${action}` });
+ captureAnalyticsEvent('export_used', {
+ format: key,
+ action,
+ });
}
function handleSelect(key: string, action: ExportActionKey): void {
diff --git a/src/hooks/useAIGeneration.ts b/src/hooks/useAIGeneration.ts
index e27e20a4..d819e776 100644
--- a/src/hooks/useAIGeneration.ts
+++ b/src/hooks/useAIGeneration.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createLogger } from '@/lib/logger';
import type { FlowEdge, FlowNode } from '@/lib/types';
+import { captureAnalyticsEvent } from '@/services/analytics/analytics';
import type { ChatMessage } from '@/services/aiService';
import { useFlowStore } from '@/store';
import { useToast } from '@/components/ui/ToastContext';
@@ -122,6 +123,7 @@ export function useAIGeneration(
imageBase64?: string,
focusedNodeIds?: string[],
showPreview = false,
+ requestKind: 'prompt' | 'focused-edit' | 'code-import' | 'sql-import' | 'terraform-import' | 'openapi-import' = 'prompt',
): Promise
=> {
if (!readiness.canGenerate && readiness.blockingIssue) {
setLastError(readiness.blockingIssue.detail);
@@ -141,6 +143,13 @@ export function useAIGeneration(
const controller = new AbortController();
abortControllerRef.current = controller;
+ captureAnalyticsEvent('ai_generation_started', {
+ provider: aiSettings.provider || 'gemini',
+ has_image: Boolean(imageBase64),
+ is_preview: showPreview,
+ request_kind: requestKind,
+ selected_count: focusedNodeIds?.length ?? selectedNodeIds.length,
+ });
try {
const result = await generateAIFlowResult({
@@ -171,6 +180,10 @@ export function useAIGeneration(
if (showPreview) {
setPendingDiff(computeImportDiff(nodes, result));
notifyOperationOutcome(addToast, { status: 'success', summary: 'Import ready — review changes before applying.' });
+ captureAnalyticsEvent('import_preview_ready', {
+ provider: aiSettings.provider || 'gemini',
+ request_kind: requestKind,
+ });
} else {
applyComposedGraph(layoutedNodes, layoutedEdges);
notifyOperationOutcome(addToast, {
@@ -178,14 +191,32 @@ export function useAIGeneration(
summary: getSuccessSummary(nodes.length, focusedNodeIds),
});
}
+ captureAnalyticsEvent('ai_generation_succeeded', {
+ provider: aiSettings.provider || 'gemini',
+ has_image: Boolean(imageBase64),
+ is_preview: showPreview,
+ request_kind: requestKind,
+ selected_count: focusedNodeIds?.length ?? selectedNodeIds.length,
+ });
return true;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') {
+ captureAnalyticsEvent('ai_generation_cancelled', {
+ provider: aiSettings.provider || 'gemini',
+ is_preview: showPreview,
+ request_kind: requestKind,
+ });
return false;
}
const errorMessage = toErrorMessage(error);
logger.error('AI generation failed.', { error });
setLastError(errorMessage);
+ captureAnalyticsEvent('ai_generation_failed', {
+ provider: aiSettings.provider || 'gemini',
+ is_preview: showPreview,
+ request_kind: requestKind,
+ error_name: error instanceof Error ? error.name : 'UnknownError',
+ });
notifyOperationOutcome(addToast, {
status: 'error',
summary: getFailureSummary(nodes.length, focusedNodeIds),
@@ -213,27 +244,27 @@ export function useAIGeneration(
]);
const handleAIRequest = useCallback(async (prompt: string, imageBase64?: string): Promise => {
- return runAIRequest(prompt, imageBase64);
+ return runAIRequest(prompt, imageBase64, undefined, false, 'prompt');
}, [runAIRequest]);
const handleFocusedAIRequest = useCallback(async (prompt: string, focusedNodeIds: string[], imageBase64?: string): Promise => {
- return runAIRequest(prompt, imageBase64, focusedNodeIds);
+ return runAIRequest(prompt, imageBase64, focusedNodeIds, false, 'focused-edit');
}, [runAIRequest]);
const handleCodeAnalysis = useCallback(async (code: string, language: SupportedLanguage): Promise => {
- return runAIRequest(buildCodeToArchitecturePrompt({ code, language }), undefined, undefined, true);
+ return runAIRequest(buildCodeToArchitecturePrompt({ code, language }), undefined, undefined, true, 'code-import');
}, [runAIRequest]);
const handleSqlAnalysis = useCallback(async (sql: string): Promise => {
- return runAIRequest(buildSqlToErdPrompt(sql), undefined, undefined, true);
+ return runAIRequest(buildSqlToErdPrompt(sql), undefined, undefined, true, 'sql-import');
}, [runAIRequest]);
const handleTerraformAnalysis = useCallback(async (input: string, format: TerraformInputFormat): Promise => {
- return runAIRequest(buildTerraformToCloudPrompt(input, format), undefined, undefined, true);
+ return runAIRequest(buildTerraformToCloudPrompt(input, format), undefined, undefined, true, 'terraform-import');
}, [runAIRequest]);
const handleOpenApiAnalysis = useCallback(async (spec: string): Promise => {
- return runAIRequest(buildOpenApiToSequencePrompt(spec), undefined, undefined, true);
+ return runAIRequest(buildOpenApiToSequencePrompt(spec), undefined, undefined, true, 'openapi-import');
}, [runAIRequest]);
return {
diff --git a/src/hooks/useAnalyticsPreference.ts b/src/hooks/useAnalyticsPreference.ts
new file mode 100644
index 00000000..b90d5558
--- /dev/null
+++ b/src/hooks/useAnalyticsPreference.ts
@@ -0,0 +1,16 @@
+import { useEffect, useState } from 'react';
+import {
+ getAnalyticsPreference,
+ setAnalyticsPreference,
+ subscribeToAnalyticsPreference,
+} from '@/services/analytics/analyticsSettings';
+
+export function useAnalyticsPreference(): [boolean, (enabled: boolean) => void] {
+ const [enabled, setEnabled] = useState(() => getAnalyticsPreference());
+
+ useEffect(() => {
+ return subscribeToAnalyticsPreference(setEnabled);
+ }, []);
+
+ return [enabled, setAnalyticsPreference];
+}
diff --git a/src/hooks/useFlowEditorActions.ts b/src/hooks/useFlowEditorActions.ts
index 45056e6f..750590fe 100644
--- a/src/hooks/useFlowEditorActions.ts
+++ b/src/hooks/useFlowEditorActions.ts
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react';
import type { TFunction } from 'i18next';
import { createLogger } from '@/lib/logger';
import type { FlowEdge, FlowNode } from '@/lib/types';
+import { captureAnalyticsEvent } from '@/services/analytics/analytics';
import type { FlowTemplate } from '@/services/templates';
import type { LayoutAlgorithm } from '@/services/elkLayout';
import type { ExportSerializationMode } from '@/services/canonicalSerialization';
@@ -29,7 +30,7 @@ const logger = createLogger({ scope: 'useFlowEditorActions' });
interface UseFlowEditorActionsParams {
nodes: FlowNode[];
edges: FlowEdge[];
- activeTabName?: string;
+ activePageName?: string;
recordHistory: () => void;
setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void;
setEdges: (edges: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void;
@@ -64,7 +65,7 @@ interface UseFlowEditorActionsResult {
export function useFlowEditorActions({
nodes,
edges,
- activeTabName,
+ activePageName,
recordHistory,
setNodes,
setEdges,
@@ -110,6 +111,10 @@ export function useFlowEditorActions({
templateId: template.id,
category: template.category,
});
+ captureAnalyticsEvent('template_used', {
+ template_id: template.id,
+ template_category: template.category,
+ });
const { nextNodes, newEdges } = buildTemplateInsertionResult({
template,
existingNodes: nodes,
@@ -125,16 +130,16 @@ export function useFlowEditorActions({
}, [nodes, edges, t, addToast]);
const handleDownloadMermaid = useCallback((): void => {
- downloadMermaidToFile({ nodes, edges, addToast, baseFileName: activeTabName });
- }, [nodes, edges, addToast, activeTabName]);
+ downloadMermaidToFile({ nodes, edges, addToast, baseFileName: activePageName });
+ }, [nodes, edges, addToast, activePageName]);
const handleExportPlantUML = useCallback(async (): Promise => {
await exportPlantUMLToClipboard({ nodes, edges, t, addToast });
}, [nodes, edges, t, addToast]);
const handleDownloadPlantUML = useCallback((): void => {
- downloadPlantUMLToFile({ nodes, edges, addToast, baseFileName: activeTabName });
- }, [nodes, edges, addToast, activeTabName]);
+ downloadPlantUMLToFile({ nodes, edges, addToast, baseFileName: activePageName });
+ }, [nodes, edges, addToast, activePageName]);
const handleExportOpenFlowDSL = useCallback(async (): Promise => {
await exportOpenFlowDSLToClipboard({
@@ -152,17 +157,17 @@ export function useFlowEditorActions({
edges,
exportSerializationMode,
addToast,
- baseFileName: activeTabName,
+ baseFileName: activePageName,
});
- }, [nodes, edges, exportSerializationMode, addToast, activeTabName]);
+ }, [nodes, edges, exportSerializationMode, addToast, activePageName]);
const handleExportFigma = useCallback(async (): Promise => {
await exportFigmaToClipboard({ nodes, edges, addToast, t });
}, [nodes, edges, addToast, t]);
const handleDownloadFigma = useCallback(async (): Promise => {
- await downloadFigmaToFile({ nodes, edges, addToast, t, baseFileName: activeTabName });
- }, [nodes, edges, addToast, t, activeTabName]);
+ await downloadFigmaToFile({ nodes, edges, addToast, t, baseFileName: activePageName });
+ }, [nodes, edges, addToast, t, activePageName]);
const [shareViewerUrl, setShareViewerUrl] = useState(null);
@@ -173,6 +178,7 @@ export function useFlowEditorActions({
const url = `${window.location.origin}/#/view?flow=${encoded}`;
setShareViewerUrl(url);
recordOnboardingEvent('first_share_opened', { surface: 'editor' });
+ captureAnalyticsEvent('share_opened', { surface: 'editor' });
}, [nodes, edges, exportSerializationMode]);
const clearShareViewerUrl = useCallback((): void => setShareViewerUrl(null), []);
diff --git a/src/hooks/useFlowEditorCallbacks.ts b/src/hooks/useFlowEditorCallbacks.ts
index dd6ede69..ef890add 100644
--- a/src/hooks/useFlowEditorCallbacks.ts
+++ b/src/hooks/useFlowEditorCallbacks.ts
@@ -4,11 +4,11 @@ import { useFlowStore } from '@/store';
import { composeDiagramForDisplay } from '@/services/composeDiagramForDisplay';
interface UseFlowEditorCallbacksParams {
- addTab: () => string;
- closeTab: (tabId: string) => void;
- updateTab: (tabId: string, update: Partial<{ name: string }>) => void;
+ addPage: () => string;
+ closePage: (pageId: string) => void;
+ updatePage: (pageId: string, update: Partial<{ name: string }>) => void;
navigate: (path: string) => void;
- tabsLength: number;
+ pagesLength: number;
cannotCloseLastTabMessage: string;
setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void;
setEdges: (edges: FlowEdge[] | ((edges: FlowEdge[]) => FlowEdge[])) => void;
@@ -20,21 +20,21 @@ interface UseFlowEditorCallbacksParams {
interface UseFlowEditorCallbacksResult {
getCenter: () => { x: number; y: number };
- handleSwitchTab: (tabId: string) => void;
- handleAddTab: () => void;
- handleCloseTab: (tabId: string) => void;
- handleRenameTab: (tabId: string, newName: string) => void;
+ handleSwitchPage: (pageId: string) => void;
+ handleAddPage: () => void;
+ handleClosePage: (pageId: string) => void;
+ handleRenamePage: (pageId: string, newName: string) => void;
selectAll: () => void;
handleRestoreSnapshot: (snapshot: FlowSnapshot) => void;
handleCommandBarApply: (newNodes: FlowNode[], newEdges: FlowEdge[]) => void;
}
export function useFlowEditorCallbacks({
- addTab,
- closeTab,
- updateTab,
+ addPage,
+ closePage,
+ updatePage,
navigate,
- tabsLength,
+ pagesLength,
cannotCloseLastTabMessage,
setNodes,
setEdges,
@@ -51,26 +51,26 @@ export function useFlowEditorCallbacks({
return screenToFlowPosition({ x: centerX, y: centerY });
}, [screenToFlowPosition]);
- const handleSwitchTab = useCallback((tabId: string) => {
- navigate(`/flow/${tabId}`);
+ const handleSwitchPage = useCallback((pageId: string) => {
+ navigate(`/flow/${pageId}`);
}, [navigate]);
- const handleAddTab = useCallback(() => {
- const newId = addTab();
+ const handleAddPage = useCallback(() => {
+ const newId = addPage();
navigate(`/flow/${newId}`);
- }, [addTab, navigate]);
+ }, [addPage, navigate]);
- const handleCloseTab = useCallback((tabId: string) => {
- if (tabsLength === 1) {
+ const handleClosePage = useCallback((pageId: string) => {
+ if (pagesLength === 1) {
alert(cannotCloseLastTabMessage);
return;
}
- closeTab(tabId);
- }, [cannotCloseLastTabMessage, closeTab, tabsLength]);
+ closePage(pageId);
+ }, [cannotCloseLastTabMessage, closePage, pagesLength]);
- const handleRenameTab = useCallback((tabId: string, newName: string) => {
- updateTab(tabId, { name: newName });
- }, [updateTab]);
+ const handleRenamePage = useCallback((pageId: string, newName: string) => {
+ updatePage(pageId, { name: newName });
+ }, [updatePage]);
const selectAll = useCallback(() => {
setNodes((nodes) => nodes.map((node) => ({ ...node, selected: true })));
@@ -134,10 +134,10 @@ export function useFlowEditorCallbacks({
return {
getCenter,
- handleSwitchTab,
- handleAddTab,
- handleCloseTab,
- handleRenameTab,
+ handleSwitchPage,
+ handleAddPage,
+ handleClosePage,
+ handleRenamePage,
selectAll,
handleRestoreSnapshot,
handleCommandBarApply,
diff --git a/src/hooks/useFlowEditorCollaboration.ts b/src/hooks/useFlowEditorCollaboration.ts
index 46986e3b..c41dfb88 100644
--- a/src/hooks/useFlowEditorCollaboration.ts
+++ b/src/hooks/useFlowEditorCollaboration.ts
@@ -47,7 +47,7 @@ export type CollaborationRemotePresence = ReturnType;
@@ -63,7 +63,7 @@ interface UseFlowEditorCollaborationResult {
export function useFlowEditorCollaboration({
collaborationEnabled,
- activeTabId,
+ activePageId,
nodes,
edges,
editorSurfaceRef,
@@ -93,8 +93,8 @@ export function useFlowEditorCollaboration({
const collaborationFlushTimerRef = useRef(null);
const collaborationRoom = useMemo(
- () => resolveCollaborationRoomId(location.search, activeTabId),
- [location.search, activeTabId]
+ () => resolveCollaborationRoomId(location.search, activePageId),
+ [location.search, activePageId]
);
const collaborationRoomId = collaborationRoom.roomId;
const collaborationRoomSecret = useMemo(
diff --git a/src/hooks/useFlowHistory.ts b/src/hooks/useFlowHistory.ts
index 17051fd8..c382ae4d 100644
--- a/src/hooks/useFlowHistory.ts
+++ b/src/hooks/useFlowHistory.ts
@@ -1,14 +1,14 @@
import { useCallback } from 'react';
import { useHistoryActions } from '@/store/historyHooks';
-import { useTabsState } from '@/store/tabHooks';
+import { useEditorPagesState } from '@/store/editorPageHooks';
export const useFlowHistory = () => {
- const { tabs, activeTabId } = useTabsState();
+ const { pages, activePageId } = useEditorPagesState();
const { recordHistoryV2, undoV2, redoV2, canUndoV2, canRedoV2 } = useHistoryActions();
- const activeTab = tabs.find((tab) => tab.id === activeTabId);
- const past = activeTab?.history.past ?? [];
- const future = activeTab?.history.future ?? [];
+ const activePage = pages.find((page) => page.id === activePageId);
+ const past = activePage?.history.past ?? [];
+ const future = activePage?.history.future ?? [];
const recordHistory = useCallback(() => {
recordHistoryV2();
diff --git a/src/hooks/usePlayback.ts b/src/hooks/usePlayback.ts
index beeb633c..8733983a 100644
--- a/src/hooks/usePlayback.ts
+++ b/src/hooks/usePlayback.ts
@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useReactFlow } from '@/lib/reactflowCompat';
import { useFlowStore } from '../store';
+import { useEditorPagesState } from '@/store/editorPageHooks';
import {
applyPlaybackStepStyles,
buildPlaybackSequence,
@@ -10,7 +11,8 @@ import {
} from '@/services/playback/contracts';
export function usePlayback() {
- const { nodes, tabs, activeTabId, setNodes } = useFlowStore();
+ const { nodes, setNodes } = useFlowStore();
+ const { pages, activePageId } = useEditorPagesState();
const { fitView } = useReactFlow();
const [isPlaying, setIsPlaying] = useState(false);
@@ -21,9 +23,9 @@ export function usePlayback() {
const initialStyles = useRef(capturePlaybackStyles([]));
const resolveSequence = useCallback(() => {
- const activeTabPlayback = tabs.find((tab) => tab.id === activeTabId)?.playback;
- return buildPlaybackSequenceFromState(nodes, activeTabPlayback, playbackSpeed);
- }, [activeTabId, nodes, playbackSpeed, tabs]);
+ const activePagePlayback = pages.find((page) => page.id === activePageId)?.playback;
+ return buildPlaybackSequenceFromState(nodes, activePagePlayback, playbackSpeed);
+ }, [activePageId, nodes, pages, playbackSpeed]);
const restoreStyles = useCallback(() => {
if (Object.keys(initialStyles.current).length > 0) {
diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json
index b99704b5..c66ec0b3 100644
--- a/src/i18n/locales/de/translation.json
+++ b/src/i18n/locales/de/translation.json
@@ -331,7 +331,7 @@
"press": "Drücken Sie",
"shortcuts": "für Tastaturkürzel",
"privacy": "Ihre Diagramme, API-Schlüssel und Daten bleiben lokal auf Ihrem Gerät.",
- "analytics": "Wir zählen nur anonyme Seitenbesuche.",
+ "analytics": "Wir erfassen derzeit keine Analysedaten.",
"features": {
"beautifulByDefault": "Standardmäßig schön",
"automatedLayouts": "Automatisierte Layouts und professionelle Designs.",
@@ -740,21 +740,6 @@
"showBetaBadgeHint": "Display the BETA chip next to logo"
},
"shortcutsHint": "Sie können auch ? drücken, um diese anzuzeigen.",
- "privacy": {
- "manifesto": "Unser Datenschutz-Manifest",
- "manifestoText": "Wir glauben an Datenschutz durch Design.",
- "localFirst": "Lokale Speicherung",
- "noAccounts": "Keine Benutzerkonten",
- "ownData": "Eigene Daten",
- "anonymous": "Nur anonyme Nutzung",
- "telemetry": "Telemetrie & Feedback",
- "anonymousStats": "Anonyme Statistiken",
- "anonymousStatsDesc": "Nur Besuche und begrenzte Nutzung.",
- "quote": "Wir verlassen uns darauf, dass Sie uns sagen, was funktioniert.",
- "helpImprove": "Helfen Sie uns zu verbessern",
- "shareFeedback": "Feedback teilen",
- "shareFeedbackDesc": "Feedback geben, Fehler melden, Funktionen anfragen"
- },
"settings": "Einstellungen",
"canvasSettings": "Canvas-Einstellungen",
"description": "Canvas-Einstellungen und Tastaturkürzel konfigurieren.",
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 983c72ec..5a56f080 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -262,7 +262,7 @@
"localStorageHint": "Autosaved on this device. We do not upload your diagram data to our servers.",
"renameFlow": {
"title": "Rename flow",
- "description": "Update the name shown on your dashboard and tabs.",
+ "description": "Update the name shown on your dashboard and in the editor.",
"label": "Flow name",
"placeholder": "Enter a flow name",
"hint": "Names are local to this browser profile unless you export or sync them elsewhere.",
@@ -361,9 +361,7 @@
"press": "Press",
"shortcuts": "for shortcuts",
"privacy": "Your diagrams, API keys, and data stay locally on your device.",
- "analytics": "We only count anonymous page visits.",
- "analyticsTitle": "Help improve OpenFlowKit",
- "analyticsDesc": "Share anonymous basic usage data (optional)",
+ "analytics": "We collect basic anonymous usage and reliability data to improve the product. Never your diagram content, prompt text, file contents, or API keys.",
"features": {
"beautifulByDefault": "Beautiful by default",
"automatedLayouts": "Automated layouts and professional themes.",
@@ -1076,22 +1074,7 @@
"showBetaBadgeHint": "Display the BETA chip next to logo"
},
"shortcutsHint": "You can also press ? to view these anytime.",
- "canvasSettings": "Canvas Settings",
- "privacy": {
- "manifesto": "Our Privacy Manifesto",
- "manifestoText": "We believe in privacy by design and user autonomy. We don't collect your email, we don't have a login system, and we don't store your data on our servers.",
- "localFirst": "Local-First Storage",
- "noAccounts": "No User Accounts",
- "ownData": "Own Your Data",
- "anonymous": "Anonymous Usage Only",
- "telemetry": "Telemetry & Feedback",
- "anonymousStats": "Anonymous Stats",
- "anonymousStatsDesc": "We only track visits and limited anonymous feature usage (like exports) to improve the tool. We do not track your names, IP addresses, inputs, or diagram content.",
- "quote": "We rely on you to tell us what works, rather than tracking your every move.",
- "helpImprove": "Help Us Improve",
- "shareFeedback": "Share Feedback",
- "shareFeedbackDesc": "Give feedback, report bugs, and ask for features"
- }
+ "canvasSettings": "Canvas Settings"
},
"customNodes": {
"browserContent": "Browser Content",
@@ -1214,7 +1197,7 @@
"strictModePasteBlocked": "Architecture strict mode blocked Mermaid paste. Open Code view, fix diagnostics, then retry."
},
"flowTabs": {
- "closeTab": "Close Tab",
- "newFlowTab": "New Flow Tab"
+ "closeTab": "Close Page",
+ "newFlowTab": "New Page"
}
}
diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json
index a2e61737..b552d8c4 100644
--- a/src/i18n/locales/es/translation.json
+++ b/src/i18n/locales/es/translation.json
@@ -331,7 +331,7 @@
"press": "Presione",
"shortcuts": "para atajos",
"privacy": "Sus diagramas, claves API y datos permanecen localmente en su dispositivo.",
- "analytics": "Solo contamos visitas anónimas.",
+ "analytics": "Hoy no recopilamos analíticas.",
"features": {
"beautifulByDefault": "Hermoso por defecto",
"automatedLayouts": "Diseños automatizados y temas profesionales.",
@@ -740,21 +740,6 @@
"showBetaBadgeHint": "Display the BETA chip next to logo"
},
"shortcutsHint": "También puedes presionar ? para verlos en cualquier momento.",
- "privacy": {
- "manifesto": "Nuestro manifiesto de privacidad",
- "manifestoText": "Creemos en la privacidad por diseño y la autonomía del usuario.",
- "localFirst": "Almacenamiento local",
- "noAccounts": "Sin cuentas de usuario",
- "ownData": "Tus datos son tuyos",
- "anonymous": "Solo uso anónimo",
- "telemetry": "Telemetría & Comentarios",
- "anonymousStats": "Estadísticas anónimas",
- "anonymousStatsDesc": "Solo rastreamos visitas y uso anónimo limitado.",
- "quote": "Confiamos en que nos digas qué funciona.",
- "helpImprove": "Ayúdanos a mejorar",
- "shareFeedback": "Compartir comentarios",
- "shareFeedbackDesc": "Dar comentarios, reportar errores, pedir características"
- },
"settings": "Configuración",
"canvasSettings": "Configuración del lienzo",
"description": "Configura las preferencias del lienzo y los atajos de teclado.",
diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json
index 8a5300a3..46b42400 100644
--- a/src/i18n/locales/fr/translation.json
+++ b/src/i18n/locales/fr/translation.json
@@ -331,7 +331,7 @@
"press": "Appuyez sur",
"shortcuts": "pour les raccourcis",
"privacy": "Vos diagrammes, clés API et données restent localement sur votre appareil.",
- "analytics": "Nous ne comptons que les visites anonymes.",
+ "analytics": "Nous ne collectons pas d’analyses pour le moment.",
"features": {
"beautifulByDefault": "Beau par défaut",
"automatedLayouts": "Mises en page automatisées et thèmes professionnels.",
@@ -740,21 +740,6 @@
"showBetaBadgeHint": "Display the BETA chip next to logo"
},
"shortcutsHint": "Vous pouvez aussi appuyer sur ? pour les afficher.",
- "privacy": {
- "manifesto": "Notre manifeste de confidentialité",
- "manifestoText": "Nous croyons en la protection des données et l'autonomie.",
- "localFirst": "Stockage local",
- "noAccounts": "Aucun compte utilisateur",
- "ownData": "Vos données vous appartiennent",
- "anonymous": "Usage anonyme uniquement",
- "telemetry": "Télémétrie & Retours",
- "anonymousStats": "Statistiques anonymes",
- "anonymousStatsDesc": "Nous suivons uniquement les visites et l'usage anonyme.",
- "quote": "Nous comptons sur vous pour nous dire ce qui marche.",
- "helpImprove": "Aidez-nous à nous améliorer",
- "shareFeedback": "Partager des retours",
- "shareFeedbackDesc": "Donner des retours, signaler des bugs, demander des fonctionnalités"
- },
"settings": "Paramètres",
"canvasSettings": "Paramètres du canevas",
"description": "Configurer les préférences du canevas et les raccourcis clavier.",
diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json
index 6101f0d5..1eff7513 100644
--- a/src/i18n/locales/ja/translation.json
+++ b/src/i18n/locales/ja/translation.json
@@ -331,7 +331,7 @@
"press": "押す",
"shortcuts": "ショートカット表示",
"privacy": "図、APIキー、データはすべてローカルデバイスに保存されます。",
- "analytics": "匿名のページ訪問のみを集計します。",
+ "analytics": "現在、分析データは収集していません。",
"features": {
"beautifulByDefault": "デフォルトで美しい",
"automatedLayouts": "自動レイアウトとプロのテーマ。",
@@ -740,21 +740,6 @@
"showBetaBadgeHint": "Display the BETA chip next to logo"
},
"shortcutsHint": "いつでも ? を押して、これらのショートカットを表示できます。",
- "privacy": {
- "manifesto": "プライバシーマニフェスト",
- "manifestoText": "私たちは設計によるプライバシーを信じています。",
- "localFirst": "ローカル保存",
- "noAccounts": "ユーザーアカウントなし",
- "ownData": "自分のデータは自分で管理",
- "anonymous": "匿名利用のみ",
- "telemetry": "テレメトリとフィードバック",
- "anonymousStats": "匿名統計",
- "anonymousStatsDesc": "訪問数と限定的な機能の使用状況のみ追跡。",
- "quote": "何がうまく機能しているかを知るには、あなたからのフィードバックが頼りです。",
- "helpImprove": "改善にご協力ください",
- "shareFeedback": "フィードバックを共有",
- "shareFeedbackDesc": "フィードバック、バグ報告、機能リクエストの送信"
- },
"settings": "設定",
"canvasSettings": "キャンバス設定",
"description": "キャンバス設定とキーボードショートカットを構成します。",
diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json
index 837376a9..bb97e2ec 100644
--- a/src/i18n/locales/tr/translation.json
+++ b/src/i18n/locales/tr/translation.json
@@ -264,7 +264,7 @@
"localStorageHint": "Autosaved on this device. We do not upload your diagram data to our servers.",
"renameFlow": {
"title": "Rename flow",
- "description": "Update the name shown on your dashboard and tabs.",
+ "description": "Update the name shown on your dashboard and in the editor.",
"label": "Flow name",
"placeholder": "Enter a flow name",
"hint": "Names are local to this browser profile unless you export or sync them elsewhere.",
@@ -364,7 +364,7 @@
"press": "Kısayollar için",
"shortcuts": "tuşlara basın",
"privacy": "Diyagramlarınız, API anahtarlarınız ve tüm verileriniz yalnızca cihazınızda saklanır.",
- "analytics": "Yalnızca anonim sayfa ziyaretleri takip edilir.",
+ "analytics": "Şu anda analiz verisi toplamıyoruz.",
"features": {
"beautifulByDefault": "Varsayılan olarak güzel",
"automatedLayouts": "Otomatik düzenler ve profesyonel temalar.",
@@ -997,21 +997,6 @@
"showBetaBadgeHint": "Logo yanında BETA çipini göster"
},
"shortcutsHint": "İstediğiniz zaman ? tuşuna basarak bunları görüntüleyebilirsiniz.",
- "privacy": {
- "manifesto": "Gizlilik Manifestomuz",
- "manifestoText": "Tasarım gereği gizlilik ve kullanıcı özerkliğine inanıyoruz. E-postanızı toplamıyoruz, giriş sistemimiz yok ve verilerinizi sunucularımızda saklamıyoruz.",
- "localFirst": "Yerel Öncelikli Depolama",
- "noAccounts": "Kullanıcı Hesabı Yok",
- "ownData": "Veriniz Size Ait",
- "anonymous": "Yalnızca Anonim Kullanım",
- "telemetry": "Telemetri ve Geri Bildirim",
- "anonymousStats": "Anonim İstatistikler",
- "anonymousStatsDesc": "Aracı geliştirmek için yalnızca ziyaretleri ve sınırlı anonim özellik kullanımını (dışa aktarma gibi) takip ediyoruz. Adlarınızı, IP adreslerinizi, girişlerinizi veya diyagram içeriğinizi takip etmiyoruz.",
- "quote": "Her hareketinizi takip etmek yerine, neyin işe yaradığını bize söylemenize güveniyoruz.",
- "helpImprove": "Geliştirmemize Yardımcı Olun",
- "shareFeedback": "Geri Bildirim Paylaş",
- "shareFeedbackDesc": "Geri bildirim verin, hataları bildirin ve özellik isteyin"
- },
"settings": "Settings",
"canvasSettings": "Canvas Settings",
"description": "Configure canvas preferences and keyboard shortcuts.",
@@ -1128,8 +1113,8 @@
"strictModePasteBlocked": "Mimari Katı Mod, Mermaid yapıştırmayı engelledi. Kod görünümünü açın, tanıları düzeltin ve tekrar deneyin."
},
"flowTabs": {
- "closeTab": "Sekmeyi Kapat",
- "newFlowTab": "Yeni Akış Sekmesi"
+ "closeTab": "Sayfayi Kapat",
+ "newFlowTab": "Yeni Sayfa"
},
"share": {
"close": "Close",
diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json
index b69f26b9..5a19b323 100644
--- a/src/i18n/locales/zh/translation.json
+++ b/src/i18n/locales/zh/translation.json
@@ -331,7 +331,7 @@
"press": "按下",
"shortcuts": "查看快捷键",
"privacy": "您的图表、API 密钥和数据均保存在本地设备上。",
- "analytics": "我们只统计匿名访问量。",
+ "analytics": "目前我们不收集分析数据。",
"features": {
"beautifulByDefault": "默认美观",
"automatedLayouts": "自动布局和专业主题。",
@@ -740,21 +740,6 @@
"showBetaBadgeHint": "Display the BETA chip next to logo"
},
"shortcutsHint": "你也可以随时按下 ? 键来查看这些快捷键。",
- "privacy": {
- "manifesto": "我们的隐私宣言",
- "manifestoText": "我们坚信基于设计的隐私保护。",
- "localFirst": "本地优先存储",
- "noAccounts": "没有用户账户",
- "ownData": "你的数据你做主",
- "anonymous": "仅限匿名使用",
- "telemetry": "遥测与反馈",
- "anonymousStats": "匿名统计",
- "anonymousStatsDesc": "我们只跟踪访问量。",
- "quote": "我们依靠您来告诉我们什么有效。",
- "helpImprove": "帮助我们改进",
- "shareFeedback": "分享反馈",
- "shareFeedbackDesc": "提供反馈、报告错误"
- },
"settings": "设置",
"canvasSettings": "画布设置",
"description": "配置画布偏好设置和键盘快捷键。",
diff --git a/src/index.tsx b/src/index.tsx
index 7c6e5bfb..13844652 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -4,11 +4,19 @@ import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RouteLoadingFallback } from './components/app/RouteLoadingFallback';
import { ToastProvider } from './components/ui/ToastContext';
+import {
+ captureAppOpened,
+ captureSessionStarted,
+ initializeAnalytics,
+} from './services/analytics/analytics';
import { ensureLocalFirstPersistenceReady } from './services/storage/localFirstRuntime';
import { installStorageTelemetrySink } from './services/storage/storageTelemetrySink';
import { registerAppShellServiceWorker } from './services/offline/registerAppShellServiceWorker';
import './index.css';
+initializeAnalytics();
+captureAppOpened();
+captureSessionStarted();
installStorageTelemetrySink();
registerAppShellServiceWorker();
diff --git a/src/services/analytics/analytics.ts b/src/services/analytics/analytics.ts
new file mode 100644
index 00000000..ca59e324
--- /dev/null
+++ b/src/services/analytics/analytics.ts
@@ -0,0 +1,157 @@
+import posthog from 'posthog-js';
+import {
+ getAnalyticsPreference,
+ subscribeToAnalyticsPreference,
+} from './analyticsSettings';
+import { initializeSurfaceAnalytics } from './surfaceAnalyticsClient';
+
+export type AnalyticsPropertyValue = string | number | boolean | null | undefined;
+export type AnalyticsProperties = Record;
+
+const POSTHOG_API_KEY = import.meta.env.VITE_POSTHOG_KEY?.trim();
+const POSTHOG_API_HOST = import.meta.env.VITE_POSTHOG_HOST?.trim() || 'https://us.i.posthog.com';
+const ANALYTICS_FEATURE_FLAG = import.meta.env.VITE_ENABLE_ANALYTICS === 'true';
+const SESSION_MARKER_KEY = 'openflowkit.analytics.session-started';
+
+let analyticsInitialized = false;
+let analyticsPreferenceSyncInstalled = false;
+let appSurfaceAnalytics = initializeSurfaceAnalytics({
+ surface: 'app',
+ apiKey: POSTHOG_API_KEY,
+ apiHost: POSTHOG_API_HOST,
+ enabled: false,
+});
+
+function isAnalyticsConfigured(): boolean {
+ return ANALYTICS_FEATURE_FLAG && Boolean(POSTHOG_API_KEY) && typeof window !== 'undefined';
+}
+
+function sanitizeString(value: string): string {
+ return value.trim().slice(0, 200);
+}
+
+function sanitizeProperties(properties?: AnalyticsProperties): Record {
+ if (!properties) return {};
+
+ return Object.fromEntries(
+ Object.entries(properties)
+ .filter(([, value]) => value !== undefined)
+ .map(([key, value]) => [key, typeof value === 'string' ? sanitizeString(value) : value ?? null])
+ );
+}
+
+function syncPostHogPreference(enabled: boolean): void {
+ if (!analyticsInitialized || !isAnalyticsConfigured()) {
+ return;
+ }
+
+ if (enabled) {
+ posthog.opt_in_capturing();
+ return;
+ }
+
+ posthog.opt_out_capturing();
+}
+
+function ensurePreferenceSyncInstalled(): void {
+ if (analyticsPreferenceSyncInstalled) {
+ return;
+ }
+
+ subscribeToAnalyticsPreference(syncPostHogPreference);
+ analyticsPreferenceSyncInstalled = true;
+}
+
+export function isAnalyticsEnabled(): boolean {
+ return isAnalyticsConfigured() && getAnalyticsPreference();
+}
+
+export function initializeAnalytics(): void {
+ if (!isAnalyticsConfigured() || analyticsInitialized || !POSTHOG_API_KEY) {
+ return;
+ }
+
+ appSurfaceAnalytics = initializeSurfaceAnalytics({
+ surface: 'app',
+ apiKey: POSTHOG_API_KEY,
+ apiHost: POSTHOG_API_HOST,
+ enabled: true,
+ });
+
+ analyticsInitialized = true;
+ ensurePreferenceSyncInstalled();
+
+ if (!getAnalyticsPreference()) {
+ posthog.opt_out_capturing();
+ }
+}
+
+export function captureAnalyticsEvent(
+ eventName: string,
+ properties?: AnalyticsProperties
+): void {
+ if (!isAnalyticsEnabled()) {
+ return;
+ }
+
+ initializeAnalytics();
+ appSurfaceAnalytics.capture(eventName, sanitizeProperties(properties));
+}
+
+export function captureAnalyticsException(
+ error: unknown,
+ properties?: AnalyticsProperties
+): void {
+ captureAnalyticsEvent('unhandled_error', {
+ error_name: error instanceof Error ? error.name : 'UnknownError',
+ ...properties,
+ });
+}
+
+function getReferrerHost(): string | null {
+ if (typeof document === 'undefined' || !document.referrer) {
+ return null;
+ }
+
+ try {
+ return new URL(document.referrer).host;
+ } catch {
+ return 'invalid';
+ }
+}
+
+function getLocationProperties(): AnalyticsProperties {
+ if (typeof window === 'undefined') {
+ return {};
+ }
+
+ const searchParams = new URLSearchParams(window.location.search);
+
+ return {
+ path: window.location.pathname,
+ hash_path: window.location.hash || null,
+ referrer_host: getReferrerHost(),
+ utm_source: searchParams.get('utm_source'),
+ utm_medium: searchParams.get('utm_medium'),
+ utm_campaign: searchParams.get('utm_campaign'),
+ utm_term: searchParams.get('utm_term'),
+ utm_content: searchParams.get('utm_content'),
+ };
+}
+
+export function captureAppOpened(): void {
+ captureAnalyticsEvent('app_opened', getLocationProperties());
+}
+
+export function captureSessionStarted(): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ if (window.sessionStorage.getItem(SESSION_MARKER_KEY) === 'true') {
+ return;
+ }
+
+ window.sessionStorage.setItem(SESSION_MARKER_KEY, 'true');
+ captureAnalyticsEvent('session_started', getLocationProperties());
+}
diff --git a/src/services/analytics/analyticsSettings.test.ts b/src/services/analytics/analyticsSettings.test.ts
new file mode 100644
index 00000000..12a6edee
--- /dev/null
+++ b/src/services/analytics/analyticsSettings.test.ts
@@ -0,0 +1,33 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ getAnalyticsPreference,
+ setAnalyticsPreference,
+ subscribeToAnalyticsPreference,
+} from './analyticsSettings';
+
+describe('analyticsSettings', () => {
+ afterEach(() => {
+ window.localStorage.removeItem('openflowkit-analytics-enabled');
+ });
+
+ it('defaults analytics preference to enabled', () => {
+ expect(getAnalyticsPreference()).toBe(true);
+ });
+
+ it('persists analytics preference updates', () => {
+ setAnalyticsPreference(false);
+
+ expect(getAnalyticsPreference()).toBe(false);
+ expect(window.localStorage.getItem('openflowkit-analytics-enabled')).toBe('false');
+ });
+
+ it('notifies subscribers when preference changes', () => {
+ const listener = vi.fn();
+ const unsubscribe = subscribeToAnalyticsPreference(listener);
+
+ setAnalyticsPreference(false);
+
+ expect(listener).toHaveBeenCalledWith(false);
+ unsubscribe();
+ });
+});
diff --git a/src/services/analytics/analyticsSettings.ts b/src/services/analytics/analyticsSettings.ts
new file mode 100644
index 00000000..e5cd2c36
--- /dev/null
+++ b/src/services/analytics/analyticsSettings.ts
@@ -0,0 +1,50 @@
+const ANALYTICS_PREFERENCE_STORAGE_KEY = 'openflowkit-analytics-enabled';
+
+type AnalyticsPreferenceListener = (enabled: boolean) => void;
+
+const listeners = new Set();
+
+function readStoredPreference(): boolean | null {
+ if (typeof window === 'undefined') return null;
+
+ try {
+ const rawValue = window.localStorage.getItem(ANALYTICS_PREFERENCE_STORAGE_KEY);
+ if (rawValue === 'true') return true;
+ if (rawValue === 'false') return false;
+ } catch {
+ // Ignore local preference read failures and fall back to defaults.
+ }
+
+ return null;
+}
+
+export function getAnalyticsPreference(): boolean {
+ return readStoredPreference() ?? true;
+}
+
+export function setAnalyticsPreference(enabled: boolean): void {
+ if (typeof window !== 'undefined') {
+ try {
+ window.localStorage.setItem(ANALYTICS_PREFERENCE_STORAGE_KEY, String(enabled));
+ } catch {
+ // Ignore local preference write failures and still notify listeners.
+ }
+ }
+
+ listeners.forEach((listener) => {
+ try {
+ listener(enabled);
+ } catch {
+ // Analytics preference listeners are best-effort only.
+ }
+ });
+}
+
+export function subscribeToAnalyticsPreference(
+ listener: AnalyticsPreferenceListener
+): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
diff --git a/src/services/analytics/surfaceAnalyticsClient.ts b/src/services/analytics/surfaceAnalyticsClient.ts
new file mode 100644
index 00000000..448ac158
--- /dev/null
+++ b/src/services/analytics/surfaceAnalyticsClient.ts
@@ -0,0 +1,125 @@
+import posthog from 'posthog-js';
+
+export type AnalyticsSurface = 'app' | 'landing' | 'docs';
+
+type SurfaceAnalyticsOptions = {
+ surface: AnalyticsSurface;
+ apiKey?: string;
+ apiHost?: string;
+ enabled?: boolean;
+ defaultProperties?: Record;
+};
+
+type SurfaceEventProperties = Record;
+
+const initializedSurfaces = new Set();
+
+function sanitizeString(value: string): string {
+ return value.trim().slice(0, 200);
+}
+
+function sanitizeProperties(
+ surface: AnalyticsSurface,
+ properties?: SurfaceEventProperties,
+ defaultProperties?: SurfaceEventProperties
+): Record {
+ return Object.fromEntries(
+ Object.entries({
+ surface,
+ ...defaultProperties,
+ ...properties,
+ })
+ .filter(([, value]) => value !== undefined)
+ .map(([key, value]) => [key, typeof value === 'string' ? sanitizeString(value) : value ?? null])
+ );
+}
+
+function getEnvironment(): string {
+ if (typeof window === 'undefined') {
+ return 'server';
+ }
+
+ const hostname = window.location.hostname;
+ if (hostname === 'localhost' || hostname === '127.0.0.1') return 'local';
+ if (hostname.includes('preview') || hostname.includes('vercel') || hostname.includes('netlify')) return 'preview';
+ return 'production';
+}
+
+function getReferrerHost(): string | null {
+ if (typeof document === 'undefined' || !document.referrer) {
+ return null;
+ }
+
+ try {
+ return new URL(document.referrer).host;
+ } catch {
+ return 'invalid';
+ }
+}
+
+function getPageProperties(): SurfaceEventProperties {
+ if (typeof window === 'undefined') {
+ return {};
+ }
+
+ const searchParams = new URLSearchParams(window.location.search);
+
+ return {
+ path: window.location.pathname,
+ hash_path: window.location.hash || null,
+ referrer_host: getReferrerHost(),
+ environment: getEnvironment(),
+ utm_source: searchParams.get('utm_source'),
+ utm_medium: searchParams.get('utm_medium'),
+ utm_campaign: searchParams.get('utm_campaign'),
+ utm_term: searchParams.get('utm_term'),
+ utm_content: searchParams.get('utm_content'),
+ };
+}
+
+export function initializeSurfaceAnalytics({
+ surface,
+ apiKey,
+ apiHost = 'https://us.i.posthog.com',
+ enabled = false,
+ defaultProperties,
+}: SurfaceAnalyticsOptions): {
+ capture: (eventName: string, properties?: SurfaceEventProperties) => void;
+ capturePageView: (eventName?: string, properties?: SurfaceEventProperties) => void;
+} {
+ const isEnabled = enabled && Boolean(apiKey) && typeof window !== 'undefined';
+
+ if (isEnabled && apiKey && !initializedSurfaces.has(surface)) {
+ posthog.init(apiKey, {
+ api_host: apiHost,
+ autocapture: false,
+ capture_pageview: false,
+ capture_pageleave: false,
+ capture_exceptions: false,
+ disable_session_recording: true,
+ persistence: 'localStorage',
+ persistence_name: `openflowkit_${surface}`,
+ });
+ initializedSurfaces.add(surface);
+ }
+
+ function capture(eventName: string, properties?: SurfaceEventProperties): void {
+ if (!isEnabled) {
+ return;
+ }
+
+ posthog.capture(eventName, sanitizeProperties(surface, properties, defaultProperties));
+ }
+
+ function capturePageView(eventName = 'page_viewed', properties?: SurfaceEventProperties): void {
+ capture(eventName, {
+ ...getPageProperties(),
+ ...properties,
+ });
+ }
+
+ return {
+ capture,
+ capturePageView,
+ };
+}
diff --git a/src/services/onboarding/events.ts b/src/services/onboarding/events.ts
index 3c83bf75..a0a33907 100644
--- a/src/services/onboarding/events.ts
+++ b/src/services/onboarding/events.ts
@@ -1,3 +1,5 @@
+import { captureAnalyticsEvent } from '@/services/analytics/analytics';
+
export type OnboardingEventName =
| 'welcome_template_selected'
| 'welcome_import_selected'
@@ -89,6 +91,11 @@ export function recordOnboardingEvent(
);
}
+ captureAnalyticsEvent(name, {
+ first: event.first,
+ ...detail,
+ });
+
return event;
}
diff --git a/src/services/storage/flowDocumentModel.test.ts b/src/services/storage/flowDocumentModel.test.ts
new file mode 100644
index 00000000..bc60f856
--- /dev/null
+++ b/src/services/storage/flowDocumentModel.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, it } from 'vitest';
+import type { FlowTab } from '@/lib/types';
+import type { PersistedDocument, WorkspaceMeta } from './persistenceTypes';
+import {
+ convertFlowDocumentsToTabs,
+ createFlowDocumentFromPersistedDocument,
+ createFlowDocumentsFromPersistedDocuments,
+ createLoadedFlowWorkspace,
+} from './flowDocumentModel';
+
+function createPersistedDocument(overrides: Partial = {}): PersistedDocument {
+ const tabHistory: FlowTab['history'] = { past: [], future: [] };
+
+ return {
+ id: 'doc-1',
+ name: 'System Design',
+ diagramType: 'flowchart',
+ createdAt: '2026-03-27T00:00:00.000Z',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ deletedAt: null,
+ content: {
+ nodes: [],
+ edges: [],
+ history: tabHistory,
+ playback: undefined,
+ },
+ ...overrides,
+ };
+}
+
+function createWorkspaceMeta(overrides: Partial = {}): WorkspaceMeta {
+ return {
+ id: 'workspace',
+ activeDocumentId: 'doc-1',
+ documentOrder: ['doc-1'],
+ lastOpenedAt: '2026-03-27T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+describe('flowDocumentModel', () => {
+ it('maps a persisted document into a single-page flow document', () => {
+ const persistedDocument = createPersistedDocument();
+
+ const document = createFlowDocumentFromPersistedDocument(persistedDocument);
+
+ expect(document.id).toBe('doc-1');
+ expect(document.name).toBe('System Design');
+ expect(document.pages).toHaveLength(1);
+ expect(document.pages[0]?.id).toBe('doc-1:page:primary');
+ expect(document.activePageId).toBe('doc-1:page:primary');
+ });
+
+ it('creates a loaded flow workspace with explicit documents', () => {
+ const persistedDocument = createPersistedDocument();
+
+ const workspace = createLoadedFlowWorkspace({
+ document: persistedDocument,
+ documents: [persistedDocument],
+ workspaceMeta: createWorkspaceMeta(),
+ });
+
+ expect(workspace.activeDocumentId).toBe('doc-1');
+ expect(workspace.documents).toHaveLength(1);
+ expect(workspace.documents[0]?.pages).toHaveLength(1);
+ });
+
+ it('preserves multipage persisted documents', () => {
+ const persistedDocument = createPersistedDocument({
+ content: undefined,
+ activePageId: 'doc-1:page:deployment',
+ pages: [
+ {
+ id: 'doc-1:page:overview',
+ name: 'Overview',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ content: {
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ playback: undefined,
+ },
+ },
+ {
+ id: 'doc-1:page:deployment',
+ name: 'Deployment',
+ diagramType: 'sequence',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ content: {
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ playback: undefined,
+ },
+ },
+ ],
+ });
+
+ const document = createFlowDocumentFromPersistedDocument(persistedDocument);
+
+ expect(document.pages).toHaveLength(2);
+ expect(document.activePageId).toBe('doc-1:page:deployment');
+ expect(document.pages[1]?.name).toBe('Deployment');
+ });
+
+ it('converts flow documents back to tabs for the current editor UI', () => {
+ const documents = createFlowDocumentsFromPersistedDocuments([
+ createPersistedDocument(),
+ ]);
+
+ const tabs = convertFlowDocumentsToTabs(documents);
+
+ expect(tabs).toHaveLength(1);
+ expect(tabs[0]?.id).toBe('doc-1');
+ expect(tabs[0]?.name).toBe('System Design');
+ });
+});
diff --git a/src/services/storage/flowDocumentModel.ts b/src/services/storage/flowDocumentModel.ts
new file mode 100644
index 00000000..1bf70b69
--- /dev/null
+++ b/src/services/storage/flowDocumentModel.ts
@@ -0,0 +1,133 @@
+import type { DiagramType, FlowTab, PlaybackState } from '@/lib/types';
+import type {
+ LoadedDocument,
+ PersistedDocument,
+ PersistedDocumentContent,
+ PersistedDocumentPage,
+ WorkspaceMeta,
+} from './persistenceTypes';
+
+export interface FlowPage {
+ id: string;
+ name: string;
+ diagramType?: DiagramType;
+ updatedAt?: string;
+ nodes: FlowTab['nodes'];
+ edges: FlowTab['edges'];
+ playback?: PlaybackState;
+ history: FlowTab['history'];
+}
+
+export interface FlowDocument {
+ id: string;
+ name: string;
+ createdAt: string;
+ updatedAt: string;
+ activePageId: string;
+ pages: FlowPage[];
+}
+
+export interface LoadedFlowWorkspace {
+ activeDocumentId: string | null;
+ documents: FlowDocument[];
+ workspaceMeta: WorkspaceMeta;
+}
+
+function createFlowPageFromPersistedContent(
+ documentId: string,
+ name: string,
+ diagramType: DiagramType | undefined,
+ updatedAt: string,
+ content: PersistedDocumentContent
+): FlowPage {
+ return {
+ id: `${documentId}:page:primary`,
+ name,
+ diagramType,
+ updatedAt,
+ nodes: content.nodes,
+ edges: content.edges,
+ playback: content.playback,
+ history: content.history,
+ };
+}
+
+function createFlowPageFromPersistedPage(page: PersistedDocumentPage): FlowPage {
+ return {
+ id: page.id,
+ name: page.name,
+ diagramType: page.diagramType,
+ updatedAt: page.updatedAt,
+ nodes: page.content.nodes,
+ edges: page.content.edges,
+ playback: page.content.playback,
+ history: page.content.history,
+ };
+}
+
+export function createFlowDocumentFromPersistedDocument(
+ document: PersistedDocument
+): FlowDocument {
+ const pages = document.pages?.length
+ ? document.pages.map(createFlowPageFromPersistedPage)
+ : document.content
+ ? [createFlowPageFromPersistedContent(
+ document.id,
+ document.name,
+ document.diagramType,
+ document.updatedAt,
+ document.content,
+ )]
+ : [];
+
+ if (pages.length === 0) {
+ throw new Error(`Persisted document "${document.id}" is missing page content.`);
+ }
+
+ const activePageId = pages.some((page) => page.id === document.activePageId)
+ ? (document.activePageId as string)
+ : pages[0].id;
+
+ return {
+ id: document.id,
+ name: document.name,
+ createdAt: document.createdAt,
+ updatedAt: document.updatedAt,
+ activePageId,
+ pages,
+ };
+}
+
+export function createFlowDocumentsFromPersistedDocuments(
+ documents: PersistedDocument[]
+): FlowDocument[] {
+ return documents.map(createFlowDocumentFromPersistedDocument);
+}
+
+export function createLoadedFlowWorkspace(
+ loaded: LoadedDocument
+): LoadedFlowWorkspace {
+ const documents = createFlowDocumentsFromPersistedDocuments(loaded.documents);
+ const activeDocumentId = loaded.document?.id ?? loaded.workspaceMeta.activeDocumentId ?? documents[0]?.id ?? null;
+
+ return {
+ activeDocumentId,
+ documents,
+ workspaceMeta: loaded.workspaceMeta,
+ };
+}
+
+export function convertFlowDocumentsToTabs(documents: FlowDocument[]): FlowTab[] {
+ return documents.flatMap((document) =>
+ document.pages.map((page) => ({
+ id: document.pages.length === 1 ? document.id : page.id,
+ name: document.pages.length === 1 ? document.name : `${document.name} / ${page.name}`,
+ diagramType: page.diagramType,
+ updatedAt: page.updatedAt ?? document.updatedAt,
+ nodes: page.nodes,
+ edges: page.edges,
+ playback: page.playback,
+ history: page.history,
+ }))
+ );
+}
diff --git a/src/services/storage/localFirstRepository.ts b/src/services/storage/localFirstRepository.ts
index b8699a14..1a31415a 100644
--- a/src/services/storage/localFirstRepository.ts
+++ b/src/services/storage/localFirstRepository.ts
@@ -1,5 +1,18 @@
import type { FlowTab } from '@/lib/types';
import type { ChatMessage } from '@/services/aiService';
+import {
+ createFlowTabsFromPersistedDocuments,
+ createPersistedDocumentFromFlowDocument,
+ createPersistedDocumentsFromTabs,
+} from './persistedDocumentAdapters';
+import type { FlowDocument } from './flowDocumentModel';
+import type {
+ LoadedDocument,
+ PersistedDocument,
+ PersistedDocumentContent,
+ PersistedDocumentSession,
+ WorkspaceMeta,
+} from './persistenceTypes';
import {
AI_SETTINGS_PERSISTENT_STORE_NAME,
CHAT_MESSAGES_STORE_NAME,
@@ -19,32 +32,6 @@ const WORKSPACE_META_FALLBACK_KEY = 'openflowkit-workspace-meta-fallback';
const CHAT_HISTORY_STORAGE_KEY_PREFIX = 'ofk_chat_history_';
const PERSISTENT_AI_SETTINGS_RECORD_ID = 'default';
-export interface PersistedDocumentContent {
- nodes: FlowTab['nodes'];
- edges: FlowTab['edges'];
- playback?: FlowTab['playback'];
- history: FlowTab['history'];
-}
-
-export interface PersistedDocument {
- id: string;
- name: string;
- diagramType?: FlowTab['diagramType'];
- content: PersistedDocumentContent;
- createdAt: string;
- updatedAt: string;
- deletedAt: string | null;
-}
-
-export interface PersistedDocumentSession {
- id: string;
- documentId: string;
- camera?: unknown;
- viewport?: unknown;
- lastOpenedPanel?: string;
- lastOpenedAt: string;
-}
-
export interface PersistedChatThread {
id: string;
documentId: string;
@@ -59,27 +46,17 @@ export interface PersistedChatMessage {
createdAt: string;
}
-export interface WorkspaceMeta {
- id: typeof WORKSPACE_META_ID;
- activeDocumentId: string | null;
- documentOrder: string[];
- lastOpenedAt: string;
-}
-
export interface PersistedAISettingsRecord {
id: 'default';
value: string;
}
-export interface LoadedDocument {
- document: PersistedDocument | null;
- documents: PersistedDocument[];
- workspaceMeta: WorkspaceMeta;
-}
-
export interface PersistenceRepository {
+ loadWorkspaceSnapshot(): Promise;
loadActiveDocument(): Promise;
saveDocument(documentId: string, content: PersistedDocumentContent): Promise;
+ saveFlowDocuments(documents: FlowDocument[], activeDocumentId: string | null): Promise;
+ saveDocuments(documents: PersistedDocument[], activeDocumentId: string | null): Promise;
saveWorkspace(tabs: FlowTab[], activeDocumentId: string | null): Promise;
deleteDocument(documentId: string): Promise;
loadDocumentSession(documentId: string): Promise;
@@ -119,37 +96,6 @@ function getNowIso(): string {
return new Date().toISOString();
}
-function toDocumentRecord(tab: FlowTab): PersistedDocument {
- const nowIso = getNowIso();
- return {
- id: tab.id,
- name: tab.name,
- diagramType: tab.diagramType,
- content: {
- nodes: tab.nodes,
- edges: tab.edges,
- playback: tab.playback,
- history: tab.history,
- },
- createdAt: tab.updatedAt ?? nowIso,
- updatedAt: tab.updatedAt ?? nowIso,
- deletedAt: null,
- };
-}
-
-function toFlowTab(document: PersistedDocument): FlowTab {
- return {
- id: document.id,
- name: document.name,
- diagramType: document.diagramType,
- updatedAt: document.updatedAt,
- nodes: document.content.nodes,
- edges: document.content.edges,
- playback: document.content.playback,
- history: document.content.history,
- };
-}
-
async function withDatabase(handler: (database: IDBDatabase) => Promise): Promise {
const indexedDbFactory = getIndexedDbFactory();
if (!indexedDbFactory) {
@@ -258,7 +204,7 @@ async function migrateLegacyChatHistory(documentId: string): Promise {
}
export const localFirstRepository: PersistenceRepository = {
- async loadActiveDocument(): Promise {
+ async loadWorkspaceSnapshot(): Promise {
try {
const loaded = await withDatabase(async (database) => {
const documents = (await getAllRecords(database, PERSISTED_DOCUMENTS_STORE_NAME))
@@ -310,8 +256,12 @@ export const localFirstRepository: PersistenceRepository = {
}
},
+ async loadActiveDocument(): Promise {
+ return this.loadWorkspaceSnapshot();
+ },
+
async saveDocument(documentId: string, content: PersistedDocumentContent): Promise {
- const loaded = await this.loadActiveDocument();
+ const loaded = await this.loadWorkspaceSnapshot();
const targetDocument = loaded.documents.find((document) => document.id === documentId);
if (!targetDocument) {
return;
@@ -320,6 +270,11 @@ export const localFirstRepository: PersistenceRepository = {
const updatedDocument: PersistedDocument = {
...targetDocument,
content,
+ pages: targetDocument.pages?.map((page) =>
+ page.id === targetDocument.activePageId
+ ? { ...page, content, updatedAt: getNowIso() }
+ : page,
+ ) ?? targetDocument.pages,
updatedAt: getNowIso(),
};
@@ -333,9 +288,8 @@ export const localFirstRepository: PersistenceRepository = {
}
},
- async saveWorkspace(tabs: FlowTab[], activeDocumentId: string | null): Promise {
+ async saveDocuments(documents: PersistedDocument[], activeDocumentId: string | null): Promise {
const nowIso = getNowIso();
- const documents = tabs.map(toDocumentRecord);
const workspaceMeta = createDefaultWorkspaceMeta(
documents.map((document) => document.id),
activeDocumentId ?? documents[0]?.id ?? null,
@@ -383,6 +337,17 @@ export const localFirstRepository: PersistenceRepository = {
}
},
+ async saveWorkspace(tabs: FlowTab[], activeDocumentId: string | null): Promise {
+ return this.saveDocuments(createPersistedDocumentsFromTabs(tabs), activeDocumentId);
+ },
+
+ async saveFlowDocuments(documents: FlowDocument[], activeDocumentId: string | null): Promise {
+ return this.saveDocuments(
+ documents.map(createPersistedDocumentFromFlowDocument),
+ activeDocumentId,
+ );
+ },
+
async deleteDocument(documentId: string): Promise {
try {
await withDatabase(async (database) => {
@@ -529,6 +494,16 @@ export const localFirstRepository: PersistenceRepository = {
},
};
+export type {
+ LoadedDocument,
+ PersistedDocument,
+ PersistedDocumentContent,
+ PersistedDocumentSession,
+ WorkspaceMeta,
+} from './persistenceTypes';
+
+export { createFlowDocumentFromPersistedDocument, createFlowDocumentsFromPersistedDocuments, convertFlowDocumentsToTabs, createLoadedFlowWorkspace } from './flowDocumentModel';
+
export function convertPersistedDocumentsToTabs(documents: PersistedDocument[]): FlowTab[] {
- return documents.map(toFlowTab);
+ return createFlowTabsFromPersistedDocuments(documents);
}
diff --git a/src/services/storage/localFirstRuntime.ts b/src/services/storage/localFirstRuntime.ts
index f02392cc..741151e4 100644
--- a/src/services/storage/localFirstRuntime.ts
+++ b/src/services/storage/localFirstRuntime.ts
@@ -1,10 +1,18 @@
import { DEFAULT_AI_SETTINGS } from '@/store';
+import { captureAnalyticsEvent } from '@/services/analytics/analytics';
import { sanitizeAISettings } from '@/store/aiSettings';
import { clearPersistedAISettings, loadPersistedAISettings } from '@/store/aiSettingsPersistence';
import { sanitizePersistedTab } from '@/store/persistence';
+import { syncWorkspaceDocuments } from '@/store/documentStateSync';
+import { getEditorPagesForDocument } from '@/store/workspaceDocumentModel';
import type { FlowStoreState } from '@/store';
import { useFlowStore } from '@/store';
-import { convertPersistedDocumentsToTabs, localFirstRepository, type PersistedChatMessage } from './localFirstRepository';
+import { createPersistedDocumentsFromTabs } from './persistedDocumentAdapters';
+import {
+ createLoadedFlowWorkspace,
+ localFirstRepository,
+ type PersistedChatMessage,
+} from './localFirstRepository';
const STORE_SUBSCRIPTION_DEBOUNCE_MS = 250;
@@ -63,7 +71,7 @@ function toPersistedChatMessages(documentId: string, serialized: string | null):
async function migrateLegacyStoreIntoRepositoryIfNeeded(): Promise {
const currentState = useFlowStore.getState();
- const loaded = await localFirstRepository.loadActiveDocument();
+ const loaded = await localFirstRepository.loadWorkspaceSnapshot();
if (loaded.documents.length > 0) {
return;
}
@@ -73,7 +81,10 @@ async function migrateLegacyStoreIntoRepositoryIfNeeded(): Promise {
return;
}
- await localFirstRepository.saveWorkspace(tabs, currentState.activeTabId);
+ await localFirstRepository.saveDocuments(
+ createPersistedDocumentsFromTabs(tabs),
+ currentState.activeTabId,
+ );
await Promise.all(
tabs.map(async (tab) => {
@@ -93,14 +104,17 @@ async function migrateLegacyStoreIntoRepositoryIfNeeded(): Promise {
}
async function hydrateStoreFromRepository(): Promise {
- const loaded = await localFirstRepository.loadActiveDocument();
- const tabs = convertPersistedDocumentsToTabs(loaded.documents);
- if (tabs.length === 0) {
+ const loaded = await localFirstRepository.loadWorkspaceSnapshot();
+ const workspace = createLoadedFlowWorkspace(loaded);
+ const activeDocument = getEditorPagesForDocument(workspace.documents, workspace.activeDocumentId);
+ if (!activeDocument) {
+ captureAnalyticsEvent('workspace_restored', {
+ document_count: 0,
+ has_active_document: false,
+ });
return;
}
- const activeTabId = loaded.document?.id ?? loaded.workspaceMeta.activeDocumentId ?? tabs[0].id;
- const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
const persistentAiSettings = await localFirstRepository.loadPersistentAISettings();
const aiSettings = persistentAiSettings
? sanitizeAISettings(JSON.parse(persistentAiSettings) as Partial, DEFAULT_AI_SETTINGS)
@@ -108,18 +122,36 @@ async function hydrateStoreFromRepository(): Promise {
useFlowStore.setState((currentState) => ({
...currentState,
- tabs,
- activeTabId: activeTab.id,
- nodes: activeTab.nodes,
- edges: activeTab.edges,
+ documents: workspace.documents,
+ activeDocumentId: activeDocument.activeDocumentId,
+ tabs: activeDocument.pages,
+ activeTabId: activeDocument.activePageId,
+ nodes: activeDocument.pages.find((page) => page.id === activeDocument.activePageId)?.nodes ?? [],
+ edges: activeDocument.pages.find((page) => page.id === activeDocument.activePageId)?.edges ?? [],
aiSettings,
}));
+
+ captureAnalyticsEvent('workspace_restored', {
+ document_count: workspace.documents.length,
+ has_active_document: Boolean(activeDocument.activeDocumentId),
+ });
}
function persistStoreSnapshot(): void {
const nextState = useFlowStore.getState();
+ const documents = syncWorkspaceDocuments({
+ documents: nextState.documents,
+ activeDocumentId: nextState.activeDocumentId,
+ tabs: nextState.tabs.map(sanitizePersistedTab),
+ activeTabId: nextState.activeTabId,
+ nodes: nextState.nodes,
+ edges: nextState.edges,
+ });
- void localFirstRepository.saveWorkspace(nextState.tabs.map(sanitizePersistedTab), nextState.activeTabId);
+ void localFirstRepository.saveFlowDocuments(
+ documents,
+ nextState.activeDocumentId,
+ );
if (nextState.aiSettings.storageMode === 'local') {
void localFirstRepository.savePersistentAISettings(JSON.stringify(nextState.aiSettings));
@@ -140,11 +172,13 @@ export async function initializeLocalFirstPersistence(): Promise {
let debounceTimer: ReturnType | null = null;
syncStopper = useFlowStore.subscribe((state, previousState) => {
+ const documentsChanged = state.documents !== previousState.documents;
const tabsChanged = state.tabs !== previousState.tabs;
- const activeDocumentChanged = state.activeTabId !== previousState.activeTabId;
+ const activeDocumentChanged = state.activeDocumentId !== previousState.activeDocumentId;
+ const activePageChanged = state.activeTabId !== previousState.activeTabId;
const aiSettingsChanged = state.aiSettings !== previousState.aiSettings;
- if (!tabsChanged && !activeDocumentChanged && !aiSettingsChanged) {
+ if (!documentsChanged && !tabsChanged && !activeDocumentChanged && !activePageChanged && !aiSettingsChanged) {
return;
}
diff --git a/src/services/storage/persistedDocumentAdapters.test.ts b/src/services/storage/persistedDocumentAdapters.test.ts
new file mode 100644
index 00000000..62377ecd
--- /dev/null
+++ b/src/services/storage/persistedDocumentAdapters.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import type { FlowTab } from '@/lib/types';
+import {
+ createFlowTabFromPersistedDocument,
+ createPersistedDocumentFromFlowDocument,
+ createPersistedDocumentFromTab,
+} from './persistedDocumentAdapters';
+
+function createFlowTab(overrides: Partial = {}): FlowTab {
+ return {
+ id: 'tab-1',
+ name: 'System Design',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ playback: undefined,
+ history: { past: [], future: [] },
+ ...overrides,
+ };
+}
+
+describe('persistedDocumentAdapters', () => {
+ it('maps a flow tab into a persisted document', () => {
+ const document = createPersistedDocumentFromTab(createFlowTab());
+
+ expect(document.id).toBe('tab-1');
+ expect(document.name).toBe('System Design');
+ expect(document.content.nodes).toEqual([]);
+ expect(document.deletedAt).toBeNull();
+ });
+
+ it('maps a persisted document back into an editor tab', () => {
+ const tab = createFlowTabFromPersistedDocument(createPersistedDocumentFromTab(createFlowTab()));
+
+ expect(tab.id).toBe('tab-1');
+ expect(tab.name).toBe('System Design');
+ expect(tab.diagramType).toBe('flowchart');
+ expect(tab.history).toEqual({ past: [], future: [] });
+ });
+
+ it('maps a multi-page flow document into a persisted multi-page document', () => {
+ const persisted = createPersistedDocumentFromFlowDocument({
+ id: 'doc-1',
+ name: 'Architecture',
+ createdAt: '2026-03-27T00:00:00.000Z',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ activePageId: 'page-2',
+ pages: [
+ {
+ id: 'page-1',
+ name: 'Overview',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ playback: undefined,
+ history: { past: [], future: [] },
+ },
+ {
+ id: 'page-2',
+ name: 'Deployment',
+ diagramType: 'sequence',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ playback: undefined,
+ history: { past: [], future: [] },
+ },
+ ],
+ });
+
+ expect(persisted.pages).toHaveLength(2);
+ expect(persisted.activePageId).toBe('page-2');
+ expect(persisted.content?.history).toEqual({ past: [], future: [] });
+ });
+});
diff --git a/src/services/storage/persistedDocumentAdapters.ts b/src/services/storage/persistedDocumentAdapters.ts
new file mode 100644
index 00000000..42c6ecfe
--- /dev/null
+++ b/src/services/storage/persistedDocumentAdapters.ts
@@ -0,0 +1,106 @@
+import type { FlowTab } from '@/lib/types';
+import type { FlowDocument, FlowPage } from './flowDocumentModel';
+import type { PersistedDocument, PersistedDocumentPage } from './persistenceTypes';
+
+function getNowIso(): string {
+ return new Date().toISOString();
+}
+
+export function createPersistedDocumentFromTab(tab: FlowTab): PersistedDocument {
+ const nowIso = getNowIso();
+ const primaryPageId = `${tab.id}:page:primary`;
+ return {
+ id: tab.id,
+ name: tab.name,
+ diagramType: tab.diagramType,
+ content: {
+ nodes: tab.nodes,
+ edges: tab.edges,
+ playback: tab.playback,
+ history: tab.history,
+ },
+ pages: [
+ {
+ id: primaryPageId,
+ name: tab.name,
+ diagramType: tab.diagramType,
+ updatedAt: tab.updatedAt ?? nowIso,
+ content: {
+ nodes: tab.nodes,
+ edges: tab.edges,
+ playback: tab.playback,
+ history: tab.history,
+ },
+ },
+ ],
+ activePageId: primaryPageId,
+ createdAt: tab.updatedAt ?? nowIso,
+ updatedAt: tab.updatedAt ?? nowIso,
+ deletedAt: null,
+ };
+}
+
+export function createPersistedDocumentsFromTabs(tabs: FlowTab[]): PersistedDocument[] {
+ return tabs.map(createPersistedDocumentFromTab);
+}
+
+export function createFlowTabFromPersistedDocument(document: PersistedDocument): FlowTab {
+ const primaryPage = document.pages?.find((page) => page.id === document.activePageId)
+ ?? document.pages?.[0];
+ const content = primaryPage?.content ?? document.content;
+ const diagramType = primaryPage?.diagramType ?? document.diagramType;
+
+ if (!content) {
+ throw new Error(`Persisted document "${document.id}" is missing page content.`);
+ }
+
+ return {
+ id: document.id,
+ name: document.name,
+ diagramType,
+ updatedAt: primaryPage?.updatedAt ?? document.updatedAt,
+ nodes: content.nodes,
+ edges: content.edges,
+ playback: content.playback,
+ history: content.history,
+ };
+}
+
+export function createFlowTabsFromPersistedDocuments(documents: PersistedDocument[]): FlowTab[] {
+ return documents.map(createFlowTabFromPersistedDocument);
+}
+
+function createPersistedPageFromFlowPage(page: FlowPage): PersistedDocumentPage {
+ return {
+ id: page.id,
+ name: page.name,
+ diagramType: page.diagramType,
+ updatedAt: page.updatedAt,
+ content: {
+ nodes: page.nodes,
+ edges: page.edges,
+ playback: page.playback,
+ history: page.history,
+ },
+ };
+}
+
+export function createPersistedDocumentFromFlowDocument(document: FlowDocument): PersistedDocument {
+ const pages = document.pages.map(createPersistedPageFromFlowPage);
+ const activePage = pages.find((page) => page.id === document.activePageId) ?? pages[0];
+ return {
+ id: document.id,
+ name: document.name,
+ diagramType: activePage?.diagramType,
+ content: activePage?.content,
+ pages,
+ activePageId: activePage?.id,
+ createdAt: document.createdAt,
+ updatedAt: document.updatedAt,
+ deletedAt: null,
+ };
+}
+
+export function createPersistedDocumentsFromFlowDocuments(documents: FlowDocument[]): PersistedDocument[] {
+ return documents.map(createPersistedDocumentFromFlowDocument);
+}
diff --git a/src/services/storage/persistenceTypes.ts b/src/services/storage/persistenceTypes.ts
new file mode 100644
index 00000000..2fdff754
--- /dev/null
+++ b/src/services/storage/persistenceTypes.ts
@@ -0,0 +1,50 @@
+import type { FlowTab } from '@/lib/types';
+
+export interface PersistedDocumentContent {
+ nodes: FlowTab['nodes'];
+ edges: FlowTab['edges'];
+ playback?: FlowTab['playback'];
+ history: FlowTab['history'];
+}
+
+export interface PersistedDocumentPage {
+ id: string;
+ name: string;
+ diagramType?: FlowTab['diagramType'];
+ updatedAt?: string;
+ content: PersistedDocumentContent;
+}
+
+export interface PersistedDocument {
+ id: string;
+ name: string;
+ diagramType?: FlowTab['diagramType'];
+ content?: PersistedDocumentContent;
+ pages?: PersistedDocumentPage[];
+ activePageId?: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface PersistedDocumentSession {
+ id: string;
+ documentId: string;
+ camera?: unknown;
+ viewport?: unknown;
+ lastOpenedPanel?: string;
+ lastOpenedAt: string;
+}
+
+export interface WorkspaceMeta {
+ id: 'workspace';
+ activeDocumentId: string | null;
+ documentOrder: string[];
+ lastOpenedAt: string;
+}
+
+export interface LoadedDocument {
+ document: PersistedDocument | null;
+ documents: PersistedDocument[];
+ workspaceMeta: WorkspaceMeta;
+}
diff --git a/src/services/storage/storageTelemetry.test.ts b/src/services/storage/storageTelemetry.test.ts
index 261565f5..f7bf73da 100644
--- a/src/services/storage/storageTelemetry.test.ts
+++ b/src/services/storage/storageTelemetry.test.ts
@@ -1,11 +1,21 @@
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { captureAnalyticsEvent } from '@/services/analytics/analytics';
import {
reportStorageTelemetry,
setStorageTelemetryHandler,
type StorageTelemetryEvent,
} from './storageTelemetry';
+vi.mock('@/services/analytics/analytics', () => ({
+ captureAnalyticsEvent: vi.fn(),
+}));
+
describe('storageTelemetry', () => {
+ afterEach(() => {
+ setStorageTelemetryHandler(null);
+ vi.mocked(captureAnalyticsEvent).mockClear();
+ });
+
it('forwards events to registered handler', () => {
const handler = vi.fn();
setStorageTelemetryHandler(handler);
@@ -19,7 +29,6 @@ describe('storageTelemetry', () => {
reportStorageTelemetry(event);
expect(handler).toHaveBeenCalledWith(event);
- setStorageTelemetryHandler(null);
});
it('never throws when handler throws', () => {
@@ -35,6 +44,22 @@ describe('storageTelemetry', () => {
message: 'throw test',
});
}).not.toThrow();
+ });
+
+ it('forwards warning and error events to analytics capture', () => {
setStorageTelemetryHandler(null);
+
+ reportStorageTelemetry({
+ area: 'snapshot',
+ code: 'SNAPSHOT_SAVE_FALLBACK_LOCAL',
+ severity: 'warning',
+ message: 'warning test',
+ });
+
+ expect(captureAnalyticsEvent).toHaveBeenLastCalledWith('storage_issue_reported', {
+ area: 'snapshot',
+ code: 'SNAPSHOT_SAVE_FALLBACK_LOCAL',
+ severity: 'warning',
+ });
});
});
diff --git a/src/services/storage/storageTelemetry.ts b/src/services/storage/storageTelemetry.ts
index ee5561c4..3f0ea0a4 100644
--- a/src/services/storage/storageTelemetry.ts
+++ b/src/services/storage/storageTelemetry.ts
@@ -1,3 +1,5 @@
+import { captureAnalyticsEvent } from '@/services/analytics/analytics';
+
export type StorageTelemetrySeverity = 'info' | 'warning' | 'error';
export interface StorageTelemetryEvent {
@@ -16,10 +18,19 @@ export function setStorageTelemetryHandler(handler: StorageTelemetryHandler | nu
}
export function reportStorageTelemetry(event: StorageTelemetryEvent): void {
- if (!telemetryHandler) return;
- try {
- telemetryHandler(event);
- } catch {
- // Telemetry is non-critical and must never break storage behavior.
+ if (telemetryHandler) {
+ try {
+ telemetryHandler(event);
+ } catch {
+ // Telemetry is non-critical and must never break storage behavior.
+ }
+ }
+
+ if (event.severity !== 'info') {
+ captureAnalyticsEvent('storage_issue_reported', {
+ area: event.area,
+ code: event.code,
+ severity: event.severity,
+ });
}
}
diff --git a/src/store.ts b/src/store.ts
index 5cdcb25b..cd618b69 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -6,7 +6,9 @@ import { createDesignSystemActions } from './store/actions/createDesignSystemAct
import { createHistoryActions } from './store/actions/createHistoryActions';
import { createLayerActions } from './store/actions/createLayerActions';
import { createTabActions } from './store/actions/createTabActions';
+import { createWorkspaceDocumentActions } from './store/actions/createWorkspaceDocumentActions';
import { createViewActions } from './store/actions/createViewActions';
+import { installWorkspaceDocumentSync } from './store/documentStateSync';
import {
DEFAULT_AI_SETTINGS,
DEFAULT_DESIGN_SYSTEM,
@@ -38,6 +40,7 @@ export const useFlowStore = create()(
...createInitialFlowState(),
...createCanvasActions(set, get),
...createHistoryActions(set, get),
+ ...createWorkspaceDocumentActions(set, get),
...createTabActions(set, get),
...createDesignSystemActions(set),
...createViewActions(set),
@@ -53,3 +56,5 @@ export const useFlowStore = create()(
}
)
);
+
+installWorkspaceDocumentSync(useFlowStore);
diff --git a/src/store/actions/createTabActions.ts b/src/store/actions/createTabActions.ts
index 9442d0c4..88846400 100644
--- a/src/store/actions/createTabActions.ts
+++ b/src/store/actions/createTabActions.ts
@@ -9,7 +9,7 @@ type GetFlowState = () => FlowState;
export function createTabActions(set: SetFlowState, get: GetFlowState): Pick<
FlowState,
- 'setActiveTabId' | 'setTabs' | 'addTab' | 'duplicateActiveTab' | 'duplicateTab' | 'closeTab' | 'updateTab' | 'copySelectedToTab' | 'moveSelectedToTab'
+ 'setActiveTabId' | 'setTabs' | 'addTab' | 'duplicateActiveTab' | 'duplicateTab' | 'deleteTab' | 'closeTab' | 'updateTab' | 'copySelectedToTab' | 'moveSelectedToTab'
> {
function nowIso(): string {
return new Date().toISOString();
@@ -44,6 +44,19 @@ export function createTabActions(set: SetFlowState, get: GetFlowState): Pick<
};
}
+ function createEmptyTab(name = 'New Flow'): FlowTab {
+ return {
+ id: createId('tab'),
+ name,
+ diagramType: DEFAULT_DIAGRAM_TYPE,
+ updatedAt: nowIso(),
+ nodes: [],
+ edges: [],
+ playback: undefined,
+ history: { past: [], future: [] },
+ };
+ }
+
return {
setActiveTabId: (id) => {
const { tabs, nodes, edges } = get();
@@ -76,17 +89,8 @@ export function createTabActions(set: SetFlowState, get: GetFlowState): Pick<
tab.id === activeTabId ? { ...tab, nodes: get().nodes, edges: get().edges } : tab
);
- const newTabId = createId('tab');
- const newTab: FlowTab = {
- id: newTabId,
- name: 'New Flow',
- diagramType: DEFAULT_DIAGRAM_TYPE,
- updatedAt: nowIso(),
- nodes: [],
- edges: [],
- playback: undefined,
- history: { past: [], future: [] },
- };
+ const newTab = createEmptyTab();
+ const newTabId = newTab.id;
set({
tabs: [...updatedTabs, newTab],
@@ -144,9 +148,56 @@ export function createTabActions(set: SetFlowState, get: GetFlowState): Pick<
return newTabId;
},
+ deleteTab: (id) => {
+ const { tabs, activeTabId } = get();
+ const nextTabs = tabs.filter((tab) => tab.id !== id);
+
+ if (nextTabs.length === tabs.length) {
+ return;
+ }
+
+ if (nextTabs.length === 0) {
+ const fallbackTab = createEmptyTab('Page 1');
+ set({
+ tabs: [fallbackTab],
+ activeTabId: fallbackTab.id,
+ nodes: fallbackTab.nodes,
+ edges: fallbackTab.edges,
+ });
+ return;
+ }
+
+ if (id !== activeTabId) {
+ set({ tabs: nextTabs });
+ return;
+ }
+
+ const deletedIndex = tabs.findIndex((tab) => tab.id === id);
+ const nextActiveTab = nextTabs[deletedIndex] ?? nextTabs[deletedIndex - 1] ?? nextTabs[0];
+ if (!nextActiveTab) {
+ return;
+ }
+
+ set({
+ tabs: nextTabs,
+ activeTabId: nextActiveTab.id,
+ nodes: nextActiveTab.nodes,
+ edges: nextActiveTab.edges,
+ });
+ },
+
closeTab: (id) => {
const { tabs, activeTabId } = get();
- if (tabs.length === 1) return;
+ if (tabs.length === 1) {
+ const fallbackTab = createEmptyTab();
+ set({
+ tabs: [fallbackTab],
+ activeTabId: fallbackTab.id,
+ nodes: fallbackTab.nodes,
+ edges: fallbackTab.edges,
+ });
+ return;
+ }
let newActiveTabId = activeTabId;
diff --git a/src/store/actions/createWorkspaceDocumentActions.ts b/src/store/actions/createWorkspaceDocumentActions.ts
new file mode 100644
index 00000000..6fa67b71
--- /dev/null
+++ b/src/store/actions/createWorkspaceDocumentActions.ts
@@ -0,0 +1,238 @@
+import type { FlowTab } from '@/lib/types';
+import { createId } from '@/lib/id';
+import { DEFAULT_DIAGRAM_TYPE } from '@/services/diagramDocument';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
+import type { FlowState } from '../types';
+
+type SetFlowState = (partial: Partial | ((state: FlowState) => Partial)) => void;
+type GetFlowState = () => FlowState;
+
+function nowIso(): string {
+ return new Date().toISOString();
+}
+
+function createEmptyPage(documentId: string, pageName = 'Page 1'): FlowTab {
+ return {
+ id: `${documentId}:page:${createId('page')}`,
+ name: pageName,
+ diagramType: DEFAULT_DIAGRAM_TYPE,
+ updatedAt: nowIso(),
+ nodes: [],
+ edges: [],
+ playback: undefined,
+ history: { past: [], future: [] },
+ };
+}
+
+function createEmptyDocument(name = 'Untitled Flow'): FlowDocument {
+ const documentId = createId('doc');
+ const primaryPage = createEmptyPage(documentId);
+ return {
+ id: documentId,
+ name,
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ activePageId: primaryPage.id,
+ pages: [primaryPage],
+ };
+}
+
+function toFlowTabPages(document: FlowDocument): FlowTab[] {
+ return document.pages.map((page) => ({
+ id: page.id,
+ name: page.name,
+ diagramType: page.diagramType,
+ updatedAt: page.updatedAt,
+ nodes: page.nodes,
+ edges: page.edges,
+ playback: page.playback,
+ history: page.history,
+ }));
+}
+
+export function createWorkspaceDocumentActions(set: SetFlowState, get: GetFlowState): Pick<
+ FlowState,
+ 'setDocuments' | 'setActiveDocumentId' | 'createDocument' | 'renameDocument' | 'duplicateDocument' | 'deleteDocumentRecord'
+> {
+ return {
+ setDocuments: (documents) => set({ documents }),
+ setActiveDocumentId: (id) => {
+ const { documents } = get();
+ const document = documents.find((entry) => entry.id === id);
+ if (!document) {
+ return;
+ }
+
+ const pages = toFlowTabPages(document);
+ const activePage = pages.find((page) => page.id === document.activePageId) ?? pages[0];
+ if (!activePage) {
+ return;
+ }
+
+ set({
+ activeDocumentId: document.id,
+ tabs: pages,
+ activeTabId: activePage.id,
+ nodes: activePage.nodes,
+ edges: activePage.edges,
+ });
+ },
+ createDocument: () => {
+ const document = createEmptyDocument();
+ const pages = toFlowTabPages(document);
+ const activePage = pages[0];
+
+ set((state) => ({
+ documents: [...state.documents, document],
+ activeDocumentId: document.id,
+ tabs: pages,
+ activeTabId: activePage.id,
+ nodes: activePage.nodes,
+ edges: activePage.edges,
+ }));
+
+ return document.id;
+ },
+ renameDocument: (id, nextName) => {
+ const trimmedName = nextName.trim();
+ if (!trimmedName) {
+ return;
+ }
+
+ set((state) => {
+ const target = state.documents.find((document) => document.id === id);
+ if (!target || target.name === trimmedName) {
+ return {};
+ }
+
+ const shouldMirrorToSinglePage = target.pages.length === 1 && target.pages[0]?.name === target.name;
+ const documents = state.documents.map((document) =>
+ document.id === id
+ ? {
+ ...document,
+ name: trimmedName,
+ updatedAt: nowIso(),
+ pages: shouldMirrorToSinglePage
+ ? document.pages.map((page) => ({ ...page, name: trimmedName }))
+ : document.pages,
+ }
+ : document,
+ );
+
+ if (state.activeDocumentId !== id || !shouldMirrorToSinglePage) {
+ return { documents };
+ }
+
+ const tabs = state.tabs.map((page) => ({ ...page, name: trimmedName }));
+ const activePage = tabs.find((page) => page.id === state.activeTabId) ?? tabs[0];
+
+ return {
+ documents,
+ tabs,
+ activeTabId: activePage?.id ?? state.activeTabId,
+ nodes: activePage?.nodes ?? state.nodes,
+ edges: activePage?.edges ?? state.edges,
+ };
+ });
+ },
+ duplicateDocument: (id) => {
+ const { documents } = get();
+ const source = documents.find((document) => document.id === id);
+ if (!source) {
+ return null;
+ }
+
+ const documentId = createId('doc');
+ let activePageId = '';
+ const duplicated: FlowDocument = {
+ ...source,
+ id: documentId,
+ name: `${source.name} Copy`,
+ createdAt: nowIso(),
+ updatedAt: nowIso(),
+ activePageId,
+ pages: source.pages.map((page, index) => {
+ const nextPageId = `${documentId}:page:${createId('page')}`;
+ if (index === 0) {
+ activePageId = nextPageId;
+ }
+ return {
+ ...page,
+ id: nextPageId,
+ updatedAt: nowIso(),
+ nodes: page.nodes.map((node) => ({
+ ...node,
+ selected: false,
+ data: { ...node.data },
+ position: { ...node.position },
+ style: node.style ? { ...node.style } : node.style,
+ })),
+ edges: page.edges.map((edge) => ({
+ ...edge,
+ selected: false,
+ data: edge.data ? { ...edge.data } : edge.data,
+ style: edge.style ? { ...edge.style } : edge.style,
+ })),
+ history: { past: [], future: [] },
+ };
+ }),
+ };
+ duplicated.activePageId = activePageId || duplicated.pages[0]?.id || '';
+
+ const pages = toFlowTabPages(duplicated);
+ const activePage = pages[0];
+
+ set((state) => ({
+ documents: [...state.documents, duplicated],
+ activeDocumentId: duplicated.id,
+ tabs: pages,
+ activeTabId: activePage.id,
+ nodes: activePage.nodes,
+ edges: activePage.edges,
+ }));
+
+ return duplicated.id;
+ },
+ deleteDocumentRecord: (id) => {
+ const { documents, activeDocumentId } = get();
+ const remainingDocuments = documents.filter((document) => document.id !== id);
+
+ if (remainingDocuments.length === documents.length) {
+ return;
+ }
+
+ if (remainingDocuments.length === 0) {
+ set({
+ documents: [],
+ activeDocumentId: '',
+ tabs: [],
+ activeTabId: '',
+ nodes: [],
+ edges: [],
+ });
+ return;
+ }
+
+ if (id !== activeDocumentId) {
+ set({ documents: remainingDocuments });
+ return;
+ }
+
+ const nextDocument = remainingDocuments[0];
+ const pages = toFlowTabPages(nextDocument);
+ const activePage = pages.find((page) => page.id === nextDocument.activePageId) ?? pages[0];
+ if (!activePage) {
+ return;
+ }
+
+ set({
+ documents: remainingDocuments,
+ activeDocumentId: nextDocument.id,
+ tabs: pages,
+ activeTabId: activePage.id,
+ nodes: activePage.nodes,
+ edges: activePage.edges,
+ });
+ },
+ };
+}
diff --git a/src/store/documentHooks.ts b/src/store/documentHooks.ts
new file mode 100644
index 00000000..1c9d27c0
--- /dev/null
+++ b/src/store/documentHooks.ts
@@ -0,0 +1,84 @@
+import { useMemo } from 'react';
+import { useShallow } from 'zustand/react/shallow';
+import { useFlowStore } from '../store';
+import type { FlowStoreState } from '../store';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
+import {
+ createWorkspaceDocumentsFromTabs,
+ findDocumentRouteTarget,
+ type WorkspaceDocumentSummary,
+} from './workspaceDocumentModel';
+
+export function useWorkspaceDocumentsState(): {
+ documents: WorkspaceDocumentSummary[];
+ activeDocumentId: string;
+} {
+ const { documents, activeDocumentId, tabs, activeTabId, nodes, edges } = useFlowStore(
+ useShallow((state) => ({
+ documents: state.documents,
+ activeDocumentId: state.activeDocumentId,
+ tabs: state.tabs,
+ activeTabId: state.activeTabId,
+ nodes: state.nodes,
+ edges: state.edges,
+ })),
+ );
+
+ const summaries = useMemo(
+ () => createWorkspaceDocumentsFromTabs({
+ documents,
+ activeDocumentId,
+ activeNodes: nodes,
+ activeEdges: edges,
+ activePages: tabs,
+ activePageId: activeTabId,
+ }),
+ [documents, activeDocumentId, tabs, activeTabId, nodes, edges],
+ );
+
+ return {
+ documents: summaries,
+ activeDocumentId,
+ };
+}
+
+export function useWorkspaceDocumentActions(): {
+ setActiveDocumentId: FlowStoreState['setActiveDocumentId'];
+ setDocuments: FlowStoreState['setDocuments'];
+ createDocument: FlowStoreState['createDocument'];
+ renameDocument: FlowStoreState['renameDocument'];
+ duplicateDocument: FlowStoreState['duplicateDocument'];
+ deleteDocument: FlowStoreState['deleteDocumentRecord'];
+} {
+ const actions = useFlowStore(
+ useShallow((state) => ({
+ setActiveDocumentId: state.setActiveDocumentId,
+ setDocuments: state.setDocuments,
+ createDocument: state.createDocument,
+ renameDocument: state.renameDocument,
+ duplicateDocument: state.duplicateDocument,
+ deleteDocument: state.deleteDocumentRecord,
+ })),
+ );
+
+ return {
+ setActiveDocumentId: actions.setActiveDocumentId,
+ setDocuments: actions.setDocuments,
+ createDocument: actions.createDocument,
+ renameDocument: actions.renameDocument,
+ duplicateDocument: actions.duplicateDocument,
+ deleteDocument: actions.deleteDocument,
+ };
+}
+
+export function useWorkspaceRouteResolver(): {
+ documents: FlowDocument[];
+ resolveTarget: (targetId: string) => { documentId: string; pageId: string } | null;
+} {
+ const documents = useFlowStore((state) => state.documents);
+
+ return {
+ documents,
+ resolveTarget: (targetId: string) => findDocumentRouteTarget(documents, targetId),
+ };
+}
diff --git a/src/store/documentStateSync.test.ts b/src/store/documentStateSync.test.ts
new file mode 100644
index 00000000..5a542a4f
--- /dev/null
+++ b/src/store/documentStateSync.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest';
+import type { FlowTab } from '@/lib/types';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
+import { syncWorkspaceDocuments } from './documentStateSync';
+
+function createPage(overrides: Partial = {}): FlowTab {
+ return {
+ id: 'page-1',
+ name: 'Overview',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ playback: undefined,
+ ...overrides,
+ };
+}
+
+function createDocument(pages: FlowTab[]): FlowDocument {
+ return {
+ id: 'doc-1',
+ name: 'System Design',
+ createdAt: '2026-03-27T00:00:00.000Z',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ activePageId: pages[0]?.id ?? '',
+ pages,
+ };
+}
+
+describe('documentStateSync', () => {
+ it('keeps the active document pages aligned with live editor state', () => {
+ const storedPage = createPage({
+ nodes: [{ id: 'node-stored' } as never],
+ });
+ const livePage = createPage({
+ nodes: [{ id: 'node-live' } as never],
+ edges: [{ id: 'edge-live' } as never],
+ });
+
+ const documents = syncWorkspaceDocuments({
+ documents: [createDocument([storedPage])],
+ activeDocumentId: 'doc-1',
+ tabs: [livePage],
+ activeTabId: 'page-1',
+ nodes: livePage.nodes,
+ edges: livePage.edges,
+ });
+
+ expect(documents[0]?.pages[0]?.nodes).toEqual(livePage.nodes);
+ expect(documents[0]?.pages[0]?.edges).toEqual(livePage.edges);
+ });
+
+ it('leaves documents unchanged when there is no active document', () => {
+ const documents = [createDocument([createPage()])];
+
+ expect(syncWorkspaceDocuments({
+ documents,
+ activeDocumentId: '',
+ tabs: [createPage()],
+ activeTabId: 'page-1',
+ nodes: [],
+ edges: [],
+ })).toBe(documents);
+ });
+});
diff --git a/src/store/documentStateSync.ts b/src/store/documentStateSync.ts
new file mode 100644
index 00000000..72d173a4
--- /dev/null
+++ b/src/store/documentStateSync.ts
@@ -0,0 +1,48 @@
+import type { FlowState } from './types';
+import { mergeActivePagesIntoDocuments } from './workspaceDocumentModel';
+
+type DocumentSyncState = Pick<
+ FlowState,
+ 'documents' | 'activeDocumentId' | 'tabs' | 'activeTabId' | 'nodes' | 'edges'
+>;
+
+export function syncWorkspaceDocuments(state: DocumentSyncState): FlowState['documents'] {
+ if (!state.activeDocumentId || state.documents.length === 0) {
+ return state.documents;
+ }
+
+ return mergeActivePagesIntoDocuments({
+ documents: state.documents,
+ activeDocumentId: state.activeDocumentId,
+ activePages: state.tabs,
+ activePageId: state.activeTabId,
+ activeNodes: state.nodes,
+ activeEdges: state.edges,
+ });
+}
+
+export function installWorkspaceDocumentSync(store: {
+ subscribe: (listener: (state: FlowState, previousState: FlowState) => void) => () => void;
+ getState: () => FlowState;
+ setState: (partial: Partial) => void;
+}): () => void {
+ return store.subscribe((state, previousState) => {
+ const activeEditorChanged =
+ state.activeDocumentId !== previousState.activeDocumentId ||
+ state.tabs !== previousState.tabs ||
+ state.activeTabId !== previousState.activeTabId ||
+ state.nodes !== previousState.nodes ||
+ state.edges !== previousState.edges;
+
+ if (!activeEditorChanged) {
+ return;
+ }
+
+ const documents = syncWorkspaceDocuments(state);
+ if (documents === state.documents) {
+ return;
+ }
+
+ store.setState({ documents });
+ });
+}
diff --git a/src/store/editorPageHooks.ts b/src/store/editorPageHooks.ts
new file mode 100644
index 00000000..8d62483a
--- /dev/null
+++ b/src/store/editorPageHooks.ts
@@ -0,0 +1,54 @@
+import { useShallow } from 'zustand/react/shallow';
+import { useFlowStore } from '../store';
+import type { FlowStoreState } from '../store';
+import type { FlowTab } from '@/lib/types';
+
+export type EditorPage = FlowTab;
+
+export function useEditorPagesState(): {
+ pages: EditorPage[];
+ activePageId: string;
+} {
+ return useFlowStore(
+ useShallow((state) => ({
+ pages: state.tabs,
+ activePageId: state.activeTabId,
+ })),
+ );
+}
+
+export function useActiveEditorPageId(): string {
+ return useFlowStore((state) => state.activeTabId);
+}
+
+export function useEditorPageActions(): {
+ setActivePageId: FlowStoreState['setActiveTabId'];
+ setPages: FlowStoreState['setTabs'];
+ addPage: FlowStoreState['addTab'];
+ duplicateActivePage: FlowStoreState['duplicateActiveTab'];
+ duplicatePage: FlowStoreState['duplicateTab'];
+ deletePage: FlowStoreState['deleteTab'];
+ closePage: FlowStoreState['closeTab'];
+ updatePage: FlowStoreState['updateTab'];
+ copySelectedToPage: FlowStoreState['copySelectedToTab'];
+ moveSelectedToPage: FlowStoreState['moveSelectedToTab'];
+} {
+ return useFlowStore(
+ useShallow((state) => ({
+ setActivePageId: state.setActiveTabId,
+ setPages: state.setTabs,
+ addPage: state.addTab,
+ duplicateActivePage: state.duplicateActiveTab,
+ duplicatePage: state.duplicateTab,
+ deletePage: state.deleteTab,
+ closePage: state.closeTab,
+ updatePage: state.updateTab,
+ copySelectedToPage: state.copySelectedToTab,
+ moveSelectedToPage: state.moveSelectedToTab,
+ })),
+ );
+}
+
+export function useEditorPageById(pageId: string): EditorPage | undefined {
+ return useFlowStore((state) => state.tabs.find((tab) => tab.id === pageId));
+}
diff --git a/src/store/persistence.ts b/src/store/persistence.ts
index 1818b99b..e143c5b9 100644
--- a/src/store/persistence.ts
+++ b/src/store/persistence.ts
@@ -1,5 +1,6 @@
import { INITIAL_EDGES, INITIAL_NODES } from '@/constants';
import { DEFAULT_DIAGRAM_TYPE } from '@/services/diagramDocument';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
import { clonePlaybackState, sanitizePlaybackState } from '@/services/playback/model';
import type { FlowTab } from '@/lib/types';
import { isDiagramType } from '@/lib/types';
@@ -36,6 +37,18 @@ function createFallbackTab(): FlowTab {
};
}
+function createFallbackDocument(): FlowDocument {
+ const fallbackTab = createFallbackTab();
+ return {
+ id: 'doc-1',
+ name: fallbackTab.name,
+ createdAt: fallbackTab.updatedAt ?? new Date().toISOString(),
+ updatedAt: fallbackTab.updatedAt ?? new Date().toISOString(),
+ activePageId: fallbackTab.id,
+ pages: [fallbackTab],
+ };
+}
+
export function sanitizePersistedNode(node: FlowTab['nodes'][number]): FlowTab['nodes'][number] {
const {
selected: _selected,
@@ -156,6 +169,11 @@ export function migratePersistedFlowState(persistedState: unknown): unknown {
return {
...state,
+ documents: Array.isArray(state.documents) ? state.documents : [createFallbackDocument()],
+ activeDocumentId:
+ typeof state.activeDocumentId === 'string'
+ ? state.activeDocumentId
+ : 'doc-1',
tabs,
activeTabId:
typeof state.activeTabId === 'string' && tabs.some((tab) => tab.id === state.activeTabId)
@@ -189,6 +207,8 @@ export function createInitialFlowState(): Pick<
FlowState,
| 'nodes'
| 'edges'
+ | 'documents'
+ | 'activeDocumentId'
| 'tabs'
| 'activeTabId'
| 'designSystems'
@@ -206,6 +226,8 @@ export function createInitialFlowState(): Pick<
return {
nodes: INITIAL_NODES,
edges: INITIAL_EDGES,
+ documents: [createFallbackDocument()],
+ activeDocumentId: 'doc-1',
tabs: [createFallbackTab()],
activeTabId: 'tab-1',
designSystems: [DEFAULT_DESIGN_SYSTEM],
diff --git a/src/store/tabHooks.ts b/src/store/tabHooks.ts
index 65e11a32..2d0c0b40 100644
--- a/src/store/tabHooks.ts
+++ b/src/store/tabHooks.ts
@@ -23,6 +23,7 @@ export function useTabActions(): Pick<
| 'addTab'
| 'duplicateActiveTab'
| 'duplicateTab'
+ | 'deleteTab'
| 'closeTab'
| 'updateTab'
| 'copySelectedToTab'
@@ -35,6 +36,7 @@ export function useTabActions(): Pick<
addTab: state.addTab,
duplicateActiveTab: state.duplicateActiveTab,
duplicateTab: state.duplicateTab,
+ deleteTab: state.deleteTab,
closeTab: state.closeTab,
updateTab: state.updateTab,
copySelectedToTab: state.copySelectedToTab,
diff --git a/src/store/types.ts b/src/store/types.ts
index b92c45b1..877540c9 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -8,6 +8,7 @@ import type {
import type { ParseDiagnostic } from '@/lib/openFlowDSLParser';
import type { DesignSystem, DiagramType, FlowEdge, FlowNode, FlowTab, GlobalEdgeOptions } from '@/lib/types';
import type { ExportSerializationMode } from '@/services/canonicalSerialization';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
export interface ViewSettings {
showGrid: boolean;
@@ -90,6 +91,14 @@ export interface FlowState {
// -------------------------------------------------------------------------
// SLICE: Tabs — multi-diagram workspace
// -------------------------------------------------------------------------
+ documents: FlowDocument[];
+ activeDocumentId: string;
+ setDocuments: (documents: FlowDocument[]) => void;
+ setActiveDocumentId: (id: string) => void;
+ createDocument: () => string;
+ renameDocument: (id: string, nextName: string) => void;
+ duplicateDocument: (id: string) => string | null;
+ deleteDocumentRecord: (id: string) => void;
tabs: FlowTab[];
activeTabId: string;
setActiveTabId: (id: string) => void;
@@ -97,6 +106,7 @@ export interface FlowState {
addTab: () => string;
duplicateActiveTab: () => string | null;
duplicateTab: (id: string) => string | null;
+ deleteTab: (id: string) => void;
closeTab: (id: string) => void;
updateTab: (id: string, updates: Partial) => void;
copySelectedToTab: (targetTabId: string) => number;
diff --git a/src/store/workspaceDocumentModel.test.ts b/src/store/workspaceDocumentModel.test.ts
new file mode 100644
index 00000000..f421099d
--- /dev/null
+++ b/src/store/workspaceDocumentModel.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+import type { FlowTab } from '@/lib/types';
+import { createWorkspaceDocumentsFromTabs } from './workspaceDocumentModel';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
+
+function createTab(overrides: Partial = {}): FlowTab {
+ return {
+ id: 'tab-1',
+ name: 'Document One',
+ diagramType: 'flowchart',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ nodes: [],
+ edges: [],
+ history: { past: [], future: [] },
+ playback: undefined,
+ ...overrides,
+ };
+}
+
+function createDocumentFromTab(tab: FlowTab): FlowDocument {
+ return {
+ id: tab.id,
+ name: tab.name,
+ createdAt: tab.updatedAt ?? '2026-03-27T00:00:00.000Z',
+ updatedAt: tab.updatedAt ?? '2026-03-27T00:00:00.000Z',
+ activePageId: tab.id,
+ pages: [tab],
+ };
+}
+
+describe('workspaceDocumentModel', () => {
+ it('builds document summaries from tabs', () => {
+ const documents = createWorkspaceDocumentsFromTabs({
+ documents: [createDocumentFromTab(createTab())],
+ activeDocumentId: 'tab-1',
+ activeNodes: [{ id: 'n1' } as never],
+ activeEdges: [],
+ activePages: [createTab()],
+ activePageId: 'tab-1',
+ });
+
+ expect(documents).toHaveLength(1);
+ expect(documents[0]).toMatchObject({
+ id: 'tab-1',
+ name: 'Document One',
+ nodeCount: 1,
+ edgeCount: 0,
+ isActive: true,
+ });
+ });
+
+ it('sorts the active document first, then by updated time', () => {
+ const documents = createWorkspaceDocumentsFromTabs({
+ documents: [
+ createDocumentFromTab(createTab({
+ id: 'tab-older',
+ name: 'Older',
+ updatedAt: '2026-03-26T00:00:00.000Z',
+ })),
+ createDocumentFromTab(createTab({
+ id: 'tab-active',
+ name: 'Active',
+ updatedAt: '2026-03-25T00:00:00.000Z',
+ })),
+ createDocumentFromTab(createTab({
+ id: 'tab-newer',
+ name: 'Newer',
+ updatedAt: '2026-03-27T00:00:00.000Z',
+ })),
+ ],
+ activeDocumentId: 'tab-active',
+ activeNodes: [],
+ activeEdges: [],
+ activePages: [createTab({ id: 'tab-active', name: 'Active', updatedAt: '2026-03-25T00:00:00.000Z' })],
+ activePageId: 'tab-active',
+ });
+
+ expect(documents.map((document) => document.id)).toEqual([
+ 'tab-active',
+ 'tab-newer',
+ 'tab-older',
+ ]);
+ });
+});
diff --git a/src/store/workspaceDocumentModel.ts b/src/store/workspaceDocumentModel.ts
new file mode 100644
index 00000000..e7630b0b
--- /dev/null
+++ b/src/store/workspaceDocumentModel.ts
@@ -0,0 +1,186 @@
+import type { FlowEdge, FlowNode, FlowTab } from '@/lib/types';
+import type { FlowDocument } from '@/services/storage/flowDocumentModel';
+
+export interface WorkspaceDocumentSummary {
+ id: string;
+ name: string;
+ updatedAt?: string;
+ nodeCount: number;
+ edgeCount: number;
+ isActive: boolean;
+}
+
+export interface CreateWorkspaceDocumentsParams {
+ documents: FlowDocument[];
+ activeDocumentId: string;
+ activeNodes: FlowNode[];
+ activeEdges: FlowEdge[];
+ activePages: FlowTab[];
+ activePageId: string;
+}
+
+export function syncActivePageTabs(
+ pages: FlowTab[],
+ activePageId: string,
+ activeNodes: FlowNode[],
+ activeEdges: FlowEdge[],
+): FlowTab[] {
+ return pages.map((page) =>
+ page.id === activePageId
+ ? {
+ ...page,
+ nodes: activeNodes,
+ edges: activeEdges,
+ }
+ : page,
+ );
+}
+
+export function mergeActivePagesIntoDocuments(params: {
+ documents: FlowDocument[];
+ activeDocumentId: string;
+ activePages: FlowTab[];
+ activePageId: string;
+ activeNodes: FlowNode[];
+ activeEdges: FlowEdge[];
+}): FlowDocument[] {
+ const { documents, activeDocumentId, activePages, activePageId, activeNodes, activeEdges } = params;
+ const syncedPages = syncActivePageTabs(activePages, activePageId, activeNodes, activeEdges);
+
+ return documents.map((document) => {
+ if (document.id !== activeDocumentId) {
+ return document;
+ }
+
+ return {
+ ...document,
+ activePageId,
+ pages: syncedPages.map((page) => ({
+ id: page.id,
+ name: page.name,
+ diagramType: page.diagramType,
+ updatedAt: page.updatedAt,
+ nodes: page.nodes,
+ edges: page.edges,
+ playback: page.playback,
+ history: page.history,
+ })),
+ };
+ });
+}
+
+export function createWorkspaceDocumentSummary(
+ document: FlowDocument,
+ activeDocumentId: string,
+ activeNodes: FlowNode[],
+ activeEdges: FlowEdge[],
+ activePages: FlowTab[],
+ activePageId: string,
+): WorkspaceDocumentSummary {
+ const isActive = document.id === activeDocumentId;
+ const resolvedDocument = isActive
+ ? mergeActivePagesIntoDocuments({
+ documents: [document],
+ activeDocumentId: document.id,
+ activePages,
+ activePageId,
+ activeNodes,
+ activeEdges,
+ })[0]
+ : document;
+ const nodeCount = resolvedDocument.pages.reduce((sum, page) => sum + page.nodes.length, 0);
+ const edgeCount = resolvedDocument.pages.reduce((sum, page) => sum + page.edges.length, 0);
+
+ return {
+ id: resolvedDocument.id,
+ name: resolvedDocument.name,
+ updatedAt: resolvedDocument.updatedAt,
+ nodeCount,
+ edgeCount,
+ isActive,
+ };
+}
+
+export function sortWorkspaceDocuments(documents: WorkspaceDocumentSummary[]): WorkspaceDocumentSummary[] {
+ return [...documents].sort((left, right) => {
+ if (left.isActive && !right.isActive) return -1;
+ if (!left.isActive && right.isActive) return 1;
+ const leftTime = Date.parse(left.updatedAt || '');
+ const rightTime = Date.parse(right.updatedAt || '');
+ return (Number.isNaN(rightTime) ? 0 : rightTime) - (Number.isNaN(leftTime) ? 0 : leftTime);
+ });
+}
+
+export function createWorkspaceDocumentsFromTabs({
+ documents,
+ activeDocumentId,
+ activeNodes,
+ activeEdges,
+ activePages,
+ activePageId,
+}: CreateWorkspaceDocumentsParams): WorkspaceDocumentSummary[] {
+ return sortWorkspaceDocuments(
+ documents.map((document) => createWorkspaceDocumentSummary(
+ document,
+ activeDocumentId,
+ activeNodes,
+ activeEdges,
+ activePages,
+ activePageId,
+ )),
+ );
+}
+
+export function findDocumentRouteTarget(documents: FlowDocument[], targetId: string): {
+ documentId: string;
+ pageId: string;
+} | null {
+ for (const document of documents) {
+ if (document.id === targetId) {
+ return {
+ documentId: document.id,
+ pageId: document.activePageId,
+ };
+ }
+
+ const matchingPage = document.pages.find((page) => page.id === targetId);
+ if (matchingPage) {
+ return {
+ documentId: document.id,
+ pageId: matchingPage.id,
+ };
+ }
+ }
+
+ return null;
+}
+
+export function getEditorPagesForDocument(documents: FlowDocument[], documentId: string | null): {
+ activeDocumentId: string;
+ activePageId: string;
+ pages: FlowTab[];
+} | null {
+ if (!documentId) {
+ return null;
+ }
+
+ const document = documents.find((entry) => entry.id === documentId);
+ if (!document || document.pages.length === 0) {
+ return null;
+ }
+
+ return {
+ activeDocumentId: document.id,
+ activePageId: document.activePageId,
+ pages: document.pages.map((page) => ({
+ id: page.id,
+ name: page.name,
+ diagramType: page.diagramType,
+ updatedAt: page.updatedAt,
+ nodes: page.nodes,
+ edges: page.edges,
+ playback: page.playback,
+ history: page.history,
+ })),
+ };
+}
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 4e84e22b..186eff7f 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/navigationcontrols.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/privacysettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodetextstylesection.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/devperformance.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/genericshapepolicy.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaptree.ts","./src/lib/nodehandles.ts","./src/lib/nodeparent.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/types.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/canvashooks.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/historyhooks.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/selectionhooks.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts"],"version":"5.8.3"}
\ No newline at end of file
+{"root":["./src/app.tsx","./src/constants.test.ts","./src/constants.ts","./src/index.tsx","./src/store.test.ts","./src/store.ts","./src/theme.ts","./src/app/routestate.test.ts","./src/app/routestate.ts","./src/components/annotationnode.tsx","./src/components/commandbar.test.tsx","./src/components/commandbar.tsx","./src/components/connectmenu.test.tsx","./src/components/connectmenu.tsx","./src/components/contextmenu.tsx","./src/components/customconnectionline.tsx","./src/components/customedge.tsx","./src/components/customnode.handleinteraction.test.tsx","./src/components/customnode.tsx","./src/components/designsystem.integration.test.tsx","./src/components/diagramviewer.tsx","./src/components/errorboundary.tsx","./src/components/exportmenu.tsx","./src/components/exportmenupanel.test.tsx","./src/components/exportmenupanel.tsx","./src/components/flowcanvas.tsx","./src/components/floweditor.tsx","./src/components/floweditoremptystate.tsx","./src/components/floweditorlayoutoverlay.tsx","./src/components/floweditorpanels.test.tsx","./src/components/floweditorpanels.tsx","./src/components/flowtabs.tsx","./src/components/groupnode.tsx","./src/components/homepage.integration.test.tsx","./src/components/homepage.tsx","./src/components/iconmap.test.tsx","./src/components/iconmap.ts","./src/components/imagenode.tsx","./src/components/inlinetexteditsurface.test.tsx","./src/components/inlinetexteditsurface.tsx","./src/components/keyboardshortcutsmodal.tsx","./src/components/languageselector.tsx","./src/components/markdownrenderer.tsx","./src/components/memoizedmarkdown.tsx","./src/components/navigationcontrols.tsx","./src/components/nodechrome.tsx","./src/components/nodequickcreatebuttons.tsx","./src/components/nodetransformcontrols.tsx","./src/components/playbackcontrols.tsx","./src/components/propertiespanel.tsx","./src/components/rightrail.tsx","./src/components/sectionnode.tsx","./src/components/shareembedmodal.tsx","./src/components/sharemodal.test.tsx","./src/components/sharemodal.tsx","./src/components/sidebarshell.tsx","./src/components/snapshotspanel.tsx","./src/components/studioaipanel.test.tsx","./src/components/studioaipanel.tsx","./src/components/studiocodepanel.test.tsx","./src/components/studiocodepanel.tsx","./src/components/studiopanel.test.tsx","./src/components/studiopanel.tsx","./src/components/studioplaybackpanel.test.tsx","./src/components/studioplaybackpanel.tsx","./src/components/swimlanenode.tsx","./src/components/textnode.tsx","./src/components/toolbar.tsx","./src/components/tooltip.tsx","./src/components/topnav.tsx","./src/components/welcomemodal.tsx","./src/components/annotationtheme.ts","./src/components/container-nodes.handleinteraction.test.tsx","./src/components/editorsurfacetiers.ts","./src/components/handleinteraction.test.ts","./src/components/handleinteraction.ts","./src/components/handleinteractionusage.test.ts","./src/components/lightweight-nodes.handleinteraction.test.tsx","./src/components/markdownsyntax.ts","./src/components/sharemodalcontent.ts","./src/components/studioaicopy.ts","./src/components/transformdiagnostics.test.ts","./src/components/transformdiagnostics.ts","./src/components/useactivenodeselection.ts","./src/components/useexportmenu.ts","./src/components/settingsmodal/aisettings.tsx","./src/components/settingsmodal/generalsettings.tsx","./src/components/settingsmodal/settingsmodal.test.tsx","./src/components/settingsmodal/settingsmodal.tsx","./src/components/settingsmodal/shortcutssettings.tsx","./src/components/settingsmodal/ai/advancedendpointsection.tsx","./src/components/settingsmodal/ai/customheaderseditor.tsx","./src/components/settingsmodal/ai/privacysection.tsx","./src/components/settingsmodal/ai/providericon.tsx","./src/components/settingsmodal/ai/providersection.tsx","./src/components/settingsmodal/ai/step.tsx","./src/components/app/docssiteredirect.test.tsx","./src/components/app/docssiteredirect.tsx","./src/components/app/mobileworkspacegate.test.tsx","./src/components/app/mobileworkspacegate.tsx","./src/components/app/routeloadingfallback.test.tsx","./src/components/app/routeloadingfallback.tsx","./src/components/app/mobileworkspacegatecopy.ts","./src/components/app/routeloadingcopy.ts","./src/components/architecture-lint/lintrulespanel.tsx","./src/components/command-bar/assetsview.test.tsx","./src/components/command-bar/assetsview.tsx","./src/components/command-bar/designsystemeditor.tsx","./src/components/command-bar/designsystemview.tsx","./src/components/command-bar/figmaimportpanel.tsx","./src/components/command-bar/importview.tsx","./src/components/command-bar/layersview.tsx","./src/components/command-bar/layoutview.tsx","./src/components/command-bar/pagesview.tsx","./src/components/command-bar/rootview.test.tsx","./src/components/command-bar/rootview.tsx","./src/components/command-bar/searchview.tsx","./src/components/command-bar/templatesview.tsx","./src/components/command-bar/viewheader.tsx","./src/components/command-bar/visualsview.tsx","./src/components/command-bar/applycodechanges.ts","./src/components/command-bar/assetsviewconstants.ts","./src/components/command-bar/searchquery.test.ts","./src/components/command-bar/searchquery.ts","./src/components/command-bar/types.ts","./src/components/command-bar/useaiviewstate.test.tsx","./src/components/command-bar/useaiviewstate.ts","./src/components/command-bar/usecloudassetcatalog.ts","./src/components/command-bar/usecommandbarcommands.test.tsx","./src/components/command-bar/usecommandbarcommands.tsx","./src/components/custom-edge/customedgewrapper.tsx","./src/components/custom-edge/sequencemessageedge.test.tsx","./src/components/custom-edge/sequencemessageedge.tsx","./src/components/custom-edge/animatededgepresentation.test.ts","./src/components/custom-edge/animatededgepresentation.ts","./src/components/custom-edge/classrelationsemantics.test.ts","./src/components/custom-edge/classrelationsemantics.ts","./src/components/custom-edge/edgerendermode.ts","./src/components/custom-edge/pathutils.test.ts","./src/components/custom-edge/pathutils.ts","./src/components/custom-edge/pathutilsgeometry.ts","./src/components/custom-edge/pathutilssiblingrouting.ts","./src/components/custom-edge/pathutilstypes.ts","./src/components/custom-edge/relationroutingsemantics.test.ts","./src/components/custom-edge/relationroutingsemantics.ts","./src/components/custom-edge/standardedgemarkers.test.ts","./src/components/custom-edge/standardedgemarkers.ts","./src/components/custom-nodes/architecturenode.handleinteraction.test.tsx","./src/components/custom-nodes/architecturenode.tsx","./src/components/custom-nodes/browsernode.tsx","./src/components/custom-nodes/classentitynode.handleinteraction.test.tsx","./src/components/custom-nodes/classnode.tsx","./src/components/custom-nodes/entitynode.tsx","./src/components/custom-nodes/journeynode.tsx","./src/components/custom-nodes/mindmapnode.tsx","./src/components/custom-nodes/mobilenode.tsx","./src/components/custom-nodes/sequenceparticipantnode.tsx","./src/components/custom-nodes/structurednodehandles.tsx","./src/components/custom-nodes/visualnodes.handleinteraction.test.tsx","./src/components/custom-nodes/browservariantrenderer.tsx","./src/components/custom-nodes/mobilevariantrenderer.tsx","./src/components/custom-nodes/structuredlistnavigation.test.ts","./src/components/custom-nodes/structuredlistnavigation.ts","./src/components/diagram-diff/diffmodebanner.tsx","./src/components/flow-canvas/flowcanvasalignmentguidesoverlay.tsx","./src/components/flow-canvas/flowcanvasoverlays.tsx","./src/components/flow-canvas/alignmentguides.test.ts","./src/components/flow-canvas/alignmentguides.ts","./src/components/flow-canvas/flowcanvasreactflowcontracts.ts","./src/components/flow-canvas/flowcanvastypes.test.ts","./src/components/flow-canvas/flowcanvastypes.tsx","./src/components/flow-canvas/largegraphsafetymode.test.ts","./src/components/flow-canvas/largegraphsafetymode.ts","./src/components/flow-canvas/nodechromecoverage.test.ts","./src/components/flow-canvas/pastehelpers.test.ts","./src/components/flow-canvas/pastehelpers.ts","./src/components/flow-canvas/useflowcanvasalignmentguides.ts","./src/components/flow-canvas/useflowcanvasconnectionstate.ts","./src/components/flow-canvas/useflowcanvascontextactions.ts","./src/components/flow-canvas/useflowcanvasdragdrop.ts","./src/components/flow-canvas/useflowcanvasinteractionlod.ts","./src/components/flow-canvas/useflowcanvasmenus.ts","./src/components/flow-canvas/useflowcanvasmenusandactions.test.tsx","./src/components/flow-canvas/useflowcanvasmenusandactions.ts","./src/components/flow-canvas/useflowcanvasnodedraghandlers.ts","./src/components/flow-canvas/useflowcanvaspaste.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.test.ts","./src/components/flow-canvas/useflowcanvasreactflowconfig.ts","./src/components/flow-canvas/useflowcanvasselectiontools.test.tsx","./src/components/flow-canvas/useflowcanvasselectiontools.ts","./src/components/flow-canvas/useflowcanvasviewstate.test.ts","./src/components/flow-canvas/useflowcanvasviewstate.ts","./src/components/flow-canvas/useflowcanvaszoomlod.ts","./src/components/flow-editor/collaborationpresenceoverlay.test.tsx","./src/components/flow-editor/collaborationpresenceoverlay.tsx","./src/components/flow-editor/floweditorchrome.tsx","./src/components/flow-editor/buildfloweditorcontrollerparams.ts","./src/components/flow-editor/buildfloweditorscreencontrollerparams.ts","./src/components/flow-editor/floweditorchromeprops.ts","./src/components/flow-editor/infradslapply.test.ts","./src/components/flow-editor/infradslapply.ts","./src/components/flow-editor/panelprops.test.ts","./src/components/flow-editor/panelprops.ts","./src/components/flow-editor/shouldexitstudioonselection.test.ts","./src/components/flow-editor/shouldexitstudioonselection.ts","./src/components/flow-editor/usecollaborationnodepositions.ts","./src/components/flow-editor/usefloweditorcontroller.ts","./src/components/flow-editor/usefloweditorinteractionbindings.ts","./src/components/flow-editor/usefloweditorpanelactions.ts","./src/components/flow-editor/usefloweditorpanelprops.ts","./src/components/flow-editor/usefloweditorruntime.ts","./src/components/flow-editor/usefloweditorscreenbehavior.ts","./src/components/flow-editor/usefloweditorscreenmodel.ts","./src/components/flow-editor/usefloweditorscreenstate.ts","./src/components/flow-editor/usefloweditorshellcontroller.test.tsx","./src/components/flow-editor/usefloweditorshellcontroller.ts","./src/components/flow-editor/usefloweditorstudiocontroller.test.tsx","./src/components/flow-editor/usefloweditorstudiocontroller.ts","./src/components/flow-editor/useinfradslapply.ts","./src/components/home/homedashboard.tsx","./src/components/home/homeflowdialogs.tsx","./src/components/home/homesettingsview.tsx","./src/components/home/homesidebar.tsx","./src/components/home/welcomemodalstate.ts","./src/components/icons/assetsicon.tsx","./src/components/icons/openflowlogo.tsx","./src/components/infra-sync/infrasyncpanel.test.tsx","./src/components/infra-sync/infrasyncpanel.tsx","./src/components/journey/journeyscorecontrol.test.tsx","./src/components/journey/journeyscorecontrol.tsx","./src/components/properties/bulknodeproperties.tsx","./src/components/properties/colorpicker.test.tsx","./src/components/properties/colorpicker.tsx","./src/components/properties/customcolorpopover.tsx","./src/components/properties/diagramnodepropertiesrouter.test.ts","./src/components/properties/diagramnodepropertiesrouter.tsx","./src/components/properties/edgeproperties.tsx","./src/components/properties/iconpicker.tsx","./src/components/properties/icontilepickerprimitives.tsx","./src/components/properties/imageupload.tsx","./src/components/properties/inspectorprimitives.tsx","./src/components/properties/nodeactionbuttons.tsx","./src/components/properties/nodecontentsection.tsx","./src/components/properties/nodeimagesettingssection.tsx","./src/components/properties/nodeproperties.tsx","./src/components/properties/nodetextstylesection.tsx","./src/components/properties/nodewireframevariantsection.tsx","./src/components/properties/propertysliderrow.tsx","./src/components/properties/segmentedchoice.tsx","./src/components/properties/shapeselector.tsx","./src/components/properties/swatchpicker.tsx","./src/components/properties/colorpickerutils.ts","./src/components/properties/propertyinputbehavior.test.ts","./src/components/properties/propertyinputbehavior.ts","./src/components/properties/wireframevariants.ts","./src/components/properties/edge/architectureedgesemanticssection.tsx","./src/components/properties/edge/edgecolorsection.tsx","./src/components/properties/edge/edgeconditionsection.tsx","./src/components/properties/edge/edgelabelsection.tsx","./src/components/properties/edge/edgerelationsection.test.tsx","./src/components/properties/edge/edgerelationsection.tsx","./src/components/properties/edge/edgeroutesection.test.tsx","./src/components/properties/edge/edgeroutesection.tsx","./src/components/properties/edge/edgestylesection.tsx","./src/components/properties/edge/sequencemessagesection.tsx","./src/components/properties/edge/architecturesemantics.test.ts","./src/components/properties/edge/architecturesemantics.ts","./src/components/properties/edge/edgelabelmodel.test.ts","./src/components/properties/edge/edgelabelmodel.ts","./src/components/properties/edge/errelationoptions.ts","./src/components/properties/families/architecturenodeproperties.test.tsx","./src/components/properties/families/architecturenodeproperties.tsx","./src/components/properties/families/architecturenodesection.tsx","./src/components/properties/families/classdiagramnodeproperties.tsx","./src/components/properties/families/classmemberlisteditor.tsx","./src/components/properties/families/classnodesection.tsx","./src/components/properties/families/erdiagramnodeproperties.tsx","./src/components/properties/families/entityfieldlisteditor.tsx","./src/components/properties/families/entitynodesection.tsx","./src/components/properties/families/journeynodeproperties.tsx","./src/components/properties/families/journeynodesection.tsx","./src/components/properties/families/mindmapnodeproperties.test.tsx","./src/components/properties/families/mindmapnodeproperties.tsx","./src/components/properties/families/sequencenodeproperties.tsx","./src/components/properties/families/sequencenodesection.tsx","./src/components/properties/families/structuredtextlisteditor.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.test.tsx","./src/components/studio-code-panel/usestudiocodepanelcontroller.ts","./src/components/toolbar/toolbaraddmenu.tsx","./src/components/toolbar/toolbaraddmenupanel.tsx","./src/components/toolbar/toolbarhistorycontrols.tsx","./src/components/toolbar/toolbarmodecontrols.tsx","./src/components/toolbar/toolbarbuttonstyles.ts","./src/components/top-nav/topnavactions.tsx","./src/components/top-nav/topnavbrand.tsx","./src/components/top-nav/topnavmenu.test.tsx","./src/components/top-nav/topnavmenu.tsx","./src/components/top-nav/topnavmenupanel.tsx","./src/components/top-nav/usetopnavstate.ts","./src/components/ui/button.tsx","./src/components/ui/collapsiblesection.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/searchfield.tsx","./src/components/ui/segmentedtabs.tsx","./src/components/ui/select.test.tsx","./src/components/ui/select.tsx","./src/components/ui/sidebaritem.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toastcontext.tsx","./src/components/ui/editorfieldstyles.ts","./src/config/aiproviders.test.ts","./src/config/aiproviders.ts","./src/config/rolloutflags.ts","./src/context/architecturelintcontext.tsx","./src/context/cinematicexportcontext.tsx","./src/context/diagramdiffcontext.tsx","./src/diagram-types/registerbuiltinplugins.test.ts","./src/diagram-types/registerbuiltinplugins.ts","./src/diagram-types/registerbuiltinpropertypanels.test.ts","./src/diagram-types/registerbuiltinpropertypanels.ts","./src/diagram-types/architecture/fuzzcorpus.test.ts","./src/diagram-types/architecture/plugin.test.ts","./src/diagram-types/architecture/plugin.ts","./src/diagram-types/classdiagram/fuzzcorpus.test.ts","./src/diagram-types/classdiagram/plugin.test.ts","./src/diagram-types/classdiagram/plugin.ts","./src/diagram-types/core/contracts.ts","./src/diagram-types/core/index.ts","./src/diagram-types/core/propertypanels.test.ts","./src/diagram-types/core/propertypanels.ts","./src/diagram-types/core/registry.ts","./src/diagram-types/erdiagram/fuzzcorpus.test.ts","./src/diagram-types/erdiagram/plugin.test.ts","./src/diagram-types/erdiagram/plugin.ts","./src/diagram-types/flowchart/plugin.ts","./src/diagram-types/journey/fuzzcorpus.test.ts","./src/diagram-types/journey/plugin.test.ts","./src/diagram-types/journey/plugin.ts","./src/diagram-types/mindmap/fuzzcorpus.test.ts","./src/diagram-types/mindmap/plugin.test.ts","./src/diagram-types/mindmap/plugin.ts","./src/diagram-types/sequence/plugin.test.ts","./src/diagram-types/sequence/plugin.ts","./src/diagram-types/statediagram/plugin.test.ts","./src/diagram-types/statediagram/plugin.ts","./src/docs/docsroutes.ts","./src/docs/publicdocscatalog.js","./src/hooks/edgeconnectinteractions.test.ts","./src/hooks/edgeconnectinteractions.ts","./src/hooks/flowexportviewport.test.ts","./src/hooks/flowexportviewport.ts","./src/hooks/mindmaptopicactionrequest.ts","./src/hooks/nodelabeleditrequest.test.ts","./src/hooks/nodelabeleditrequest.ts","./src/hooks/nodequickcreaterequest.test.ts","./src/hooks/nodequickcreaterequest.ts","./src/hooks/snapshotpolicy.test.ts","./src/hooks/snapshotpolicy.ts","./src/hooks/useaigeneration.ts","./src/hooks/useanalyticspreference.ts","./src/hooks/useanimatededgeperformancewarning.test.ts","./src/hooks/useanimatededgeperformancewarning.ts","./src/hooks/useclipboardoperations.ts","./src/hooks/usedesignsystem.ts","./src/hooks/useedgeinteractions.ts","./src/hooks/useedgeoperations.ts","./src/hooks/usefloweditoractions.test.ts","./src/hooks/usefloweditoractions.ts","./src/hooks/usefloweditorcallbacks.ts","./src/hooks/usefloweditorcollaboration.test.ts","./src/hooks/usefloweditorcollaboration.ts","./src/hooks/usefloweditoruistate.ts","./src/hooks/useflowexport.ts","./src/hooks/useflowhistory.test.ts","./src/hooks/useflowhistory.ts","./src/hooks/useflowoperations.ts","./src/hooks/useinfrasync.ts","./src/hooks/useinlinenodetextedit.test.ts","./src/hooks/useinlinenodetextedit.ts","./src/hooks/usekeyboardshortcuts.test.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/uselayoutoperations.ts","./src/hooks/usemarkdowneditor.ts","./src/hooks/usemodifierkeys.test.ts","./src/hooks/usemodifierkeys.ts","./src/hooks/usenodeoperations.ts","./src/hooks/useplayback.ts","./src/hooks/usesnapshots.ts","./src/hooks/usestoragepressureguard.ts","./src/hooks/usestructuredlisteditor.ts","./src/hooks/usestyleclipboard.test.ts","./src/hooks/usestyleclipboard.ts","./src/hooks/ai-generation/chathistorystorage.test.ts","./src/hooks/ai-generation/chathistorystorage.ts","./src/hooks/ai-generation/codetoarchitecture.ts","./src/hooks/ai-generation/graphcomposer.test.ts","./src/hooks/ai-generation/graphcomposer.ts","./src/hooks/ai-generation/nodeactionprompts.test.ts","./src/hooks/ai-generation/nodeactionprompts.ts","./src/hooks/ai-generation/openapiparser.test.ts","./src/hooks/ai-generation/openapiparser.ts","./src/hooks/ai-generation/openapitosequence.ts","./src/hooks/ai-generation/positionpreservingapply.test.ts","./src/hooks/ai-generation/positionpreservingapply.ts","./src/hooks/ai-generation/readiness.test.ts","./src/hooks/ai-generation/readiness.ts","./src/hooks/ai-generation/requestlifecycle.test.ts","./src/hooks/ai-generation/requestlifecycle.ts","./src/hooks/ai-generation/sqlparser.test.ts","./src/hooks/ai-generation/sqlparser.ts","./src/hooks/ai-generation/sqltoerd.ts","./src/hooks/ai-generation/terraformtocloud.ts","./src/hooks/edge-operations/utils.test.ts","./src/hooks/edge-operations/utils.ts","./src/hooks/flow-editor-actions/exporthandlers.test.ts","./src/hooks/flow-editor-actions/exporthandlers.ts","./src/hooks/flow-editor-actions/helpers.ts","./src/hooks/flow-editor-actions/layouthandlers.test.ts","./src/hooks/flow-editor-actions/layouthandlers.ts","./src/hooks/flow-export/diagramdocumenttransfer.test.ts","./src/hooks/flow-export/diagramdocumenttransfer.ts","./src/hooks/flow-export/exportcapture.test.ts","./src/hooks/flow-export/exportcapture.ts","./src/hooks/flow-operations/useflowcoreactions.ts","./src/hooks/node-operations/createconnectedsibling.ts","./src/hooks/node-operations/dragstopreconcilepolicy.test.ts","./src/hooks/node-operations/dragstopreconcilepolicy.ts","./src/hooks/node-operations/routingduringdrag.test.ts","./src/hooks/node-operations/routingduringdrag.ts","./src/hooks/node-operations/usearchitecturenodeoperations.test.ts","./src/hooks/node-operations/usearchitecturenodeoperations.ts","./src/hooks/node-operations/usemindmapnodeoperations.test.ts","./src/hooks/node-operations/usemindmapnodeoperations.ts","./src/hooks/node-operations/usenodedragoperations.test.ts","./src/hooks/node-operations/usenodedragoperations.ts","./src/hooks/node-operations/usenodeoperationadders.ts","./src/hooks/node-operations/utils.test.ts","./src/hooks/node-operations/utils.ts","./src/i18n/config.test.ts","./src/i18n/config.ts","./src/i18n/strictmodelocalecoverage.test.ts","./src/i18n/usedlocalecoverage.test.ts","./src/lib/architecturetemplates.test.ts","./src/lib/architecturetemplates.ts","./src/lib/brand.ts","./src/lib/classmembers.ts","./src/lib/connectcreationpolicy.test.ts","./src/lib/connectcreationpolicy.ts","./src/lib/devperformance.ts","./src/lib/entityfields.ts","./src/lib/ertoclassconversion.test.ts","./src/lib/ertoclassconversion.ts","./src/lib/exportfilename.test.ts","./src/lib/exportfilename.ts","./src/lib/flowminddslparserv2.test.ts","./src/lib/flowminddslparserv2.ts","./src/lib/genericshapepolicy.ts","./src/lib/id.ts","./src/lib/index.ts","./src/lib/legacybranding.ts","./src/lib/logger.ts","./src/lib/mermaidparser.ts","./src/lib/mermaidparserhelpers.ts","./src/lib/mindmaplayout.test.ts","./src/lib/mindmaplayout.ts","./src/lib/mindmaptree.ts","./src/lib/nodehandles.ts","./src/lib/nodeparent.ts","./src/lib/openflowdslparser.ts","./src/lib/openflowdslparserv2.ts","./src/lib/reactflowcompat.ts","./src/lib/reconnectedge.test.ts","./src/lib/reconnectedge.ts","./src/lib/relationsemantics.test.ts","./src/lib/relationsemantics.ts","./src/lib/releasestaleelkroutes.test.ts","./src/lib/releasestaleelkroutes.ts","./src/lib/result.ts","./src/lib/storagepressure.test.ts","./src/lib/storagepressure.ts","./src/lib/types.ts","./src/services/aligndistribute.ts","./src/services/aiservice.test.ts","./src/services/aiservice.ts","./src/services/animatedexport.test.ts","./src/services/animatedexport.ts","./src/services/architectureroundtrip.test.ts","./src/services/canonicalserialization.test.ts","./src/services/canonicalserialization.ts","./src/services/composediagramfordisplay.test.ts","./src/services/composediagramfordisplay.ts","./src/services/diagramdocument.test.ts","./src/services/diagramdocument.ts","./src/services/domainlibrary.test.ts","./src/services/domainlibrary.ts","./src/services/elklayout.test.ts","./src/services/elklayout.ts","./src/services/exportservice.test.ts","./src/services/exportservice.ts","./src/services/figmaexportservice.ts","./src/services/flowchartroundtrip.test.ts","./src/services/geminiservice.ts","./src/services/gifencoder.test.ts","./src/services/gifencoder.ts","./src/services/iconassetcatalog.ts","./src/services/importfidelity.test.ts","./src/services/importfidelity.ts","./src/services/mermaidparser.test.ts","./src/services/openflowdslexporter.test.ts","./src/services/openflowdslexporter.ts","./src/services/openflowdslparser.test.ts","./src/services/openflowroundtripgolden.test.ts","./src/services/openflowroundtripgoldenfixtures.ts","./src/services/operationfeedback.ts","./src/services/remainingfamiliesroundtrip.test.ts","./src/services/smartedgerouting.test.ts","./src/services/smartedgerouting.ts","./src/services/statediagramroundtrip.test.ts","./src/services/templates.selector.test.ts","./src/services/templates.ts","./src/services/ai/contextserializer.test.ts","./src/services/ai/contextserializer.ts","./src/services/analytics/analytics.ts","./src/services/analytics/analyticssettings.test.ts","./src/services/analytics/analyticssettings.ts","./src/services/analytics/surfaceanalyticsclient.ts","./src/services/architecturelint/defaultrules.ts","./src/services/architecturelint/ruleengine.test.ts","./src/services/architecturelint/ruleengine.ts","./src/services/architecturelint/rulelibrary.ts","./src/services/architecturelint/types.ts","./src/services/architecturelint/workspacerules.ts","./src/services/collaboration/canvasdiff.test.ts","./src/services/collaboration/canvasdiff.ts","./src/services/collaboration/comments.test.ts","./src/services/collaboration/comments.ts","./src/services/collaboration/contracts.test.ts","./src/services/collaboration/contracts.ts","./src/services/collaboration/hookutils.ts","./src/services/collaboration/presenceviewmodel.test.ts","./src/services/collaboration/presenceviewmodel.ts","./src/services/collaboration/reducer.test.ts","./src/services/collaboration/reducer.ts","./src/services/collaboration/roomconfig.ts","./src/services/collaboration/roomlink.test.ts","./src/services/collaboration/roomlink.ts","./src/services/collaboration/runtimecontroller.test.ts","./src/services/collaboration/runtimecontroller.ts","./src/services/collaboration/runtimehookutils.test.ts","./src/services/collaboration/runtimehookutils.ts","./src/services/collaboration/session.test.ts","./src/services/collaboration/session.ts","./src/services/collaboration/storebridge.test.ts","./src/services/collaboration/storebridge.ts","./src/services/collaboration/transport.test.ts","./src/services/collaboration/transport.ts","./src/services/collaboration/transportfactory.test.ts","./src/services/collaboration/transportfactory.ts","./src/services/collaboration/types.ts","./src/services/collaboration/versioning.test.ts","./src/services/collaboration/versioning.ts","./src/services/collaboration/yjspeertransport.test.ts","./src/services/collaboration/yjspeertransport.ts","./src/services/diagramdiff/diffengine.ts","./src/services/elk-layout/determinism.ts","./src/services/elk-layout/options.ts","./src/services/elk-layout/types.ts","./src/services/export/cinematicbuildplan.test.ts","./src/services/export/cinematicbuildplan.ts","./src/services/export/cinematicexporttheme.test.ts","./src/services/export/cinematicexporttheme.ts","./src/services/export/cinematicrenderstate.test.ts","./src/services/export/cinematicrenderstate.ts","./src/services/export/formatting.ts","./src/services/export/mermaidbuilder.test.ts","./src/services/export/mermaidbuilder.ts","./src/services/export/pdfdocument.test.ts","./src/services/export/pdfdocument.ts","./src/services/export/plantumlbuilder.ts","./src/services/figma/edgehelpers.ts","./src/services/figma/iconhelpers.ts","./src/services/figma/nodelayers.ts","./src/services/figma/themehelpers.ts","./src/services/figmaimport/figmaapiclient.ts","./src/services/infrasync/dockercomposeparser.test.ts","./src/services/infrasync/dockercomposeparser.ts","./src/services/infrasync/infratodsl.test.ts","./src/services/infrasync/infratodsl.ts","./src/services/infrasync/kubernetesparser.test.ts","./src/services/infrasync/kubernetesparser.ts","./src/services/infrasync/terraformstateparser.test.ts","./src/services/infrasync/terraformstateparser.ts","./src/services/infrasync/types.ts","./src/services/mermaid/detectdiagramtype.test.ts","./src/services/mermaid/detectdiagramtype.ts","./src/services/mermaid/diagnosticformatting.test.ts","./src/services/mermaid/diagnosticformatting.ts","./src/services/mermaid/parsemermaidbytype.test.ts","./src/services/mermaid/parsemermaidbytype.ts","./src/services/mermaid/strictmodediagnosticspresentation.test.ts","./src/services/mermaid/strictmodediagnosticspresentation.ts","./src/services/mermaid/strictmodeguidance.test.ts","./src/services/mermaid/strictmodeguidance.ts","./src/services/mermaid/strictmodeuxregression.test.ts","./src/services/offline/registerappshellserviceworker.test.ts","./src/services/offline/registerappshellserviceworker.ts","./src/services/onboarding/config.ts","./src/services/onboarding/events.test.ts","./src/services/onboarding/events.ts","./src/services/playback/contracts.test.ts","./src/services/playback/contracts.ts","./src/services/playback/model.test.ts","./src/services/playback/model.ts","./src/services/playback/studio.test.ts","./src/services/playback/studio.ts","./src/services/sequence/sequencemessage.test.ts","./src/services/sequence/sequencemessage.ts","./src/services/shapelibrary/bootstrap.test.ts","./src/services/shapelibrary/bootstrap.ts","./src/services/shapelibrary/ingestionpipeline.test.ts","./src/services/shapelibrary/ingestionpipeline.ts","./src/services/shapelibrary/manifestvalidation.test.ts","./src/services/shapelibrary/manifestvalidation.ts","./src/services/shapelibrary/providercatalog.test.ts","./src/services/shapelibrary/providercatalog.ts","./src/services/shapelibrary/registry.test.ts","./src/services/shapelibrary/registry.ts","./src/services/shapelibrary/starterpacks.ts","./src/services/shapelibrary/types.ts","./src/services/storage/flowdocumentmodel.test.ts","./src/services/storage/flowdocumentmodel.ts","./src/services/storage/flowpersiststorage.test.ts","./src/services/storage/flowpersiststorage.ts","./src/services/storage/indexeddbschema.test.ts","./src/services/storage/indexeddbschema.ts","./src/services/storage/indexeddbstatestorage.test.ts","./src/services/storage/indexeddbstatestorage.ts","./src/services/storage/localfirstrepository.ts","./src/services/storage/localfirstruntime.ts","./src/services/storage/persisteddocumentadapters.test.ts","./src/services/storage/persisteddocumentadapters.ts","./src/services/storage/persistencetypes.ts","./src/services/storage/snapshotstorage.test.ts","./src/services/storage/snapshotstorage.ts","./src/services/storage/storagetelemetry.test.ts","./src/services/storage/storagetelemetry.ts","./src/services/storage/storagetelemetrysink.test.ts","./src/services/storage/storagetelemetrysink.ts","./src/services/storage/uilocalstorage.test.ts","./src/services/storage/uilocalstorage.ts","./src/services/templatelibrary/registry.test.ts","./src/services/templatelibrary/registry.ts","./src/services/templatelibrary/startertemplates.assets.test.ts","./src/services/templatelibrary/startertemplates.test.ts","./src/services/templatelibrary/startertemplates.ts","./src/services/templatelibrary/types.ts","./src/store/aisettings.test.ts","./src/store/aisettings.ts","./src/store/aisettingspersistence.ts","./src/store/canvashooks.ts","./src/store/defaults.ts","./src/store/designsystemhooks.ts","./src/store/documenthooks.ts","./src/store/documentstatesync.test.ts","./src/store/documentstatesync.ts","./src/store/editorpagehooks.ts","./src/store/historyhooks.ts","./src/store/persistence.test.ts","./src/store/persistence.ts","./src/store/selectionhooks.ts","./src/store/tabhooks.ts","./src/store/types.ts","./src/store/viewhooks.ts","./src/store/workspacedocumentmodel.test.ts","./src/store/workspacedocumentmodel.ts","./src/store/actions/createaiandselectionactions.ts","./src/store/actions/createcanvasactions.ts","./src/store/actions/createdesignsystemactions.ts","./src/store/actions/createhistoryactions.ts","./src/store/actions/createlayeractions.ts","./src/store/actions/createtabactions.ts","./src/store/actions/createviewactions.ts","./src/store/actions/createworkspacedocumentactions.ts"],"version":"5.8.3"}
\ No newline at end of file
diff --git a/web/package.json b/web/package.json
index 1965109f..ff4d53ef 100644
--- a/web/package.json
+++ b/web/package.json
@@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.2.1",
"astro": "^5.7.0",
"lucide-react": "^0.469.0",
+ "posthog-js": "^1.347.2",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"sharp": "^0.34.1",
diff --git a/web/src/components/LandingAnalytics.astro b/web/src/components/LandingAnalytics.astro
new file mode 100644
index 00000000..2d4c953e
--- /dev/null
+++ b/web/src/components/LandingAnalytics.astro
@@ -0,0 +1,33 @@
+---
+---
+
+
diff --git a/web/src/components/Layout.astro b/web/src/components/Layout.astro
index 8f9cbf34..6caeeb01 100644
--- a/web/src/components/Layout.astro
+++ b/web/src/components/Layout.astro
@@ -1,5 +1,6 @@
---
import '../styles/global.css';
+import LandingAnalytics from './LandingAnalytics.astro';
interface Props {
title: string;
@@ -74,6 +75,7 @@ const schemas = [...baseSchemas, ...extraSchemas];
+