diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx
index 51836f7cf1..ea4910bf86 100644
--- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx
@@ -22,6 +22,12 @@ vi.mock('../../../components/table', () => ({
vi.mock('./components/EditFrameworkDialog', () => ({ EditFrameworkDialog: () => null }));
vi.mock('./components/DeleteFrameworkDialog', () => ({ DeleteFrameworkDialog: () => null }));
+vi.mock('./versions/components/PublishVersionDialog', () => ({
+ PublishVersionDialog: () => null,
+}));
+vi.mock('./versions/hooks/useFrameworkVersions', () => ({
+ useFrameworkVersions: () => ({ data: [], refetch: vi.fn() }),
+}));
vi.mock('@/app/lib/api-client', () => ({ apiClient: vi.fn() }));
vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.toolbar.test.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.toolbar.test.tsx
new file mode 100644
index 0000000000..48b8bf20fc
--- /dev/null
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.toolbar.test.tsx
@@ -0,0 +1,101 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+// Shared, hoisted handles so the mock factory and the assertions see the same
+// references. publishProps records each render's `open` value.
+const { handleCommit, handleCancel, publishProps } = vi.hoisted(() => ({
+ handleCommit: vi.fn(async () => true),
+ handleCancel: vi.fn(),
+ publishProps: [] as Array<{ open: boolean }>,
+}));
+
+vi.mock('../../../components/table', () => ({
+ ComboboxCell: () => null,
+ DateCell: () => null,
+ RelationalCell: () => null,
+ EditableCell: () => null,
+}));
+vi.mock('./components/EditFrameworkDialog', () => ({ EditFrameworkDialog: () => null }));
+vi.mock('./components/DeleteFrameworkDialog', () => ({ DeleteFrameworkDialog: () => null }));
+vi.mock('./versions/components/PublishVersionDialog', () => ({
+ PublishVersionDialog: (props: { open: boolean }) => {
+ publishProps.push({ open: props.open });
+ return null;
+ },
+}));
+vi.mock('./versions/hooks/useFrameworkVersions', () => ({
+ useFrameworkVersions: () => ({ data: [{ version: '1.0.0' }], refetch: vi.fn() }),
+}));
+vi.mock('@/app/lib/api-client', () => ({ apiClient: vi.fn() }));
+vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock('@trycompai/ui', () => ({
+ Button: ({ children, variant: _v, size: _s, ...props }: any) => (
+
+ ),
+}));
+
+vi.mock('./hooks/useRequirementChangeTracking', () => ({
+ simpleUUID: () => 'temp-id',
+ useRequirementChangeTracking: () => ({
+ data: [],
+ updateCell: vi.fn(),
+ updateRelational: vi.fn(),
+ addRow: vi.fn(),
+ deleteRow: vi.fn(),
+ getRowClassName: () => '',
+ handleCommit,
+ handleCancel,
+ isDirty: true,
+ createdIds: new Set(),
+ changesSummary: '(2 changes)',
+ }),
+}));
+
+import { FrameworkRequirementsClientPage } from './FrameworkRequirementsClientPage';
+
+function renderPage() {
+ render(
+ ,
+ );
+}
+
+describe('FrameworkRequirementsClientPage — Save as Draft / Save and Commit (FRAME-4)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ handleCommit.mockImplementation(async () => true);
+ publishProps.length = 0;
+ });
+
+ it('shows all three buttons when there are uncommitted changes', () => {
+ renderPage();
+ expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Save as Draft' })).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Save and Commit' })).toBeTruthy();
+ });
+
+ it('Save as Draft commits without opening the publish dialog', () => {
+ renderPage();
+ fireEvent.click(screen.getByRole('button', { name: 'Save as Draft' }));
+ expect(handleCommit).toHaveBeenCalledTimes(1);
+ expect(publishProps.every((p) => p.open === false)).toBe(true);
+ });
+
+ it('Save and Commit saves then opens the publish dialog', async () => {
+ renderPage();
+ fireEvent.click(screen.getByRole('button', { name: 'Save and Commit' }));
+ expect(handleCommit).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(publishProps.some((p) => p.open === true)).toBe(true));
+ });
+
+ it('does not open the publish dialog when the save fails', async () => {
+ handleCommit.mockImplementation(async () => false);
+ renderPage();
+ fireEvent.click(screen.getByRole('button', { name: 'Save and Commit' }));
+ await waitFor(() => expect(handleCommit).toHaveBeenCalled());
+ expect(publishProps.every((p) => p.open === false)).toBe(true);
+ });
+});
diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx
index da517c096c..43e752d9b0 100644
--- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx
+++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx
@@ -22,6 +22,8 @@ import {
useRequirementChangeTracking,
type RequirementGridRow,
} from './hooks/useRequirementChangeTracking';
+import { PublishVersionDialog } from './versions/components/PublishVersionDialog';
+import { useFrameworkVersions } from './versions/hooks/useFrameworkVersions';
interface FrameworkDetails {
id: string;
@@ -76,6 +78,12 @@ export function FrameworkRequirementsClientPage({
// Row whose large description editor is currently open — highlighted so the
// edited row is obvious behind the (semi-transparent) editor dialog.
const [expandedRowId, setExpandedRowId] = useState(null);
+ // "Save and Commit" saves the edits then opens the publish flow (FRAME-4).
+ const [isPublishOpen, setIsPublishOpen] = useState(false);
+ const { data: publishedVersions, refetch: refetchVersions } = useFrameworkVersions(
+ frameworkDetails.id,
+ );
+ const latestPublishedVersion = publishedVersions?.[0]?.version;
const initialGridData: RequirementGridRow[] = useMemo(
() =>
@@ -107,6 +115,13 @@ export function FrameworkRequirementsClientPage({
changesSummary,
} = useRequirementChangeTracking(initialGridData, frameworkDetails.id);
+ // Save edits, then (only if they all persisted) open the publish dialog so
+ // the accumulated changes can be committed as a new version.
+ const handleSaveAndCommit = useCallback(async () => {
+ const ok = await handleCommit();
+ if (ok) setIsPublishOpen(true);
+ }, [handleCommit]);
+
const uniqueFamilies = useMemo(() => {
const families = new Set();
for (const row of data) {
@@ -304,8 +319,11 @@ export function FrameworkRequirementsClientPage({
-