diff --git a/apps/frontend/.storybook/main.ts b/apps/frontend/.storybook/main.ts
new file mode 100644
index 000000000..af123abf2
--- /dev/null
+++ b/apps/frontend/.storybook/main.ts
@@ -0,0 +1,17 @@
+import type { StorybookConfig } from '@storybook/react-vite';
+import path from 'path';
+
+const config: StorybookConfig = {
+ stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
+ addons: [],
+ framework: {
+ name: '@storybook/react-vite',
+ options: {
+ builder: {
+ viteConfigPath: path.resolve(__dirname, 'vite.config.ts'),
+ },
+ },
+ },
+};
+
+export default config;
diff --git a/apps/frontend/.storybook/preview.tsx b/apps/frontend/.storybook/preview.tsx
new file mode 100644
index 000000000..969f3026d
--- /dev/null
+++ b/apps/frontend/.storybook/preview.tsx
@@ -0,0 +1,35 @@
+import type { Preview } from '@storybook/react';
+import React from 'react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { UIProvider } from '@packmind/ui';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ staleTime: Infinity,
+ },
+ },
+});
+
+const preview: Preview = {
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/,
+ },
+ },
+ },
+};
+
+export default preview;
diff --git a/apps/frontend/.storybook/vite.config.ts b/apps/frontend/.storybook/vite.config.ts
new file mode 100644
index 000000000..9b6086c95
--- /dev/null
+++ b/apps/frontend/.storybook/vite.config.ts
@@ -0,0 +1,35 @@
+import { defineConfig } from 'vite';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import path from 'path';
+
+export default defineConfig(() => {
+ // Determine edition mode (defaults to OSS if not explicitly set to 'proprietary')
+ const isOssMode = process.env.PACKMIND_EDITION !== 'proprietary';
+
+ // Configure resolve aliases based on edition
+ const resolveAliases = isOssMode
+ ? {
+ '@packmind/proprietary/frontend': path.resolve(
+ __dirname,
+ '../src/domain/editions/stubs',
+ ),
+ }
+ : {
+ '@packmind/proprietary/frontend': path.resolve(__dirname, '../src'),
+ };
+
+ return {
+ root: path.resolve(__dirname, '..'),
+ cacheDir: '../../../node_modules/.vite/apps/frontend-storybook',
+ assetsInclude: ['**/*.svg', '**/*.png'],
+ define: {
+ __PACKMIND_EDITION__: JSON.stringify(
+ process.env.PACKMIND_EDITION || 'oss',
+ ),
+ },
+ resolve: {
+ alias: resolveAliases,
+ },
+ plugins: [nxViteTsPaths()],
+ };
+});
diff --git a/apps/frontend/project.json b/apps/frontend/project.json
index 5f462e0ee..791cf0943 100644
--- a/apps/frontend/project.json
+++ b/apps/frontend/project.json
@@ -26,6 +26,23 @@
"options": {
"command": "nx dev frontend"
}
+ },
+ "storybook": {
+ "executor": "nx:run-commands",
+ "continuous": true,
+ "options": {
+ "cwd": "apps/frontend",
+ "command": "storybook dev -p 6006"
+ }
+ },
+ "build-storybook": {
+ "executor": "nx:run-commands",
+ "cache": true,
+ "outputs": ["{projectRoot}/storybook-static"],
+ "options": {
+ "cwd": "apps/frontend",
+ "command": "storybook build"
+ }
}
}
}
diff --git a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx
index 1084a8a5b..8a3d6ffc2 100644
--- a/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx
+++ b/apps/frontend/src/domain/change-proposals/components/StandardReviewDetail/ProposalReviewPanel.tsx
@@ -31,7 +31,6 @@ import {
import { HighlightedText, HighlightedRuleBox } from '../HighlightedContent';
import { UnifiedMarkdownViewer } from '../UnifiedMarkdownViewer';
import { useDiffNavigation } from '../../hooks/useDiffNavigation';
-import { renderMarkdownDiffOrPreview } from '../SkillReviewDetail/SkillContent/renderMarkdownDiffOrPreview';
interface ProposalReviewPanelProps {
selectedStandard: Standard | undefined;
@@ -244,15 +243,24 @@ export function ProposalReviewPanel({
? renderDiffText(payload.oldValue, payload.newValue)
: selectedStandard.name}
- {renderMarkdownDiffOrPreview(
- isDescriptionDiff,
- showPreview,
- payload,
- selectedStandard.description,
- {
- previewPaddingVariant: 'none',
- defaultPaddingVariant: 'none',
- },
+ {isDescriptionDiff ? (
+
+ ) : (
+
+
+
)}
{/* Rules Section */}
diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx
new file mode 100644
index 000000000..2622f78e0
--- /dev/null
+++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.stories.tsx
@@ -0,0 +1,159 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import {
+ UnifiedMarkdownViewer,
+ UnifiedMarkdownViewerProps,
+} from './UnifiedMarkdownViewer';
+
+const MultiViewer: React.FunctionComponent<
+ Omit
+> = (props) => {
+ return (
+
+
+
Unified view
+
+
+
+
+
Diff view
+
+
+
+ );
+};
+
+const meta: Meta = {
+ title: 'ChangeProposals/UnifiedMarkdownViewer',
+ component: MultiViewer,
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const MarkdownFormatting: Story = {
+ args: {
+ oldValue: `# My title
+
+This was my content
+
+There was a line here.
+
+## My sub-heading
+
+My list:
+ - one item
+ - another item
+
+# Code example
+
+\`\`\`javascript
+function hello() {
+ console.log('Hello');
+}
+\`\`\`
+`,
+ newValue: `# My title
+
+This is my updated content
+
+## My sub-heading
+
+My list:
+ - first item
+ - a new item
+ - another item
+
+ # Example
+
+\`\`\`javascript
+function hello() {
+ console.log('Hello, ' + name);
+}
+\`\`\``,
+ proposalNumbers: [1],
+ },
+};
+
+export const TagChanges: Story = {
+ args: {
+ oldValue: '# My title\n\nThis was my content',
+ newValue: '## My title\n\nThis is my **content**',
+ proposalNumbers: [1],
+ },
+};
+
+export const MultipleProposals: Story = {
+ args: {
+ oldValue: 'This is my content',
+ newValue: 'This is my updated content',
+ proposalNumbers: [1, 2, 3],
+ },
+};
+
+export const CodeBlock: Story = {
+ args: {
+ oldValue: `# Example
+
+\`\`\`javascript
+function hello() {
+ console.log('Hello');
+}
+\`\`\``,
+ newValue: `# Example
+
+\`\`\`javascript
+function hello(name) {
+ console.log('Hello, ' + name);
+}
+\`\`\``,
+ proposalNumbers: [1],
+ },
+};
+
+export const ListChanges: Story = {
+ args: {
+ oldValue: `# Todo List
+
+- Item 1
+- Item 2
+- Item 3`,
+ newValue: `# Todo List
+
+- Item 1
+- Updated Item 2
+- Item 3
+- Item 4`,
+ proposalNumbers: [1],
+ },
+};
+
+export const ComplexChanges: Story = {
+ args: {
+ oldValue: `# My title
+
+This was my content
+
+There was a line here.
+
+## My sub-heading
+
+My list:
+ - one item
+ - another item`,
+ newValue: `# My title
+
+This is my updated content
+
+## My sub-heading
+
+My list:
+ - first item
+ - a new item
+ - another item`,
+ proposalNumbers: [1, 2],
+ },
+};
diff --git a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx
index 915eb0146..380635493 100644
--- a/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx
+++ b/apps/frontend/src/domain/change-proposals/components/UnifiedMarkdownViewer.tsx
@@ -1,12 +1,16 @@
import React from 'react';
import { PMBox } from '@packmind/ui';
import { MilkdownProvider } from '@milkdown/react';
-import { DiffMarkdownEditor } from '../../../shared/components/editor/DiffMarkdownEditor';
+import {
+ DiffMarkdownEditor,
+ IDiffMarkdownEditorProps,
+} from '../../../shared/components/editor/DiffMarkdownEditor';
-interface UnifiedMarkdownViewerProps {
+export interface UnifiedMarkdownViewerProps {
oldValue: string;
newValue: string;
proposalNumbers: number[];
+ displayMode?: IDiffMarkdownEditorProps['displayMode'];
}
/**
@@ -18,6 +22,7 @@ export function UnifiedMarkdownViewer({
oldValue,
newValue,
proposalNumbers,
+ displayMode,
}: UnifiedMarkdownViewerProps) {
return (
@@ -26,6 +31,7 @@ export function UnifiedMarkdownViewer({
oldValue={oldValue}
newValue={newValue}
proposalNumbers={proposalNumbers}
+ displayMode={displayMode ?? 'unified'}
paddingVariant="none"
/>
diff --git a/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts b/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts
new file mode 100644
index 000000000..85688573d
--- /dev/null
+++ b/apps/frontend/src/domain/change-proposals/utils/buildInlineDiffMarkdown.ts
@@ -0,0 +1,13 @@
+import { buildDiffHtml } from './markdownDiff';
+
+/**
+ * Builds inline diff markdown content for the "diff" display mode.
+ * Uses buildDiffHtml to generate HTML with and tags
+ * showing additions and deletions inline.
+ */
+export function buildInlineDiffMarkdown(
+ oldValue: string,
+ newValue: string,
+): string {
+ return buildDiffHtml(oldValue, newValue);
+}
diff --git a/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts b/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts
new file mode 100644
index 000000000..3502b7a7a
--- /dev/null
+++ b/apps/frontend/src/domain/change-proposals/utils/markdownBlockDiff.spec.ts
@@ -0,0 +1,423 @@
+import { parseAndDiffMarkdown } from './markdownBlockDiff';
+
+describe('markdownBlockDiff', () => {
+ describe('parseAndDiffMarkdown', () => {
+ it('parses and diffs a simple text change', () => {
+ const oldValue = 'This was my content';
+ const newValue = 'This is my updated content';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'paragraph',
+ content: 'This is my updated content',
+ status: 'updated',
+ diffContent: expect.stringMatching(
+ /This --was--\+\+is\+\+ my \+\+updated \+\+content/,
+ ),
+ }),
+ ]);
+ });
+
+ it('handles heading blocks with level tracking', () => {
+ const oldValue = '# My title';
+ const newValue = '# My updated title';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'heading',
+ level: '#',
+ content: 'My updated title',
+ status: 'updated',
+ diffContent: expect.stringContaining('++updated ++'),
+ }),
+ ]);
+ });
+
+ it('preserves unchanged blocks', () => {
+ const markdown = '# My title\n\nSome content that stays the same';
+
+ const result = parseAndDiffMarkdown(markdown, markdown);
+
+ expect(result).toEqual([
+ expect.objectContaining({ status: 'unchanged' }),
+ expect.objectContaining({ status: 'unchanged' }),
+ ]);
+ });
+
+ it('marks added blocks correctly', () => {
+ const oldValue = '# Title';
+ const newValue = '# Title\n\nNew paragraph';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: 'heading',
+ status: 'unchanged',
+ }),
+ expect.objectContaining({
+ type: 'paragraph',
+ content: 'New paragraph',
+ status: 'added',
+ diffContent: '++New paragraph++',
+ }),
+ ]),
+ );
+ });
+
+ it('marks deleted blocks correctly', () => {
+ const oldValue = '# Title\n\nParagraph to delete';
+ const newValue = '# Title';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: 'heading',
+ status: 'unchanged',
+ }),
+ expect.objectContaining({
+ type: 'paragraph',
+ content: 'Paragraph to delete',
+ status: 'deleted',
+ diffContent: '--Paragraph to delete--',
+ }),
+ ]),
+ );
+ });
+
+ it('handles the complex user story example correctly', () => {
+ const oldValue = `# My title
+
+This was my content
+
+There was a line here.
+
+## My sub-heading
+
+My list:
+ - one item
+ - another item`;
+
+ const newValue = `# My title
+
+This is my updated content
+
+## My sub-heading
+
+My list:
+ - first item
+ - a new item
+ - another item`;
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ type: 'heading',
+ level: '#',
+ content: 'My title',
+ status: 'unchanged',
+ }),
+ expect.objectContaining({
+ type: 'heading',
+ level: '##',
+ content: 'My sub-heading',
+ status: 'unchanged',
+ }),
+ expect.objectContaining({
+ type: 'paragraph',
+ content: 'This is my updated content',
+ status: 'updated',
+ diffContent: expect.stringMatching(/--was--.*\+\+is\+\+/),
+ }),
+ expect.objectContaining({
+ type: 'paragraph',
+ content: 'There was a line here.',
+ status: 'deleted',
+ diffContent: '--There was a line here.--',
+ }),
+ expect.objectContaining({
+ type: 'list',
+ status: 'updated',
+ items: expect.arrayContaining([
+ expect.objectContaining({
+ content: 'one item',
+ status: 'deleted',
+ }),
+ expect.objectContaining({
+ content: 'first item',
+ status: 'added',
+ }),
+ expect.objectContaining({
+ content: 'a new item',
+ status: 'added',
+ }),
+ expect.objectContaining({
+ content: 'another item',
+ status: 'unchanged',
+ }),
+ ]),
+ }),
+ ]),
+ );
+ });
+
+ it('handles multiple heading levels', () => {
+ const oldValue = '# H1\n## H2\n### H3';
+ const newValue = '# H1\n## Updated H2\n### H3';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'heading',
+ level: '#',
+ status: 'unchanged',
+ }),
+ expect.objectContaining({
+ type: 'heading',
+ level: '##',
+ content: 'Updated H2',
+ status: 'updated',
+ }),
+ expect.objectContaining({
+ type: 'heading',
+ level: '###',
+ status: 'unchanged',
+ }),
+ ]);
+ });
+
+ it('computes word-level diffs for code blocks', () => {
+ const oldValue = '```js\nconst x = 1;\n```';
+ const newValue = '```js\nconst x = 2;\n```';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'code',
+ status: 'updated',
+ language: 'js',
+ diffContent: expect.stringMatching(/--1--.*\+\+2\+\+/),
+ lineDiff: expect.stringContaining('-const x = 1;'),
+ }),
+ ]);
+ });
+
+ it('includes additions in line diff for code blocks', () => {
+ const oldValue = '```js\nconst x = 1;\n```';
+ const newValue = '```js\nconst x = 2;\n```';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result[0].lineDiff).toContain('+const x = 2;');
+ });
+
+ it('computes line-level diffs for multi-line code blocks', () => {
+ const oldValue = `\`\`\`javascript
+function hello() {
+ console.log('Hello');
+}
+\`\`\``;
+
+ const newValue = `\`\`\`javascript
+function hello(name) {
+ console.log('Hello, ' + name);
+}
+\`\`\``;
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'code',
+ status: 'updated',
+ language: 'javascript',
+ lineDiff: expect.stringContaining('-function hello() {'),
+ }),
+ ]);
+ });
+
+ it('tracks list item additions', () => {
+ const oldValue = '- item 1\n- item 2';
+ const newValue = '- item 1\n- item 2\n- item 3';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'list',
+ status: 'updated',
+ items: [
+ expect.objectContaining({ status: 'unchanged' }),
+ expect.objectContaining({ status: 'unchanged' }),
+ expect.objectContaining({
+ status: 'added',
+ diffContent: '++item 3++',
+ }),
+ ],
+ }),
+ ]);
+ });
+
+ it('tracks list item deletions', () => {
+ const oldValue = '- item 1\n- item 2\n- item 3';
+ const newValue = '- item 1\n- item 3';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'list',
+ status: 'updated',
+ items: [
+ expect.objectContaining({ status: 'unchanged' }),
+ expect.objectContaining({
+ status: 'deleted',
+ diffContent: '--item 2--',
+ }),
+ expect.objectContaining({ status: 'unchanged' }),
+ ],
+ }),
+ ]);
+ });
+
+ it('returns empty array for empty markdown', () => {
+ const result = parseAndDiffMarkdown('', '');
+
+ expect(result).toEqual([]);
+ });
+
+ it('handles transition from empty to content', () => {
+ const result = parseAndDiffMarkdown('', '# New heading');
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ type: 'heading',
+ status: 'added',
+ }),
+ ]);
+ });
+
+ it('escapes HTML to prevent XSS', () => {
+ const oldValue = 'Content with ';
+ const newValue = 'Content with safe
';
+
+ const result = parseAndDiffMarkdown(oldValue, newValue);
+
+ expect(result[0].diffContent).toEqual(
+ expect.not.stringContaining('