- {/*
-
setEmail(e.target.value)}
- className="h-8 text-xs"
- />
-
-
*/}
+ {isOwner && (
+ <>
+
+
+ {error && (
+
+ {error}
+
+ )}
+ >
+ )}
);
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx
index f97e470..626fde0 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.stories.tsx
@@ -6,6 +6,7 @@ import { CreateDocumentButton } from './create-document-button';
const meta: Meta = {
title: 'DocumentPage/CreateDocumentButton',
component: CreateDocumentButton,
+ tags: ['autodocs'],
parameters: {
layout: 'centered',
},
@@ -17,10 +18,16 @@ export default meta;
type Story = StoryObj;
+/**
+ * Button opening the create-document modal.
+ */
export const Default: Story = {
args: {},
};
+/**
+ * Pending state while creation resolves.
+ */
export const WithSlowCreation: Story = {
args: {
onCreateDocument: fn().mockImplementation(
@@ -30,6 +37,9 @@ export const WithSlowCreation: Story = {
},
};
+/**
+ * Error surfaced when creation fails.
+ */
export const WithError: Story = {
args: {
onCreateDocument: fn().mockRejectedValue(
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx
index cfac41d..24050c3 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/CreateDocumentButton/create-document-button.tsx
@@ -1,16 +1,21 @@
import React, { useState } from 'react';
import { LuPlus as AddIcon } from 'react-icons/lu';
-import NewDocumentFormBody from '@/components/common/forms/NewDocumentFormBody';
+import NewDocumentFormBody from '@/components/common/NewDocumentFormBody';
import { Button } from '@/components/ui/Button';
import { Modal, ModalOverlay, ModalTrigger } from '@/components/ui/Modal';
import type { CreateDocumentForm } from '@/types/api';
interface CreateDocumentButtonProps {
+ /** Creates a document with the submitted title; closes the modal on success. */
onCreateDocument?: (title: string) => Promise
;
+ /** Extra classes merged onto the trigger button. */
className?: string;
}
+/**
+ * Header button driving the shared create-document flow with pending/error handling.
+ */
export const CreateDocumentButton = ({
onCreateDocument,
className,
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx
index 9aedff0..32d79c5 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.stories.tsx
@@ -5,6 +5,7 @@ import { DocumentTitle } from './document-title';
const meta: Meta = {
title: 'DocumentPage/DocumentTitle',
component: DocumentTitle,
+ tags: ['autodocs'],
parameters: {
layout: 'centered',
},
@@ -13,16 +14,25 @@ export default meta;
type Story = StoryObj;
+/**
+ * Default rendering.
+ */
export const Default: Story = {
args: {},
};
+/**
+ * Standard inline title.
+ */
export const DocumentTitleDefault: Story = {
args: {
title: 'My Document',
},
};
+/**
+ * Long title truncation.
+ */
export const LongTitle: Story = {
args: {
title:
@@ -30,12 +40,18 @@ export const LongTitle: Story = {
},
};
+/**
+ * Missing title renders nothing.
+ */
export const NoTitle: Story = {
args: {
title: undefined,
},
};
+/**
+ * Empty-string title renders nothing.
+ */
export const EmptyTitle: Story = {
args: {
title: '',
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx
index c5c5a46..c224f89 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentTitle/document-title.tsx
@@ -3,10 +3,15 @@ import React from 'react';
import { cn } from '@/utils/cn';
interface DocumentTitleProps {
+ /** Title text; when absent the component renders nothing. */
title?: string;
+ /** Extra classes merged onto the title element. */
className?: string;
}
+/**
+ * Inline document title in the header; renders nothing when title is absent.
+ */
export const DocumentTitle = ({ title, className }: DocumentTitleProps) => {
if (!title) return null;
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx
index 641d0db..5d338fc 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.stories.tsx
@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
-import { MemoryRouter } from 'react-router';
import { fn } from 'storybook/test';
import { DocumentToolbar } from './document-toolbar';
@@ -7,16 +6,15 @@ import { DocumentToolbar } from './document-toolbar';
const meta: Meta = {
title: 'DocumentPage/DocumentToolbar',
component: DocumentToolbar,
+ tags: ['autodocs'],
parameters: {
layout: 'centered',
},
decorators: [
(Story) => (
-
-
-
-
-
+
+
+
),
],
args: {
@@ -30,12 +28,18 @@ export default meta;
type Story = StoryObj;
+/**
+ * Toolbar with minimal props.
+ */
export const ToolbarMinimal: Story = {
args: {
mode: 'both',
},
};
+/**
+ * Toolbar including user-specific controls.
+ */
export const ToolbarWithUser: Story = {
args: {
mode: 'edit',
@@ -45,6 +49,9 @@ export const ToolbarWithUser: Story = {
},
};
+/**
+ * Fully populated toolbar.
+ */
export const ToolbarComplete: Story = {
args: {
mode: 'both',
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx
index 2f41013..bcd6e50 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/DocumentToolbar/document-toolbar.tsx
@@ -11,18 +11,31 @@ import { ShareButton } from '../ShareButton/share-button';
import { ViewModeSelector } from '../ViewModeSelector';
interface DocumentToolbarProps {
+ /** Active editor layout: edit-only, split, or view-only. */
mode: 'edit' | 'both' | 'view';
+ /** Called with the selected layout when it changes. */
setMode: (mode: 'edit' | 'both' | 'view') => void;
+ /** Signed-in username shown in the user menu. */
username?: string;
+ /** Signed-in user's avatar image URL. */
avatarUrl?: string;
+ /** Signs the user out from the account menu. */
logout?: () => void;
+ /** Title of the open document. */
documentTitle?: string;
+ /** Creates a document with the given title from the header actions. */
onCreateDocument?: (title: string) => Promise;
+ /** ID of the open document; enables document-scoped controls. */
docId?: string;
+ /** Renders read-only affordances (View-only badge instead of roster controls). */
isReadOnly?: boolean;
+ /** Hides owner-only controls (share/options) for collaborators. */
isCollaborator?: boolean;
}
+/**
+ * Header toolbar switching contents between edit and view modes.
+ */
export const DocumentToolbar = ({
mode,
setMode,
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx
index c9f4553..35a0b22 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.stories.tsx
@@ -5,11 +5,15 @@ import { MoreOptionsDropdown } from './options-dropdown';
const meta: Meta = {
title: 'DocumentPage/OptionsDropdown',
component: MoreOptionsDropdown,
+ tags: ['autodocs'],
};
export default meta;
type Story = StoryObj;
+/**
+ * More-options menu.
+ */
export const Default: Story = {
args: {},
};
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx
index 71a67fe..c89960e 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/OptionsDropdown/options-dropdown.tsx
@@ -14,9 +14,13 @@ import {
} from '@/components/ui/Dropdown';
type Props = {
+ /** Extra classes merged onto the kebab-menu trigger button. */
className?: string;
};
+/**
+ * Kebab menu exposing additional document actions.
+ */
export const MoreOptionsDropdown = ({ className }: Props) => {
return (
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx
index 89d69a4..af7dddd 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.stories.tsx
@@ -5,6 +5,7 @@ import { ShareButton } from './share-button';
const meta: Meta = {
title: 'DocumentPage/ShareButton',
component: ShareButton,
+ tags: ['autodocs'],
parameters: {
layout: 'centered',
},
@@ -13,6 +14,9 @@ export default meta;
type Story = StoryObj;
+/**
+ * Button opening the share dialog.
+ */
export const Default: Story = {
args: {},
};
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx
index 4f406ab..628cc66 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-button.tsx
@@ -19,16 +19,22 @@ import {
ToastClose,
ToastProvider,
} from '@/components/ui/Toast';
-import { useJoinRequests } from '@/hooks/useJoinRequests';
-import { useShareLink } from '@/hooks/useShareLink';
+import { useJoinRequests } from '@/hooks/use-join-requests';
+import { useShareLink } from '@/hooks/use-share-link';
import { ShareModeSelect } from './share-mode-select';
type Props = {
+ /** Extra classes merged onto the share trigger button. */
className?: string;
+ /** ID of the document being shared. */
docId?: string;
+ /** Switches hooks to read-only collaborator access. */
isCollaborator?: boolean;
};
+/**
+ * Share flow trigger managing permission selection and the share dialog state.
+ */
export const ShareButton = ({ className, docId, isCollaborator }: Props) => {
const [permission, setPermission] = useState<'view' | 'edit'>('view');
const [toast, setToast] = useState(false);
@@ -44,7 +50,6 @@ export const ShareButton = ({ className, docId, isCollaborator }: Props) => {
shareLink,
loading: linkLoading,
error,
- refresh,
} = useShareLink(docId, permission, isCollaborator);
const handleCopy = async () => {
@@ -53,9 +58,7 @@ export const ShareButton = ({ className, docId, isCollaborator }: Props) => {
};
const handlePermissionChange = (value: 'view' | 'edit') => {
- console.log('changed permission to', value);
- setPermission(value);
- refresh(); // This will use the new `permission` from state
+ setPermission(value); // the hook refetches on permission change
};
return (
@@ -78,7 +81,7 @@ export const ShareButton = ({ className, docId, isCollaborator }: Props) => {
size="icon"
variant="outline"
onClick={handleCopy}
- disabled={!shareLink}
+ disabled={!shareLink || linkLoading}
>
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx
index 6b19bea..98c71ad 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/ShareButton/share-mode-select.tsx
@@ -11,11 +11,16 @@ const options = [
{ label: 'Edit mode', value: 'edit' },
];
+/**
+ * Select between view/edit permission for a share link.
+ */
export function ShareModeSelect({
onChange,
value,
}: {
+ /** Called with the chosen permission level. */
onChange: (permission: 'view' | 'edit') => void;
+ /** Currently selected permission level. */
value: 'view' | 'edit';
}) {
return (
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx
index 9d445df..42dda6f 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.stories.tsx
@@ -6,6 +6,7 @@ import { ViewModeSelector } from './view-mode-selector';
const meta: Meta = {
title: 'DocumentPage/ViewModeSelector',
component: ViewModeSelector,
+ tags: ['autodocs'],
parameters: {
layout: 'centered',
},
@@ -17,6 +18,9 @@ export default meta;
type Story = StoryObj;
+/**
+ * Edit/preview mode toggle group.
+ */
export const Default: Story = {
args: {},
};
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx
index 75cf63c..841605e 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/ViewModeSelector/view-mode-selector.tsx
@@ -9,11 +9,17 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/ToggleGroup';
import { cn } from '@/utils/cn';
interface ViewModeSelectorProps {
+ /** Active editor layout shown as the selected toggle. */
mode: 'edit' | 'both' | 'view';
+ /** Called with the selected layout when the toggle changes. */
setMode: (mode: 'edit' | 'both' | 'view') => void;
+ /** Extra classes merged onto the toggle group. */
className?: string;
}
+/**
+ * Toggle between editing and preview modes.
+ */
export const ViewModeSelector: React.FC = ({
mode,
setMode,
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx
index 82519b6..39a390f 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.stories.tsx
@@ -1,24 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
-import { MemoryRouter } from 'react-router';
import { WorkspaceInfo } from './workspace-info';
const meta: Meta = {
title: 'DocumentPage/WorkspaceInfo',
component: WorkspaceInfo,
+ tags: ['autodocs'],
parameters: {
layout: 'centered',
},
- decorators: (Story) => (
-
-
-
- ),
};
export default meta;
type Story = StoryObj;
+/**
+ * Workspace badge display.
+ */
export const Default: Story = {
args: {},
};
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx
index 6bfafea..4a5bd5a 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/WorkspaceInfo/workspace-info.tsx
@@ -4,6 +4,9 @@ import { Link } from 'react-router';
import { paths } from '@/config/paths';
+/**
+ * Static workspace label rendered in the header.
+ */
export const WorkspaceInfo: React.FC = () => {
return (
= {
title: 'DocumentPage/DocumentHeader',
component: DocumentHeader,
+ tags: ['autodocs'],
parameters: {
layout: 'fullscreen',
},
decorators: [
(Story) => (
-
-
-
-
-
+
+
+
),
],
args: {
@@ -29,12 +27,18 @@ const meta: Meta = {
export default meta;
type Story = StoryObj;
+/**
+ * Header with minimal props.
+ */
export const Default: Story = {
args: {
mode: 'both',
},
};
+/**
+ * Header with authenticated user present.
+ */
export const WithUser: Story = {
args: {
mode: 'edit',
@@ -44,6 +48,9 @@ export const WithUser: Story = {
},
};
+/**
+ * Header bound to a document.
+ */
export const WithDocument: Story = {
args: {
mode: 'view',
@@ -61,6 +68,9 @@ export const WithDocument: Story = {
},
};
+/**
+ * Dense collaborator avatar stack.
+ */
export const WithManyCollaborators: Story = {
args: {
mode: 'both',
@@ -91,6 +101,9 @@ export const WithManyCollaborators: Story = {
},
};
+/**
+ * Long document title truncation.
+ */
export const LongDocumentTitle: Story = {
args: {
mode: 'edit',
@@ -101,6 +114,9 @@ export const LongDocumentTitle: Story = {
},
};
+/**
+ * Collaborator UI hidden when the list is empty.
+ */
export const NoCollaborators: Story = {
args: {
mode: 'both',
@@ -110,6 +126,9 @@ export const NoCollaborators: Story = {
},
};
+/**
+ * Toolbar in edit mode.
+ */
export const EditMode: Story = {
args: {
mode: 'edit',
@@ -118,6 +137,9 @@ export const EditMode: Story = {
},
};
+/**
+ * Toolbar in view mode.
+ */
export const ViewMode: Story = {
args: {
mode: 'view',
diff --git a/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx b/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx
index 2ea6968..9fd180d 100644
--- a/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx
+++ b/client/src/features/DocumentPage/components/DocumentHeader/document-header.tsx
@@ -6,19 +6,33 @@ import { DocumentToolbar } from './DocumentToolbar/document-toolbar';
import { WorkspaceInfo } from './WorkspaceInfo';
export interface DocumentHeaderProps {
+ /** Active editor layout: edit-only, split, or view-only. */
mode: 'edit' | 'both' | 'view';
+ /** Called with the selected layout when it changes. */
setMode: (mode: 'edit' | 'both' | 'view') => void;
+ /** Signed-in username shown in the toolbar. */
username?: string;
+ /** Signed-in user's avatar image URL. */
avatarUrl?: string;
+ /** Signs the user out from the account menu. */
logout?: () => void;
+ /** Title of the open document. */
documentTitle?: string;
+ /** Creates a document with the given title from the header actions. */
onCreateDocument?: (title: string) => Promise;
+ /** Extra classes merged onto the header container. */
className?: string;
+ /** ID of the open document; enables document-scoped controls. */
docId?: string;
+ /** Disables editing controls for read-only viewers. */
isReadOnly?: boolean;
+ /** Unlocks collaborator-level controls on shared documents. */
isCollaborator?: boolean;
}
+/**
+ * Composes the document top bar: title, collaborators, share, view-mode and options controls.
+ */
export const DocumentHeader: React.FC = ({
mode,
setMode,
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.stories.tsx
new file mode 100644
index 0000000..d14c600
--- /dev/null
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.stories.tsx
@@ -0,0 +1,80 @@
+import { EditorState } from '@codemirror/state';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { EditorView } from 'codemirror';
+import { expect, fn, userEvent, within } from 'storybook/test';
+
+import { MarkdownStatusBar } from './markdown-status-bar';
+
+const meta: Meta = {
+ title: 'Features/DocumentPage/MarkdownStatusBar',
+ component: MarkdownStatusBar,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+const setUseTabsFn = fn();
+const setSpellcheckFn = fn();
+
+// NOTE: never pass an EditorView through `args` — its circular internals
+// send Storybook's JSON arg serialization into an infinite loop.
+
+export const WithoutView: Story = {
+ args: {
+ view: null,
+ useTabs: true,
+ setUseTabs: setUseTabsFn,
+ spellcheck: false,
+ setSpellcheck: setSpellcheckFn,
+ },
+};
+
+/** Clicking "Tab: 4" flips indentation mode even without a live view. */
+export const TogglesIndentationMode: Story = {
+ args: {
+ view: null,
+ useTabs: true,
+ setUseTabs: setUseTabsFn,
+ spellcheck: false,
+ setSpellcheck: setSpellcheckFn,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByText(/^tab: 4$/i));
+ await expect(setUseTabsFn).toHaveBeenCalledWith(false);
+ },
+};
+
+/** The first toggle control flips spellchecking. */
+export const TogglesSpellcheck: Story = {
+ args: {
+ view: null,
+ useTabs: true,
+ setUseTabs: setUseTabsFn,
+ spellcheck: false,
+ setSpellcheck: setSpellcheckFn,
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getAllByRole('button')[0]);
+ await expect(setSpellcheckFn).toHaveBeenCalledWith(true);
+ },
+};
+
+/** Smoke: renders live status against a real (unattached) editor view. */
+export const WithLiveView: Story = {
+ render: () => (
+ {}}
+ spellcheck={false}
+ setSpellcheck={() => {}}
+ />
+ ),
+};
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx
index 29f0e31..d7557da 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/markdown-status-bar.tsx
@@ -4,17 +4,26 @@ import { LuCheck, LuX } from 'react-icons/lu';
import { cn } from '@/utils/cn';
-import { useEditorStatus } from './useEditorStatus';
+import { useEditorStatus } from './use-editor-status';
export interface MarkdownStatusBarProps {
+ /** Extra classes merged onto the status bar container. */
className?: string;
+ /** Editor view whose cursor/document stats are displayed. */
view: EditorView | null;
+ /** Whether tab indentation is enabled (vs 4 spaces). */
useTabs: boolean;
+ /** Toggles tab indentation from the bar. */
setUseTabs: React.Dispatch>;
+ /** Whether spellcheck highlighting is enabled. */
spellcheck: boolean;
+ /** Toggles spellcheck from the bar. */
setSpellcheck: React.Dispatch>;
}
+/**
+ * Word/character/status readout derived from the editor view.
+ */
export const MarkdownStatusBar = ({
className,
view,
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/use-editor-status.ts
similarity index 80%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/use-editor-status.ts
index 9eccc1d..d90d2d0 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownStatusBar/use-editor-status.ts
@@ -2,6 +2,11 @@ import { StateEffect } from '@codemirror/state';
import { EditorView } from 'codemirror';
import { useEffect, useState } from 'react';
+/**
+ * Derives live editor status (counts, cursor info) from a CodeMirror view.
+ * @param view - Editor view to observe; status stays static while null.
+ * @returns Current line/column position, line total and document length.
+ */
export function useEditorStatus(view: EditorView | null) {
const [status, setStatus] = useState({
line: 1,
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.stories.tsx
new file mode 100644
index 0000000..f0048e5
--- /dev/null
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.stories.tsx
@@ -0,0 +1,73 @@
+import { history } from '@codemirror/commands';
+import { EditorState } from '@codemirror/state';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { EditorView } from 'codemirror';
+import { expect, userEvent, within } from 'storybook/test';
+
+import { MarkdownToolbar } from './markdown-toolbar';
+
+const meta: Meta = {
+ title: 'Features/DocumentPage/MarkdownToolbar',
+ component: MarkdownToolbar,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+let currentView: EditorView | null = null;
+const makeView = (doc: string) => {
+ // Stories remount without unmounting the previous one — destroy the old
+ // unattached view or it leaks for the rest of the browser session.
+ currentView?.destroy();
+ currentView = new EditorView({
+ // history() is part of basicSetup in the real editor; required for undo
+ state: EditorState.create({ doc, extensions: [history()] }),
+ });
+ return currentView;
+};
+
+export const Default: Story = {
+ render: () => ,
+};
+
+/** Clicking Bold wraps the selection with ** markers. */
+export const AppliesBold: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // select all text so the command has a target
+ const view = currentView!;
+ view.dispatch({
+ selection: { anchor: 0, head: view.state.doc.length },
+ });
+ await userEvent.click(await canvas.findByTitle('Bold'));
+ await expect(view.state.doc.toString()).toBe('**word**');
+ },
+};
+
+export const UndoRestores: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const view = currentView!;
+ view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
+ await userEvent.click(await canvas.findByTitle('Bold'));
+ await expect(view.state.doc.toString()).toBe('**plain**');
+ await userEvent.click(await canvas.findByTitle('Undo'));
+ await expect(view.state.doc.toString()).toBe('plain');
+ },
+};
+
+export const NullViewRendersInert: Story = {
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const bold = canvas.getByTitle('Bold');
+ // Buttons render enabled but clicking must be a no-op without a view —
+ // no throw, and the toolbar stays mounted and interactive.
+ await userEvent.click(bold);
+ await expect(bold).toBeEnabled();
+ await expect(canvas.getByTitle('Italic')).toBeEnabled();
+ },
+};
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx
index 6c72d26..e134fbb 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/markdown-toolbar.tsx
@@ -4,13 +4,18 @@ import { cn } from '@/utils/cn';
import { ToolbarButton } from './toolbar-button';
import { toolbarButtons } from './toolbar-buttons';
-import { useMarkdownCommands } from './useMarkdownCommands';
+import { useMarkdownCommands } from './use-markdown-commands';
+/**
+ * Formatting button row dispatching commands into the CodeMirror view.
+ */
export function MarkdownToolbar({
view,
className,
}: {
+ /** Editor view the formatting commands dispatch into. */
view: EditorView | null;
+ /** Extra classes merged onto the toolbar container. */
className?: string;
}) {
const { handleUndo, handleRedo, runCommand, runOrderedListCommand } =
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx
index 5a60461..39cf0d8 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-button.tsx
@@ -1,12 +1,18 @@
import React from 'react';
+/**
+ * Icon button wrapper for a single toolbar command.
+ */
export function ToolbarButton({
icon: Icon,
title,
onClick,
}: {
+ /** Icon component rendered inside the button. */
icon: React.ElementType;
+ /** Native tooltip and accessible name of the button. */
title: string;
+ /** Invoked when the button is clicked. */
onClick: () => void;
}) {
return (
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx
index 4680e8e..26e8a5f 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/toolbar-buttons.tsx
@@ -37,6 +37,9 @@ type ToolbarCommand =
}
| { type: 'divider' };
+/**
+ * Ordered registry of toolbar commands (undo/redo, inline formatting, headings).
+ */
export const toolbarButtons: ToolbarCommand[] = [
{ type: 'button', icon: LuUndo, title: 'Undo', action: 'undo' },
{ type: 'button', icon: LuRedo, title: 'Redo', action: 'redo' },
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/use-markdown-commands.tsx
similarity index 93%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/use-markdown-commands.tsx
index 31ff23e..3df760d 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownToolbar/use-markdown-commands.tsx
@@ -3,6 +3,11 @@ import { undo, redo } from '@codemirror/commands';
import { EditorSelection } from '@codemirror/state';
import { EditorView } from 'codemirror';
+/**
+ * Runs named markdown formatting commands against the editor view.
+ * @param view - Editor view to dispatch commands into; no-ops while null.
+ * @returns Command handlers wired to the toolbar buttons.
+ */
export function useMarkdownCommands(view: EditorView | null) {
const runCommand = (
before: string,
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorExtensions.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-extensions.ts
similarity index 91%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorExtensions.ts
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-extensions.ts
index a71eed5..55168b8 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorExtensions.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-extensions.ts
@@ -6,6 +6,9 @@ import { lineNumbers, gutter } from '@codemirror/view';
import { EditorView, keymap, highlightActiveLine } from '@codemirror/view';
// import { githubMarkdown } from "@codemirror/lang-markdown"; // optional preset
+/**
+ * CodeMirror extension list composing the markdown language, keymap and editor behavior plugins.
+ */
export const editorExtensions = [
markdown({
base: markdownLanguage,
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorTheme.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-theme.ts
similarity index 93%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorTheme.ts
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-theme.ts
index e479191..389f12d 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/EditorTheme.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/editor-theme.ts
@@ -2,6 +2,7 @@ import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorView } from '@codemirror/view';
import { tags } from '@lezer/highlight';
+/** Dracula palette and metric values driving the theme and highlight style. */
const config = {
name: 'dracula',
dark: true,
@@ -30,6 +31,7 @@ const config = {
invalid: '#FF5555',
regexp: '#F1FA8C',
};
+/** Editor-chrome theme (gutters, tooltips, selections) in the Dracula palette. */
const draculaTheme = EditorView.theme(
{
'&': {
@@ -95,6 +97,7 @@ const draculaTheme = EditorView.theme(
},
{ dark: config.dark },
);
+/** Syntax highlight rules mapping code/markdown tags to palette colors. */
const draculaHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: config.keyword },
{
@@ -150,6 +153,7 @@ const draculaHighlightStyle = HighlightStyle.define([
{ tag: tags.invalid, color: config.invalid },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
]);
+/** Combined theme + highlighting extension list consumed by MarkdownEditor. */
const MyTheme = [draculaTheme, syntaxHighlighting(draculaHighlightStyle)];
export { config, MyTheme, draculaHighlightStyle, draculaTheme };
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts
index 1808a93..3599b1b 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/index.ts
@@ -1 +1 @@
-export * from './MarkdownEditor';
+export * from './markdown-editor';
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/KeyMapExtension.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/key-map-extension.ts
similarity index 97%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/KeyMapExtension.ts
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/key-map-extension.ts
index c756e49..38d5b60 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/KeyMapExtension.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/key-map-extension.ts
@@ -1,5 +1,8 @@
import { EditorView } from 'codemirror';
+/**
+ * Named markdown formatting commands (toggle bold, italic, etc.) used by toolbar and shortcuts.
+ */
export const markdownCommands = {
toggleBold: (view: EditorView) => {
const { state } = view;
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.stories.tsx
new file mode 100644
index 0000000..f4ce2e9
--- /dev/null
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.stories.tsx
@@ -0,0 +1,75 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, within } from 'storybook/test';
+import * as Y from 'yjs';
+
+import { MarkdownEditor } from './markdown-editor';
+
+const meta: Meta = {
+ title: 'Features/DocumentPage/MarkdownEditor',
+ component: MarkdownEditor,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+/**
+ * The provider stub replaces the live websocket (D6 seam); awareness is
+ * left undefined, which the editor tolerates by skipping presence features.
+ */
+const stubProvider = {
+ destroy: () => {},
+};
+
+const makeYText = (initial = '') => {
+ const ydoc = new Y.Doc();
+ const ytext = ydoc.getText('content');
+ if (initial) ytext.insert(0, initial);
+ return ytext;
+};
+
+export const Editable: Story = {
+ render: () => (
+
+ {}}
+ />
+
+ ),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Status bar must reflect the live view (non-zero length) — guards
+ // against the toolbar/status receiving a stale null view on mount.
+ await canvas.findByText(/3 lines/i);
+ await expect(canvas.getByText(/length:/i).textContent).toMatch(
+ /length: (?!0\b)\d+/i,
+ );
+ },
+};
+
+export const ReadOnly: Story = {
+ render: () => (
+
+ {}}
+ isReadOnly
+ />
+
+ ),
+};
+
+export const EmptyDocument: Story = {
+ render: () => (
+
+ {}}
+ />
+
+ ),
+};
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownEditor.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.tsx
similarity index 76%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownEditor.tsx
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.tsx
index 3f66d74..1ef8021 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/MarkdownEditor.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownEditor/markdown-editor.tsx
@@ -9,14 +9,14 @@ import * as Y from 'yjs';
import { useAuth } from '@/context/auth';
import { cn } from '@/utils/cn';
-import { generateUserColor } from '@/utils/generateUserColor';
+import { generateUserColor } from '@/utils/generate-user-color';
-import { editorExtensions } from './EditorExtensions';
-import { MyTheme } from './EditorTheme';
-import { markdownCommands } from './KeyMapExtension';
+import { editorExtensions } from './editor-extensions';
+import { MyTheme } from './editor-theme';
+import { markdownCommands } from './key-map-extension';
import { MarkdownStatusBar } from './MarkdownStatusBar';
import { MarkdownToolbar } from './MarkdownToolbar';
-import { createAdvancedSpellcheckExtension } from './spellCheck';
+import { createAdvancedSpellcheckExtension } from './spell-check';
const markdownKeymap = keymap.of([
indentWithTab,
@@ -27,6 +27,9 @@ const markdownKeymap = keymap.of([
{ key: 'Mod-e', run: markdownCommands.insertCode },
]);
+/**
+ * CodeMirror 6 editor bound to Yjs collaborative types; routes local edits into ytext and applies remote updates.
+ */
export function MarkdownEditor({
ytext,
provider,
@@ -35,28 +38,32 @@ export function MarkdownEditor({
syncScroll,
isReadOnly,
}: {
+ /** Shared Yjs text bound to the CodeMirror view. */
ytext: Y.Text | null;
+ /** Collaboration provider supplying awareness state. */
provider: any;
+ /** Ref receiving the scrollable editor element for scroll syncing. */
editorScrollRef?: React.RefObject;
+ /** Scroll handler attached while synced scrolling is enabled. */
onScroll: () => void;
+ /** Attaches the parent's scroll handler when true. */
syncScroll?: boolean;
+ /** Disables editing and hides toolbar/status controls when true. */
isReadOnly?: boolean;
}) {
const { user } = useAuth(); // Get current user
const [spellcheck, setSpellcheck] = useState(false);
const [useTabs, setUseTabs] = useState(true);
+ // Held in state (not a ref): toolbar/status bar receive the view as a prop,
+ // and ref writes don't re-render — a ref here would hand them null until an
+ // unrelated re-render happened.
+ const [view, setView] = useState(null);
const editorRef = useRef(null);
- const viewRef = useRef(null);
useEffect(() => {
if (!editorRef.current || !ytext || !provider) return;
- // Clean up previous editor if remounting
- if (viewRef.current) {
- viewRef.current.destroy();
- }
-
if (user && provider.awareness) {
provider.awareness.setLocalStateField('user', {
name: user.username,
@@ -97,7 +104,7 @@ export function MarkdownEditor({
parent: editorRef.current,
});
- viewRef.current = view;
+ setView(view);
if (editorScrollRef) {
editorScrollRef.current = editorRef.current;
@@ -105,6 +112,7 @@ export function MarkdownEditor({
return () => {
view.destroy();
+ setView(null);
};
}, [ytext, provider, useTabs, spellcheck, editorScrollRef, isReadOnly, user]); // Add spellcheck to dependencies
@@ -126,7 +134,7 @@ export function MarkdownEditor({
{!isReadOnly && (
)}
@@ -136,7 +144,7 @@ export function MarkdownEditor({
ref={editorRef}
/>
= new Map();
@@ -223,6 +226,9 @@ class DictionaryManager {
}
// Suggestion widget for displaying spelling corrections
+/**
+ * CodeMirror widget rendering inline correction suggestions.
+ */
class SuggestionWidget extends WidgetType {
constructor(
private suggestions: string[],
@@ -469,6 +475,11 @@ const spellcheckTheme = EditorView.theme({
});
// Main spellcheck extension factory
+/**
+ * Assembles the spellcheck extension: misspelling decorations plus suggestion widgets.
+ * @param language - Hunspell dictionary locale to load. Defaults to 'en_US'.
+ * @returns CodeMirror extensions applying decorations and styling.
+ */
export function createAdvancedSpellcheckExtension(
language: string = 'en_US',
): Extension {
@@ -476,6 +487,9 @@ export function createAdvancedSpellcheckExtension(
}
// Custom word management
+/**
+ * Global custom-word store for words the user added to the dictionary.
+ */
export class SpellcheckManager {
private static customWords: Set = new Set();
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts
index 6c1863d..8c83320 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/index.ts
@@ -1 +1 @@
-export * from './MarkdownPreview';
+export * from './markdown-preview';
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx
index 323090a..cd9757f 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.stories.tsx
@@ -1,10 +1,11 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
-import { MarkdownPreview } from './MarkdownPreview';
+import { MarkdownPreview } from './markdown-preview';
const meta: Meta = {
component: MarkdownPreview,
+ tags: ['autodocs'],
title: 'Features/DocumentPage/MarkdownPreview',
};
@@ -25,6 +26,9 @@ const XSS_PAYLOAD = [
'',
].join('\n');
+/**
+ * Raw HTML attacks are stripped by sanitization.
+ */
export const SanitizesMaliciousHtml: StoryObj = {
args: {
content: XSS_PAYLOAD,
@@ -70,6 +74,9 @@ const MERMAID_DOC = [
'```',
].join('\n');
+/**
+ * Fenced mermaid blocks render as diagrams.
+ */
export const RendersMermaidDiagrams: StoryObj = {
args: {
content: MERMAID_DOC,
@@ -116,6 +123,9 @@ const RICH_DOC = [
'Math: $E = mc^2$',
].join('\n');
+/**
+ * Safe markdown features survive sanitization intact.
+ */
export const PreservesSafeMarkdownFeatures: StoryObj = {
args: {
content: RICH_DOC,
@@ -154,6 +164,9 @@ export const PreservesSafeMarkdownFeatures: StoryObj = {
},
};
+/**
+ * Grab the rendered prose container from the story canvas.
+ */
function getProse(canvasElement: HTMLElement): HTMLElement {
const preview = canvasElement.querySelector(
'.markdown-previewer',
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MarkdownPreview.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.tsx
similarity index 86%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MarkdownPreview.tsx
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.tsx
index a3f1a3b..89ffa25 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MarkdownPreview.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-preview.tsx
@@ -20,12 +20,16 @@ import {
import { dateFormat } from '@/utils/dateformat';
import { MarkdownToc } from './markdown-toc';
-import { MermaidDiagram } from './MermaidDiagram';
+import { MermaidDiagram } from './mermaid-diagram';
import { rehypeSupSub } from './rehype-subsuper';
+import { rehypeTextDecorations } from './remark-decorations';
import { remarkTypographer } from './remark-typographer';
-import { rehypeTextDecorations } from './remarkDecorations';
import { markdownSanitizeSchema } from './sanitize-schema';
+/**
+ * Sanitized markdown renderer: unified pipeline with GFM, math, mermaid,
+ * syntax highlighting and copy-code buttons over a hardened allow-list.
+ */
export function MarkdownPreview({
content,
lastUpdated,
@@ -33,10 +37,15 @@ export function MarkdownPreview({
onScroll,
syncScroll,
}: {
+ /** Raw markdown source to render. */
content: string;
+ /** Ref receiving the scrollable container for scroll syncing. */
previewScrollRef?: React.RefObject;
+ /** Scroll handler attached while synced scrolling is enabled. */
onScroll?: () => void;
+ /** Attaches the parent's scroll handler when true. */
syncScroll?: boolean;
+ /** ISO timestamp shown as "Last Edited"; falls back to "Unknown". */
lastUpdated?: string;
}) {
useEffect(() => {
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx
index ab5a6f8..b7e6549 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/markdown-toc.tsx
@@ -34,6 +34,9 @@ interface CollapsibleProps {
children: React.ReactNode;
}
+/**
+ * Local collapsible trigger primitive.
+ */
function CollapsibleTrigger({
children,
onClick,
@@ -57,6 +60,9 @@ function CollapsibleTrigger({
);
}
+/**
+ * Local collapsible content primitive.
+ */
function CollapsibleContent({
children,
className = '',
@@ -69,6 +75,9 @@ function CollapsibleContent({
);
}
+/**
+ * Local open-state container for the TOC groups.
+ */
function Collapsible({
open = false,
onOpenChange,
@@ -106,6 +115,11 @@ function Collapsible({
}
// Extract headings from markdown content
+/**
+ * Parse ATX markdown headings into flat TOC entries.
+ * @param markdown - Raw markdown source to scan.
+ * @returns Headings nested into a level-based tree.
+ */
function extractHeadings(markdown: string): TocHeading[] {
const headingRegex = /^(#{1,6})\s+(.+)$/gm;
const headings: TocHeading[] = [];
@@ -132,6 +146,9 @@ function extractHeadings(markdown: string): TocHeading[] {
}
// Build a nested tree structure from flat headings array
+/**
+ * Nest flat heading entries into a level-based tree.
+ */
function buildHeadingTree(headings: TocHeading[]): TocHeading[] {
const root: TocHeading[] = [];
const stack: TocHeading[] = [];
@@ -155,6 +172,9 @@ function buildHeadingTree(headings: TocHeading[]): TocHeading[] {
}
// Individual TOC item component
+/**
+ * Recursive TOC node renderer with anchor links.
+ */
function TocItem({
heading,
onHeadingClick,
@@ -221,15 +241,22 @@ function TocItem({
}
// Main TOC component
+/**
+ * Table-of-contents panel extracted from the document headings.
+ */
export function MarkdownToc({
content,
className = '',
onHeadingClick,
collapsible = true,
}: {
+ /** Raw markdown source headings are extracted from. */
content: string;
+ /** Extra classes merged onto the TOC panel. */
className?: string;
+ /** Notifies parents when a TOC entry is selected. */
onHeadingClick?: (id: string) => void;
+ /** Wraps entries in an expandable group. Defaults to true. */
collapsible?: boolean;
}) {
const [isOpen, setIsOpen] = useState(true);
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MermaidDiagram.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/mermaid-diagram.tsx
similarity index 90%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MermaidDiagram.tsx
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/mermaid-diagram.tsx
index 146f2c9..8dff07c 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/MermaidDiagram.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/mermaid-diagram.tsx
@@ -45,7 +45,15 @@ const SVG_SANITIZE_CONFIG: Config = {
],
};
-export function MermaidDiagram({ code }: { code: string }) {
+/**
+ * Renders fenced mermaid code to SVG asynchronously.
+ */
+export function MermaidDiagram({
+ code,
+}: {
+ /** Mermaid diagram source to render. */
+ code: string;
+}) {
const [svg, setSvg] = useState('');
const [failed, setFailed] = useState(false);
const renderId = useMemo(
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts
index 6934631..daa291f 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/rehype-subsuper.ts
@@ -3,6 +3,9 @@ import { visit } from 'unist-util-visit';
const supersubRegex = /(\^([^\s^]+)\^)|(_([^\s_]+)_)/g;
+/**
+ * Rehype plugin adding superscript/subscript handling to the preview pipeline.
+ */
export const rehypeSupSub: Plugin = () => {
return (tree: any) => {
visit(tree, 'text', (node: any, index, parent) => {
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remarkDecorations.tsx b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-decorations.tsx
similarity index 95%
rename from client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remarkDecorations.tsx
rename to client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-decorations.tsx
index 61e5f6b..3eac5bb 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remarkDecorations.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-decorations.tsx
@@ -4,6 +4,9 @@ import { visit } from 'unist-util-visit';
// Regex patterns for text decorations
const textDecorationRegex = /(\+\+([^+]+)\+\+)|(==([^=]+)==)/g;
+/**
+ * Rehype plugin applying extra inline text decorations during preview rendering.
+ */
export const rehypeTextDecorations: Plugin = () => {
return (tree: any) => {
visit(tree, 'text', (node: any, index, parent) => {
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts
index a0a71bd..e53186c 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/remark-typographer.ts
@@ -43,6 +43,9 @@ const typographerPatterns = [
},
];
+/**
+ * Remark plugin performing typographic adjustments on the markdown AST.
+ */
export const remarkTypographer: Plugin = () => {
return (tree: Node) => {
visit(tree, 'text', (node: any) => {
diff --git a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts
index 8969a6a..22305b8 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/MarkdownPreview/sanitize-schema.ts
@@ -3,6 +3,9 @@ import type { Options } from 'rehype-sanitize';
const HIGHLIGHT_CLASS_PATTERN = /^(hljs(-[\w-]+)?|language-[\w-]+)$/;
+/**
+ * Allow-list schema extending hast-util-sanitize defaults for the preview pipeline.
+ */
export const markdownSanitizeSchema: Options = {
...defaultSchema,
attributes: {
diff --git a/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx
new file mode 100644
index 0000000..c399e6b
--- /dev/null
+++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.stories.tsx
@@ -0,0 +1,87 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { expect, fn, within } from 'storybook/test';
+
+import type { CollabProviderFactory } from '@/hooks/use-collab';
+import type { DocumentData } from '@/types/api';
+
+import { DocumentMain } from './document-main';
+
+const meta: Meta = {
+ title: 'Features/DocumentPage/DocumentMain',
+ component: DocumentMain,
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+const mockDoc: DocumentData = {
+ id: 'story-doc',
+ title: 'Storybook Document',
+ content: '',
+ authorId: 'story-author',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+};
+
+/**
+ * Offline provider stub: seeds the shared Y.Doc so the editor/preview render
+ * real content without a websocket (D6 seam via the createProvider prop).
+ * Module-level so the factory identity stays stable across renders.
+ */
+const seededProviderFactory: CollabProviderFactory = (options) => {
+ const ytext = options.document.getText('content');
+ if (!ytext.length) {
+ ytext.insert(0, '# Storybook Document\n\nSynced without a websocket.');
+ }
+ return { destroy: () => {} };
+};
+
+const renderWith = (
+ mode: 'edit' | 'view',
+ isReadOnly?: boolean,
+): Story['render'] =>
+ function RenderedStory() {
+ return (
+
+
+
+ );
+ };
+
+export const EditMode: Story = {
+ render: renderWith('edit'),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // The seeded ytext must reach the editor — proves the provider stub
+ // actually feeds collaboration state instead of an empty doc.
+ await canvas.findByText(/Synced without a websocket/i);
+ },
+};
+
+export const ViewMode: Story = {
+ render: renderWith('view'),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Preview renders the seeded markdown as a heading + paragraph.
+ await canvas.findByRole('heading', { name: /storybook document/i });
+ await canvas.findByText(/synced without a websocket/i);
+ },
+};
+
+export const ReadOnly: Story = {
+ render: renderWith('edit', true),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await canvas.findByText(/synced without a websocket/i);
+ // Read-only: toolbar must not mount.
+ await expect(canvas.queryByTitle('Bold')).toBeNull();
+ },
+};
diff --git a/client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx
similarity index 87%
rename from client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx
rename to client/src/features/DocumentPage/components/DocumentMain/document-main.tsx
index ed3d6a8..ca676af 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/DocumentMain.tsx
+++ b/client/src/features/DocumentPage/components/DocumentMain/document-main.tsx
@@ -4,13 +4,16 @@ import { LuLink, LuUnlink } from 'react-icons/lu';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { Spinner } from '@/components/ui/Spinner';
-import { useCollab } from '@/hooks/useCollab';
+import { useCollab, type CollabProviderFactory } from '@/hooks/use-collab';
import { DocumentData } from '@/types/api';
import { cn } from '@/utils/cn';
import { MarkdownEditor } from './MarkdownEditor';
import { MarkdownPreview } from './MarkdownPreview';
+/**
+ * Wires collaboration state into either MarkdownEditor or MarkdownPreview per view mode.
+ */
export function DocumentMain({
docId,
mode,
@@ -18,15 +21,27 @@ export function DocumentMain({
setDoc,
className,
isReadOnly,
+ createProvider,
}: {
+ /** ID of the document to collaborate on; renders a spinner while absent. */
docId: string | undefined;
+ /** Active editor layout: edit-only, split, or view-only. */
mode: EditorMode;
+ /** Loaded document metadata; content is kept in sync with the editor. */
doc: DocumentData | null;
+ /** Persists editor text back into the parent's document state. */
setDoc: (doc: DocumentData) => void;
+ /** Extra classes merged onto the layout container. */
className?: string;
+ /** Renders the editor as read-only when true. */
isReadOnly?: boolean;
+ /** Provider factory override for tests/stories; live websocket by default. */
+ createProvider?: CollabProviderFactory;
}) {
- const { text, isReady, ydoc, ytext, provider } = useCollab(docId);
+ const { text, isReady, ydoc, ytext, provider } = useCollab(
+ docId,
+ createProvider,
+ );
const editorScrollRef = useRef(null);
const previewScrollRef = useRef(null);
const isSyncingRef = useRef(false);
diff --git a/client/src/features/DocumentPage/components/DocumentMain/index.ts b/client/src/features/DocumentPage/components/DocumentMain/index.ts
index 5f5041a..e7e7ab5 100644
--- a/client/src/features/DocumentPage/components/DocumentMain/index.ts
+++ b/client/src/features/DocumentPage/components/DocumentMain/index.ts
@@ -1 +1 @@
-export * from './DocumentMain';
+export * from './document-main';
diff --git a/client/src/hooks/use-auto-save.ts b/client/src/hooks/use-auto-save.ts
new file mode 100644
index 0000000..5d566da
--- /dev/null
+++ b/client/src/hooks/use-auto-save.ts
@@ -0,0 +1,25 @@
+import { useEffect } from 'react';
+
+import { DocumentData } from '@/types/api';
+
+/**
+ * Interval autosave hook: calls saveFn on a fixed interval.
+ *
+ * @param saveFn - Persist callback invoked on every tick.
+ * @param doc - Document draft watched by the effect; any identity change
+ * resets the timer, so a save fires intervalMs after the last change
+ * rather than on a fixed global schedule.
+ * @param intervalMs - Delay between ticks in ms. Defaults to 5000.
+ */
+export function useAutoSave(
+ saveFn: () => void,
+ doc: DocumentData,
+ intervalMs = 5000,
+) {
+ useEffect(() => {
+ const interval = setInterval(() => {
+ saveFn();
+ }, intervalMs);
+ return () => clearInterval(interval);
+ }, [saveFn, doc, intervalMs]);
+}
diff --git a/client/src/hooks/useCollab.ts b/client/src/hooks/use-collab.ts
similarity index 59%
rename from client/src/hooks/useCollab.ts
rename to client/src/hooks/use-collab.ts
index 41b0240..f2ac3c9 100644
--- a/client/src/hooks/useCollab.ts
+++ b/client/src/hooks/use-collab.ts
@@ -5,18 +5,45 @@ import * as Y from 'yjs';
import { env } from '@/config/env';
import { getAccessToken } from '@/utils/token';
-export function useCollab(docId: string | undefined) {
+/**
+ * Factory contract for creating the collaboration provider.
+ * The default builds a live HocuspocusProvider; tests/stories may supply
+ * an offline or stubbed adapter instead.
+ */
+export type CollabProviderFactory = (
+ options: ConstructorParameters[0] & {
+ url: string;
+ name: string;
+ document: Y.Doc;
+ },
+) => { destroy: () => void };
+
+const defaultProviderFactory: CollabProviderFactory = (options) =>
+ new HocuspocusProvider(options);
+
+/**
+ * Yjs collaboration session for a document id: creates a Y.Doc plus a provider
+ * (live websocket by default), mirrors ytext into React text state, and
+ * destroys everything on docId change/unmount.
+ *
+ * @param docId — room/document id to join.
+ * @param createProvider — optional provider factory override for tests/stories.
+ */
+export function useCollab(
+ docId: string | undefined,
+ createProvider: CollabProviderFactory = defaultProviderFactory,
+) {
const [text, setText] = useState('');
const [isReady, setIsReady] = useState(false);
const ydocRef = useRef(null);
const ytextRef = useRef(null);
- const providerRef = useRef(null);
+ const providerRef = useRef | null>(null);
useEffect(() => {
if (!docId) return;
const ydoc = new Y.Doc();
- const provider = new HocuspocusProvider({
+ const provider = createProvider({
url: env.Socket_URL, // Hocuspocus server URL
name: docId, // Room/document ID
document: ydoc,
@@ -46,7 +73,7 @@ export function useCollab(docId: string | undefined) {
providerRef.current = null;
setIsReady(false);
};
- }, [docId]);
+ }, [docId, createProvider]);
return {
text,
diff --git a/client/src/hooks/useCollaborators.ts b/client/src/hooks/use-collaborators.ts
similarity index 65%
rename from client/src/hooks/useCollaborators.ts
rename to client/src/hooks/use-collaborators.ts
index 3d4585a..92e70e8 100644
--- a/client/src/hooks/useCollaborators.ts
+++ b/client/src/hooks/use-collaborators.ts
@@ -3,20 +3,34 @@ import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { Collaborator } from '@/types/api';
+/**
+ * Collaborator management: fetch, add by email and remove, with loading
+ * and error state.
+ *
+ * @param docId - Document id whose collaborators are managed; fetching
+ * is skipped while undefined. Mount gating (owner/editor-only UI) lives
+ * in the caller; the fetch itself is allowed for any mounted docId.
+ * @returns Collaborator list, loading/error flags and the add/remove
+ * actions. `addCollaborator` resolves to whether the collaborator was
+ * added.
+ */
export const useCollaborators = (docId?: string) => {
const [collaborators, setCollaborators] = useState([]);
const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
useEffect(() => {
if (!docId) return;
const fetchCollaborators = async () => {
setLoading(true);
+ setError(null);
try {
const res = await api.get(`/document/${docId}/collaborators`);
setCollaborators(res.data || []);
} catch (err) {
console.error('Failed to fetch collaborators:', err);
+ setError('Failed to load collaborators');
} finally {
setLoading(false);
}
@@ -32,23 +46,28 @@ export const useCollaborators = (docId?: string) => {
setCollaborators((prev) => prev.filter((c) => c.id !== userId));
} catch (err) {
console.error('Failed to remove collaborator:', err);
+ setError('Failed to remove collaborator');
}
};
const addCollaborator = async (email: string) => {
- if (!docId || !email) return;
+ if (!docId || !email) return false;
try {
await api.post(`/document/${docId}/collaborators`, { email });
const res = await api.get(`/document/${docId}/collaborators`);
setCollaborators(res.data || []);
+ return true;
} catch (err) {
console.error('Failed to add collaborator:', err);
+ setError('Failed to add collaborator');
+ return false;
}
};
return {
collaborators,
loading,
+ error,
removeCollaborator,
addCollaborator,
};
diff --git a/client/src/hooks/useDocument.ts b/client/src/hooks/use-document.ts
similarity index 63%
rename from client/src/hooks/useDocument.ts
rename to client/src/hooks/use-document.ts
index dafe225..78a85d3 100644
--- a/client/src/hooks/useDocument.ts
+++ b/client/src/hooks/use-document.ts
@@ -3,6 +3,15 @@ import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { DocumentData } from '@/types/api';
+/**
+ * Fetches a document into doc plus an editedDoc draft, exposes save
+ * (handleSave), permission flags (access) and loading/saving/error state.
+ *
+ * @param id - Document id to load; refetches whenever it changes and
+ * skips loading entirely when undefined.
+ * @returns Server doc, editable draft with setter, save action, access
+ * flags and loading/saving/error state.
+ */
export function useDocument(id?: string) {
const [doc, setDoc] = useState(null);
const [editedDoc, setEditedDoc] = useState(null);
@@ -13,6 +22,7 @@ export function useDocument(id?: string) {
isCollaborator: boolean;
permission: 'view' | 'edit';
} | null>(null);
+ const [error, setError] = useState(null);
useEffect(() => {
if (!id) {
@@ -21,6 +31,7 @@ export function useDocument(id?: string) {
}
setLoading(true);
+ setError(null);
api
.get(`/document/${id}`)
@@ -31,6 +42,7 @@ export function useDocument(id?: string) {
})
.catch((err) => {
console.error('Failed to fetch document:', err);
+ setError('Failed to load document');
setAccess(null); // ensure we don’t reuse old access
})
.finally(() => {
@@ -39,13 +51,19 @@ export function useDocument(id?: string) {
}, [id]);
const handleSave = useCallback(async () => {
- if (!id) return;
+ if (!id || !editedDoc) return;
try {
setSaving(true);
- await api.put(`/document/${id}`, editedDoc);
+ setError(null);
+ // Content is owned by Yjs sync; REST persists metadata only (issue #47).
+ await api.put(`/document/${id}`, {
+ title: editedDoc.title,
+ isPublic: editedDoc.isPublic,
+ });
setDoc(editedDoc);
} catch (err) {
console.error('Failed to save document:', err);
+ setError('Failed to save document');
} finally {
setSaving(false);
}
@@ -59,5 +77,6 @@ export function useDocument(id?: string) {
saving,
handleSave,
access,
+ error,
};
}
diff --git a/client/src/hooks/useJoinRequests.ts b/client/src/hooks/use-join-requests.ts
similarity index 53%
rename from client/src/hooks/useJoinRequests.ts
rename to client/src/hooks/use-join-requests.ts
index 32daed2..603a42e 100644
--- a/client/src/hooks/useJoinRequests.ts
+++ b/client/src/hooks/use-join-requests.ts
@@ -1,51 +1,60 @@
import { useCallback, useEffect, useState } from 'react';
-import { api } from '@/lib/api';
+import {
+ approveJoinRequest,
+ getJoinRequests,
+ rejectJoinRequest,
+} from '@/lib/join-requests-api';
import { CollaborationRequest } from '@/types/api';
-export const getJoinRequests = async (docId: string) => {
- const res = await api.get(`/document/${docId}/requests`);
- return res.data;
-};
-
-export const approveJoinRequest = async (docId: string, requestId: string) => {
- const res = await api.post(
- `/document/${docId}/requests/${requestId}/approve`,
- );
- return res.data;
-};
-
-export const rejectJoinRequest = async (docId: string, requestId: string) => {
- const res = await api.delete(
- `/document/${docId}/requests/${requestId}/reject`,
- );
- return res.data;
-};
-
+/**
+ * Manages collaboration requests for a document.
+ * Fetches pending requests when the user is not yet a collaborator and
+ * exposes approve/reject actions that update the local list optimistically.
+ *
+ * @param docId — id of the document whose requests to manage.
+ * @param isCollaborator — when true, request polling is skipped.
+ * @returns requests list, loading flag, approve/reject actions and a refetch.
+ */
export function useJoinRequests(
docId?: string | null,
isCollaborator?: boolean,
) {
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
const fetchRequests = useCallback(async () => {
try {
setLoading(true);
+ setError(null);
const data = await getJoinRequests(docId!);
setRequests(data);
} catch (err) {
console.error('Failed to fetch join requests:', err);
+ setError('Failed to load join requests');
} finally {
setLoading(false);
}
}, [docId]);
+ /**
+ * Approves a request by id and removes it from the local list.
+ *
+ * @param requestId - Join request id to approve; removed from the
+ * local list once the API call resolves.
+ */
const approve = async (requestId: string) => {
await approveJoinRequest(docId!, requestId);
setRequests((prev) => prev.filter((r) => r.id !== requestId));
};
+ /**
+ * Rejects a request by id and removes it from the local list.
+ *
+ * @param requestId - Join request id to reject; removed from the
+ * local list once the API call resolves.
+ */
const reject = async (requestId: string) => {
await rejectJoinRequest(docId!, requestId);
setRequests((prev) => prev.filter((r) => r.id !== requestId));
@@ -59,6 +68,7 @@ export function useJoinRequests(
return {
requests,
loading,
+ error,
approve,
reject,
refetch: fetchRequests,
diff --git a/client/src/hooks/useMediaQuery.ts b/client/src/hooks/use-media-query.ts
similarity index 65%
rename from client/src/hooks/useMediaQuery.ts
rename to client/src/hooks/use-media-query.ts
index 1ae3f40..71dfa79 100644
--- a/client/src/hooks/useMediaQuery.ts
+++ b/client/src/hooks/use-media-query.ts
@@ -1,5 +1,13 @@
import { useEffect, useState } from 'react';
+/**
+ * Live boolean match state for a CSS media query string.
+ *
+ * @param query - CSS media query to watch, e.g. '(min-width: 768px)';
+ * the listener re-subscribes whenever it changes.
+ * @returns Whether the query currently matches, kept live via the
+ * matchMedia change event.
+ */
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(
() => window.matchMedia(query).matches,
diff --git a/client/src/hooks/use-share-link.ts b/client/src/hooks/use-share-link.ts
new file mode 100644
index 0000000..8752dbc
--- /dev/null
+++ b/client/src/hooks/use-share-link.ts
@@ -0,0 +1,64 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { api } from '@/lib/api';
+
+/**
+ * Fetches (and can refresh) a share link for a permission level;
+ * surfaces shareLink, loading and error. Concurrent fetches resolve
+ * last-request-wins, so a stale response never overwrites a newer one,
+ * and any previous link is invalidated whenever a new fetch starts.
+ *
+ * @param docId - Document id whose share link is fetched; fetching is
+ * skipped while undefined.
+ * @param permission - Permission level used for the automatic fetch;
+ * falls back to 'view' while undefined.
+ * @param isCollaborator - When true, the automatic fetch effect is
+ * skipped.
+ * @returns Share link URL, loading/error flags and a manual refresh
+ * requiring the permission to fetch.
+ */
+export const useShareLink = (
+ docId?: string,
+ permission?: 'view' | 'edit',
+ isCollaborator?: boolean,
+) => {
+ const [shareLink, setShareLink] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const requestRef = useRef(0);
+
+ const fetchShareLink = useCallback(
+ async (permission: 'view' | 'edit') => {
+ if (!docId) return;
+ const requestId = ++requestRef.current;
+ // Invalidate any previous link up front so a stale URL can never be
+ // displayed or copied while loading or after a failure
+ setShareLink('');
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await api.get(`/document/${docId}/share-link`, {
+ params: { permission },
+ });
+ if (requestId !== requestRef.current) return;
+ setShareLink(res.data?.url ?? '');
+ } catch (err: any) {
+ if (requestId !== requestRef.current) return;
+ setError('Failed to fetch share link');
+ console.error(err);
+ } finally {
+ if (requestId === requestRef.current) setLoading(false);
+ }
+ },
+ [docId],
+ );
+
+ useEffect(() => {
+ // Fetch on mount and whenever the selected permission changes
+ if (!isCollaborator && docId) {
+ fetchShareLink(permission ?? 'view');
+ }
+ }, [docId, permission, isCollaborator, fetchShareLink]);
+
+ return { shareLink, loading, error, refresh: fetchShareLink };
+};
diff --git a/client/src/hooks/useAutoSave.ts b/client/src/hooks/useAutoSave.ts
deleted file mode 100644
index 675950b..0000000
--- a/client/src/hooks/useAutoSave.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useEffect } from 'react';
-
-import { DocumentData } from '@/types/api';
-
-export function useAutoSave(
- saveFn: () => void,
- doc: DocumentData,
- intervalMs = 5000,
-) {
- useEffect(() => {
- const interval = setInterval(() => {
- saveFn();
- }, intervalMs);
- return () => clearInterval(interval);
- }, [saveFn, doc, intervalMs]);
-}
diff --git a/client/src/hooks/useShareLink.ts b/client/src/hooks/useShareLink.ts
deleted file mode 100644
index bd0126f..0000000
--- a/client/src/hooks/useShareLink.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-
-import { api } from '@/lib/api';
-
-export const useShareLink = (
- docId?: string,
- permission?: 'view' | 'edit',
- isCollaborator?: boolean,
-) => {
- const [shareLink, setShareLink] = useState('');
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const fetchShareLink = useCallback(
- async (permission: 'view' | 'edit' = 'view') => {
- if (!docId) return;
- setLoading(true);
- setError(null);
- try {
- const res = await api.get(`/document/${docId}/share-link`, {
- params: { permission },
- });
- setShareLink(res.data?.url ?? '');
- } catch (err: any) {
- setError('Failed to fetch share link');
- console.error(err);
- } finally {
- setLoading(false);
- }
- },
- [docId],
- );
-
- useEffect(() => {
- if (!isCollaborator) {
- fetchShareLink(permission);
- } // Fetch on mount with default permission
- }, [docId, permission, isCollaborator, fetchShareLink]);
-
- return { shareLink, loading, error, refresh: fetchShareLink };
-};
diff --git a/client/src/lib/__tests__/api.test.tsx b/client/src/lib/__tests__/api.test.tsx
deleted file mode 100644
index e69de29..0000000
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index 76b6e0c..eb50be2 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -3,19 +3,15 @@ import axios from 'axios';
import { env } from '@/config/env';
import { getAccessToken } from '@/utils/token';
+/**
+ * Shared axios instance for all API calls.
+ * Attaches the stored access token to every request.
+ */
export const api = axios.create({
baseURL: `${env.API_URL}/api`,
withCredentials: true,
});
-// a refresh API instance to handle token refresh
-// to avoid circular dependency issues
-
-// const refreshApi = axios.create({
-// baseURL: env.API_URL,
-// withCredentials: true,
-// });
-
api.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
@@ -23,34 +19,3 @@ api.interceptors.request.use((config) => {
}
return config;
});
-
-// api.interceptors.response.use(
-// (res) => res,
-// async (error) => {
-// const originalRequest = error.config;
-
-// if (error.response?.status === 401 && !originalRequest._retry) {
-// originalRequest._retry = true;
-
-// try {
-// const res = await refreshApi.get('/auth/refresh');
-// const newAccessToken = res.data.accessToken;
-// setAccessToken(newAccessToken);
-
-// originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
-// return api(originalRequest);
-// } catch (err) {
-// // TODO: clear token, redirect to login, etc.
-// console.error('Token refresh failed:', err);
-// const searchParams = new URLSearchParams();
-// const redirectTo =
-// searchParams.get('redirectTo') || window.location.pathname;
-// window.location.href = paths.auth.login.getHref(redirectTo);
-
-// return Promise.reject(err);
-// }
-// }
-
-// return Promise.reject(error);
-// },
-// );
diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts
index f70b2d5..b966778 100644
--- a/client/src/lib/auth.ts
+++ b/client/src/lib/auth.ts
@@ -6,6 +6,10 @@ import { useAuth } from '@/context/auth';
import { api } from './api';
+/**
+ * Zod schema validating registration input: valid email, username (min 3
+ * characters), password (min 8 characters), and optional full name.
+ */
export const RegisterSchema = z.object({
email: z.string().email(),
username: z.string().min(3),
@@ -13,6 +17,9 @@ export const RegisterSchema = z.object({
fullName: z.string().optional(),
});
+/**
+ * zod schema validating login credentials (email format + required password).
+ */
export const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
@@ -21,6 +28,10 @@ export const LoginSchema = z.object({
export type RegisterSchemaType = z.infer;
export type LoginSchemaType = z.infer;
+/**
+ * Registers a user by POSTing the data to /auth/register. Resolves true on
+ * success, false on failure (the error is logged, not thrown).
+ */
export const RegisterUser = async (
data: RegisterSchemaType,
): Promise => {
@@ -33,6 +44,10 @@ export const RegisterUser = async (
}
};
+/**
+ * Logs a user in by POSTing credentials to /auth/login and resolving with the
+ * response payload (access/refresh tokens and user); rethrows on failure.
+ */
export const LoginUser = async (data: LoginSchemaType) => {
try {
const res = await api.post('/auth/login', data);
@@ -43,6 +58,9 @@ export const LoginUser = async (data: LoginSchemaType) => {
}
};
+/**
+ * Route guard redirecting unauthenticated users away from protected children.
+ */
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const auth = useAuth();
const location = useLocation();
diff --git a/client/src/lib/join-requests-api.ts b/client/src/lib/join-requests-api.ts
new file mode 100644
index 0000000..372d3b4
--- /dev/null
+++ b/client/src/lib/join-requests-api.ts
@@ -0,0 +1,35 @@
+import { api } from '@/lib/api';
+
+/**
+ * Fetches all pending collaboration requests for a document.
+ * @param docId — id of the document whose requests to fetch.
+ * @returns list of pending collaboration requests.
+ */
+export const getJoinRequests = async (docId: string) => {
+ const res = await api.get(`/document/${docId}/requests`);
+ return res.data;
+};
+
+/**
+ * Approves a pending collaboration request, granting the requester access.
+ * @param docId — id of the document the request targets.
+ * @param requestId — id of the request to approve.
+ */
+export const approveJoinRequest = async (docId: string, requestId: string) => {
+ const res = await api.post(
+ `/document/${docId}/requests/${requestId}/approve`,
+ );
+ return res.data;
+};
+
+/**
+ * Rejects a pending collaboration request.
+ * @param docId — id of the document the request targets.
+ * @param requestId — id of the request to reject.
+ */
+export const rejectJoinRequest = async (docId: string, requestId: string) => {
+ const res = await api.delete(
+ `/document/${docId}/requests/${requestId}/reject`,
+ );
+ return res.data;
+};
diff --git a/client/src/lib/rehypeCopyButton.ts b/client/src/lib/rehype-copy-button.ts
similarity index 84%
rename from client/src/lib/rehypeCopyButton.ts
rename to client/src/lib/rehype-copy-button.ts
index e658c24..8b815a1 100644
--- a/client/src/lib/rehypeCopyButton.ts
+++ b/client/src/lib/rehype-copy-button.ts
@@ -1,6 +1,10 @@
import type { Root, Element } from 'hast';
import { visit } from 'unist-util-visit';
+/**
+ * Rehype plugin that appends a 🔗 button to every heading that has an id;
+ * the button copies `#` to the clipboard on click.
+ */
export function rehypeCopyHeadingLinks() {
return (tree: Root) => {
visit(tree, 'element', (node) => {
diff --git a/client/src/utils/cn.ts b/client/src/utils/cn.ts
index 9ad0df4..29dae18 100644
--- a/client/src/utils/cn.ts
+++ b/client/src/utils/cn.ts
@@ -1,6 +1,9 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
+/**
+ * Merge conditional Tailwind class values, resolving conflicts via tailwind-merge.
+ */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
diff --git a/client/src/utils/dateformat.ts b/client/src/utils/dateformat.ts
index fb775ff..954bdc3 100644
--- a/client/src/utils/dateformat.ts
+++ b/client/src/utils/dateformat.ts
@@ -1,5 +1,8 @@
import { formatDistanceToNow } from 'date-fns';
+/**
+ * Humanized relative timestamp ("about 5 minutes ago") via date-fns.
+ */
export function dateFormat(date: Date) {
const timeAgo = formatDistanceToNow(date, {
addSuffix: true,
diff --git a/client/src/utils/generateUserColor.ts b/client/src/utils/generate-user-color.ts
similarity index 83%
rename from client/src/utils/generateUserColor.ts
rename to client/src/utils/generate-user-color.ts
index c0d93e5..7d1f238 100644
--- a/client/src/utils/generateUserColor.ts
+++ b/client/src/utils/generate-user-color.ts
@@ -1,3 +1,6 @@
+/**
+ * Deterministically pick an avatar color from a fixed palette based on userId.
+ */
export function generateUserColor(userId: string): string {
const colors = [
'#FF6B6B',
diff --git a/client/src/utils/token.ts b/client/src/utils/token.ts
index c287994..f85d6b5 100644
--- a/client/src/utils/token.ts
+++ b/client/src/utils/token.ts
@@ -1,11 +1,20 @@
let accessToken: string | null = null;
+/**
+ * Store the access token in module scope for the axios interceptor.
+ */
export const setAccessToken = (token: string) => {
accessToken = token;
};
+/**
+ * Current access token, or null when signed out.
+ */
export const getAccessToken = () => accessToken;
+/**
+ * Clear the stored access token.
+ */
export const clearAccessToken = () => {
accessToken = null;
};
diff --git a/client/vite.config.ts b/client/vite.config.ts
index 3d9104f..58a6bc2 100644
--- a/client/vite.config.ts
+++ b/client/vite.config.ts
@@ -21,6 +21,22 @@ export default defineConfig({
'@': path.resolve(__dirname, 'src'), // ⬅️ this is required
},
},
+ optimizeDeps: {
+ // Pre-bundled on first storybook-test run otherwise, which reloads the
+ // browser mid-run and fails imports ("Failed to fetch dynamically
+ // imported module"). Keep in sync with heavy deps used by stories.
+ include: [
+ 'react-dom/client',
+ '@hocuspocus/provider',
+ 'yjs',
+ 'y-codemirror.next',
+ 'codemirror',
+ '@codemirror/view',
+ '@codemirror/state',
+ '@codemirror/language',
+ 'react-resizable-panels',
+ ],
+ },
test: {
projects: [
{
@@ -34,6 +50,9 @@ export default defineConfig({
],
test: {
name: 'storybook',
+ // One browser instance already stretches CI runners; parallel
+ // files alongside the server suite starves vitest's runner.
+ fileParallelism: false,
browser: {
enabled: true,
headless: true,
diff --git a/docs/adr/0001-defer-auth-token-consolidation.md b/docs/adr/0001-defer-auth-token-consolidation.md
new file mode 100644
index 0000000..ea8ab7d
--- /dev/null
+++ b/docs/adr/0001-defer-auth-token-consolidation.md
@@ -0,0 +1,37 @@
+# ADR 0001: Defer auth token consolidation
+
+- **Status:** Accepted (deferred)
+- **Date:** 2026-08-23
+- **Context:** Client Quality Initiative (`docs/superpowers/specs/2026-08-23-client-quality-design.md` §6)
+
+## Context
+
+During the client quality survey, the access-token lifecycle was found to be
+spread across three stores simultaneously:
+
+1. `AuthContext` React state (`context/auth/auth-provider.tsx`)
+2. Module-level storage in `utils/token.ts` (read by the axios request interceptor)
+3. Mutated axios defaults (`api.defaults.headers.common.Authorization`)
+
+Additionally, a hidden `localStorage['wasLoggedOut']` flag drives mount-time
+refresh behavior, and `lib/api.ts` contained an entire commented-out
+401-refresh interceptor — a competing design for the same concern, left as
+archaeology. Understanding "how does auth work" requires bouncing across four
+or more files; no single module owns token lifecycle.
+
+## Decision
+
+Defer consolidation to a dedicated future initiative. The client-quality work
+limited itself to deleting the dead interceptor code and documenting current
+behavior honestly.
+
+## Consequences
+
+- Auth remains triplicated; changes to token handling must touch all three
+ stores. Anyone touching auth should read `auth-provider.tsx`, `utils/token.ts`,
+ and `lib/api.ts` together.
+- The consolidation deserves its own spec: it alters login/refresh/logout flows
+ with server-coupled behavior and cannot ride along a documentation initiative.
+- Future architecture reviews should NOT re-flag this as a quick fix — it was
+ assessed during 2026-08 and deliberately deferred (this record exists so the
+ analysis isn't repeated).
diff --git a/docs/superpowers/plans/2026-08-23-client-quality.md b/docs/superpowers/plans/2026-08-23-client-quality.md
new file mode 100644
index 0000000..e00ce44
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-23-client-quality.md
@@ -0,0 +1,468 @@
+# Client Quality Initiative Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Normalize client structure, enforce JSDoc via lint, achieve full Storybook state coverage with stories-as-tests, wired into CI.
+
+**Architecture:** Seven-layer stacked PR flow managed by `gh-stack`, rooted on `develop`. Each layer is one PR; each task inside a layer is its own commit. Every layer ends green on local gates before the next begins.
+
+**Tech Stack:** Storybook 9 + `@storybook/addon-vitest` (browser mode, Playwright chromium), vitest 3, ESLint flat config + `eslint-plugin-check-file` + `eslint-plugin-jsdoc` (only new dep, dev), plop, gh-stack.
+
+**Spec:** `docs/superpowers/specs/2026-08-23-client-quality-design.md`
+
+## Stack Layout
+
+```
+develop (trunk)
+└── client-quality/docs ← spec + this plan (PR1)
+ └── client-quality/a-infra ← CI filters, test wiring, plop (PR2)
+ └── client-quality/b-structure ← all renames/moves (PR3)
+ └── client-quality/c-rules ← naming rule flip + jsdoc warn (PR4)
+ └── client-quality/d-seams ← JSDoc backfill + error fields + D6 (PR5)
+ └── client-quality/e-stories ← decorators + stories + play tests (PR6)
+ └── client-quality/f-ci ← CI revert, AGENTS.md, ADR 0001 (PR7)
+```
+
+Merge strictly bottom-up via `gh stack merge --yes`; never plain `gh pr merge`.
+
+## Global Constraints
+
+- Directories PascalCase, files kebab-case (all `.ts`/`.tsx`)
+- Feature layering untouched (`import/no-restricted-paths` zones stay as-is)
+- No new runtime dependencies; sole new dev dependency: `eslint-plugin-jsdoc`
+- No MSW/network mocking — story data via props and decorators
+- All file moves via `git mv`
+- Existing Playwright e2e must stay green throughout
+- One task = one commit; D6 is isolated with its own e2e gate and drop-out policy
+- Per-layer verification: `pnpm --filter client typecheck && pnpm --filter client build` minimum; plus `pnpm --filter client test` from Layer 1 onward; plus `pnpm lint` from Layer 3 onward
+
+---
+
+## Layer 0: `client-quality/docs`
+
+### Task 0.1: Commit design spec ✅ (done — 34f7cd9)
+
+### Task 0.2: Commit implementation plan
+
+- [x] Write this document
+- [ ] `git add docs/superpowers/plans/2026-08-23-client-quality.md && git commit -m "docs: client quality initiative implementation plan"`
+- [ ] `gh stack submit --auto` → creates draft PR1 (docs)
+
+---
+
+## Layer 1: `client-quality/a-infra`
+
+Create with: `gh stack add client-quality/a-infra`
+
+### Task 1.1: Extend CI branch filters
+
+**Files:** Modify `.github/workflows/lint-type-check.yml`, `.github/workflows/e2e.yml`
+
+In both files, change the `on.pull_request.branches` and `on.push.branches` lists:
+
+```yaml
+branches: [main, develop, 'client-quality/**']
+```
+
+(Leave everything else untouched; removal of `'client-quality/**'` happens in Task 6.1.)
+
+- [ ] Edit both workflows
+- [ ] Commit: `ci: trigger workflows for stacked client-quality branches`
+
+### Task 1.2: Wire the test runner
+
+**Files:** Modify `client/package.json`, root `package.json`
+
+- [ ] In `client/package.json` scripts add: `"test": "vitest run",`
+- [ ] In root `package.json` scripts add: `"test:client": "pnpm --filter client test",` (joins the existing `run-p test:*` chain automatically)
+- [ ] Local prerequisite (not committed): `pnpm exec playwright install chromium`
+- [ ] Verify: `pnpm --filter client test` runs the existing 24 story files and passes
+- [ ] Commit: `chore(client): wire vitest browser test runner`
+
+### Task 1.3: Delete empty orphaned test
+
+**Files:** Delete `client/src/lib/__tests__/api.test.tsx` (verified 0 lines) and the now-empty `__tests__/` dir
+
+- [ ] Delete via `git rm client/src/lib/__tests__/api.test.tsx`
+- [ ] Verify typecheck still green
+- [ ] Commit: `chore(client): remove empty orphaned test file`
+
+### Task 1.4: Plop templates emit compliant scaffolding
+
+**Files:** Modify `client/generators/component/component.tsx.hbs`, `client/generators/component/component.stories.tsx.hbs`
+
+New component template:
+
+```hbs
+/**
+ * {{pascalCase name}} — TODO: one-line description of purpose.
+ */
+import React from 'react';
+
+export interface {{pascalCase name}}Props {
+ // define props
+}
+
+export const {{pascalCase name}} = ({}: {{pascalCase name}}Props) => {
+ return {{pascalCase name}} works!
;
+};
+```
+
+Note: import order must satisfy the repo's `import/order` rule once real props exist; keep `React` import first-line external group as generated today.
+
+New stories template:
+
+```hbs
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { {{pascalCase name}} } from './{{kebabCase name}}';
+
+const meta: Meta = {
+ title: '{{titlePath}}/{{pascalCase name}}',
+ component: {{pascalCase name}},
+ tags: ['autodocs'],
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+/*
+Example interaction test:
+import { expect } from 'storybook/test';
+export const Clicked: Story = {
+ args: {},
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole('button'));
+ await expect(/* assertion *\/).toHaveBeenCalled();
+ },
+};
+*/
+```
+
+- [ ] Update both templates (keep `index.cjs` logic unchanged)
+- [ ] Verify: `pnpm --filter client generate`, generate a throwaway component into Root, confirm shape, then discard the generated files before committing
+- [ ] Commit: `chore(client): scaffold components with autodocs and jsdoc stubs`
+
+---
+
+## Layer 2: `client-quality/b-structure`
+
+Create with: `gh stack add client-quality/b-structure`. Every task: `git mv` + fix imports + verify (`typecheck && build`). Commit per task.
+
+### Task 2.1 (B1): Layouts → folder-per-component
+
+| From | To |
+|---|---|
+| `src/components/layouts/AuthLayout.tsx` | `src/components/layouts/AuthLayout/auth-layout.tsx` + `AuthLayout/index.ts` |
+| `src/components/layouts/ContentLayout.tsx` | `ContentLayout/content-layout.tsx` + index |
+| `src/components/layouts/DashboardLayout.tsx` | `DashboardLayout/dashboard-layout.tsx` + index |
+| `src/components/layouts/DocumentLayout.tsx` | `DocumentLayout/document-layout.tsx` + index |
+
+index.ts content: `export { default } from './auth-layout';` (adjust per named/default export found on read). Fix any deep imports discovered via grep.
+
+Commit: `refactor(client): folder-per-component for layouts`
+
+### Task 2.2 (B2): context/auth normalization
+
+| From | To |
+|---|---|
+| `src/context/auth/AuthContext.tsx` | `auth-context.tsx` |
+| `src/context/auth/AuthProvider.tsx` | `auth-provider.tsx` |
+| `src/context/auth/useAuth.tsx` | `use-auth.tsx` |
+
+Update `context/auth/index.ts` re-export paths.
+
+Commit: `refactor(client): kebab-case auth context files`
+
+### Task 2.3 (B3): ui oddballs + Form/Input dissolution
+
+1. `git mv src/components/ui/seo src/components/ui/Seo`; `git mv Seo/Head.tsx Seo/head.tsx`; create `Seo/index.ts`
+2. `git mv src/components/ui/auth src/components/ui/Auth`
+3. `git mv src/components/ui/Header/Header.stories.tsx …/header.stories.tsx`
+4. **Dissolve `Form/Input/`:**
+ - `git mv src/components/ui/Form/Input/input-field.tsx src/components/ui/Form/input.tsx`
+ - `git mv src/components/ui/Form/Input/variants.ts src/components/ui/Form/variants.ts`
+ - Delete `Form/Input/index.ts`; create `Form/index.ts`: `export * from './input';`
+ - In `input.tsx`: `'../field-wrapper'` → `'./field-wrapper'`
+ - In `input.stories.tsx`: `'./Input'` → `'./input'`
+ - Update 3 external imports: `@/components/ui/Form/Input` → `@/components/ui/Form` (`NewDocumentFormBody.tsx`, `login-form.tsx`, `register-form.tsx`)
+5. Audit `Seo/Auth/Header` internals for remaining PascalCase files (grep sweep must come back clean except intentionally-PascalCase dirs)
+
+Verify + Storybook smoke test: `timeout 60 pnpm --filter client exec storybook --ci --smoke-test` (or equivalent headless boot check).
+
+Commit: `refactor(client): normalize ui directory casing and dissolve Form/Input`
+
+### Task 2.4 (B4): Feature fixes
+
+1. `git mv src/features/Dashboard/components/DashBoardMain src/features/Dashboard/components/DashboardMain`
+2. `git mv src/components/common/forms/NewDocumentFormBody.tsx src/components/common/NewDocumentFormBody/new-document-form-body.tsx` + `index.ts`; remove now-empty `common/forms/`; update 2 imports (`create-document-button.tsx`, `new-document-modal.tsx`) to barrel form `@/components/common/NewDocumentFormBody`
+
+Commit: `refactor(client): fix DashboardMain casing, relocate shared NewDocumentFormBody`
+
+### Task 2.5 (B5): Hooks renames (7 files, 6 verified import sites)
+
+| From | To |
+|---|---|
+| `useAutoSave.ts` | `use-auto-save.ts` |
+| `useCollab.ts` | `use-collab.ts` |
+| `useCollaborators.ts` | `use-collaborators.ts` |
+| `useDocument.ts` | `use-document.ts` |
+| `useJoinRequests.ts` | `use-join-requests.ts` |
+| `useMediaQuery.ts` | `use-media-query.ts` |
+| `useShareLink.ts` | `use-share-link.ts` |
+
+Import sites to update: `app/routes/app/document.tsx` (×2: useDocument, useMediaQuery), `features/…/DocumentMain/DocumentMain.tsx` (useCollab), `features/…/ShareButton/share-button.tsx` (×2: useJoinRequests, useShareLink), `features/…/CollaboratorsDropdown/collaborators-dropdown.tsx` (useCollaborators).
+
+Commit: `refactor(client): kebab-case hook filenames`
+
+### Task 2.6 (B6): DocumentMain subtree + leaf renames
+
+| From (under `features/DocumentPage/components/DocumentMain/`) | To |
+|---|---|
+| `DocumentMain.tsx` | `document-main.tsx` |
+| `MarkdownEditor/MarkdownEditor.tsx` | `markdown-editor.tsx` |
+| `MarkdownEditor/EditorExtensions.ts` | `editor-extensions.ts` |
+| `MarkdownEditor/EditorTheme.ts` | `editor-theme.ts` |
+| `MarkdownEditor/KeyMapExtension.ts` | `key-map-extension.ts` |
+| `MarkdownEditor/spellCheck.ts` | `spell-check.ts` |
+| `MarkdownEditor/MarkdownStatusBar/useEditorStatus.ts` | `use-editor-status.ts` |
+| `MarkdownEditor/MarkdownToolbar/useMarkdownCommands.tsx` | `use-markdown-commands.tsx` |
+| `MarkdownPreview/MarkdownPreview.tsx` | `markdown-preview.tsx` |
+| `MarkdownPreview/MermaidDiagram.tsx` | `mermaid-diagram.tsx` |
+| `MarkdownPreview/remarkDecorations.tsx` | `remark-decorations.tsx` |
+| `src/lib/rehypeCopyButton.ts` | `rehype-copy-button.ts` |
+| `src/utils/generateUserColor.ts` | `generate-user-color.ts` |
+
+Fix barrels (`DocumentMain/index.ts`, `MarkdownEditor/index.ts`, `MarkdownPreview/index.ts`), sibling relative imports (MermaidDiagram in MarkdownPreview.tsx, EditorExtensions/Theme/KeyMap/spellCheck in MarkdownEditor.tsx, FieldWrapper-style relative refs, `markdown-preview.stories.tsx` import), and any `@/lib/rehypeCopyButton` / `@/utils/generateUserColor` call sites (grep first).
+
+Final sweep gate: `find client/src \( -name '*.ts' -o -name '*.tsx' \) | grep -E '/[A-Za-z]*[A-Z][A-Za-z]*(\.[a-z]+)*\.(ts|tsx)$'` must return nothing (middle-extension aware).
+
+Commit: `refactor(client): kebab-case DocumentMain subtree and util filenames`
+
+---
+
+## Layer 3: `client-quality/c-rules`
+
+Create with: `gh stack add client-quality/c-rules`
+
+### Task 3.1: Naming rule flip + stale-ignore cleanup
+
+**Modify `client/eslint.config.js`:**
+
+```js
+'check-file/filename-naming-convention': [
+ 'error',
+ {
+ '**/*.{ts,tsx}': 'KEBAB_CASE',
+ },
+ {
+ ignoreMiddleExtensions: true,
+ },
+],
+```
+
+Remove `src/shared/**` from `ignores` (line ~17) and `--ignore-pattern src/shared` from `lint:fix`/`lint:ci` scripts in `client/package.json` (directory does not exist).
+
+- [ ] Edits
+- [ ] Verify: `pnpm --filter client lint` exits clean (proves Layer 2 completeness)
+- [ ] Commit: `chore(client): enforce kebab-case filenames, drop stale shared ignores`
+
+### Task 3.2: Add eslint-plugin-jsdoc (warn)
+
+- [ ] `pnpm --filter client add -D eslint-plugin-jsdoc`
+- [ ] Config additions:
+
+```js
+import jsdoc from 'eslint-plugin-jsdoc';
+// plugins: { jsdoc }
+// extends: jsdoc.configs['flat/recommended-typescript-flavor'] OR manual rules below
+'jsdoc/require-jsdoc': ['warn', {
+ require: { FunctionDeclaration: true, ClassDeclaration: true, ArrowFunctionExpression: false, FunctionExpression: false },
+ contexts: ['ExportNamedDeclaration > VariableDeclaration > VariableDeclarator'],
+ exemptEmptyFunctions: true,
+}],
+'jsdoc/require-param': 'off',
+```
+
+Pragmatic target: every exported component/hook/util gets a JSDoc block; params documented only when non-obvious. Interim `warn` flips to `error` in Task 4.5.
+
+- [ ] Verify: `pnpm lint` runs (warnings expected and acceptable until Layer 4 completes; `lint:ci` unaffected since root `pnpm lint` doesn't pass `--max-warnings 0`)
+- [ ] Commit: `chore(client): add jsdoc lint rule (warn)`
+
+---
+
+## Layer 4: `client-quality/d-seams`
+
+Create with: `gh stack add client-quality/d-seams`. Commits per batch; typecheck+build between each.
+
+### Task 4.1: Dead code deletion
+
+Delete commented refresh interceptor block (`client/src/lib/api.ts:11-17,27-55`).
+Commit: `chore(client): remove dead token-refresh interceptor code`
+
+### Task 4.2: Relocate bare API fns out of hooks file
+
+Move `getJoinRequests` / `approveJoinRequest` / `rejectJoinRequest` from `hooks/use-join-requests.ts` into new `src/lib/join-requests-api.ts` (kebab, JSDoc'd); hook imports them internally. Grep for external callers first; update if any.
+Commit: `refactor(client): split join-request api functions from hook`
+
+### Task 4.3: Additive error fields on data hooks
+
+For `use-document.ts`, `use-join-requests.ts`, `use-collaborators.ts`, `use-share-link.ts`: add `error: string | null` (set in catch, cleared on success) to state + return object. Purely additive — existing destructuring consumers unaffected.
+Commit: `feat(client): expose error state from data hooks`
+
+### Task 4.4: JSDoc backfill (batches, one commit per batch)
+
+Order: ui primitives → Auth forms → layouts → context/auth → hooks → lib/utils → Dashboard features → DocumentPage features (~70 exports total). Each export gets a concise JSDoc block; props interface members documented when non-obvious; missing `tags: ['autodocs']` added to story metas on touch.
+
+Suggested batch commits: `docs(client): jsdoc — `.
+
+### Task 4.5: Flip jsdoc rule to error
+
+Change `jsdoc/require-jsdoc` severity `warn` → `error`.
+- [ ] `pnpm lint` fully clean
+- [ ] Commit: `chore(client): enforce jsdoc rule`
+
+### Task 4.6 (D6 — ISOLATED behavioral commit): Provider seam in useCollab
+
+**Files:** Modify `hooks/use-collab.ts` ONLY.
+
+Add optional provider factory parameter; default constructs `HocuspocusProvider` with `env.Socket_URL` exactly as today. Zero caller changes required.
+
+```ts
+import { HocuspocusProvider } from '@hocuspocus/provider';
+// ...
+export interface CollabProviderFactory {
+ (options: { url: string; name: string; document: Y.Doc }): HocuspocusProvider;
+}
+const defaultProviderFactory: CollabProviderFactory = (opts) =>
+ new HocuspocusProvider(opts);
+
+export function useCollab(
+ docId: string | undefined,
+ createProvider: CollabProviderFactory = defaultProviderFactory,
+) {
+ // ... unchanged body, but construct via createProvider({ url: env.Socket_URL, name: docId, document: ydoc })
+}
+```
+
+Verification sequence (all must pass):
+- [ ] `pnpm --filter client typecheck && pnpm --filter client build`
+- [ ] `pnpm --filter client test` (existing stories green)
+- [ ] `pnpm --filter client test:e2e -- --project=collaboration-specs` (auto-runs auth→setup→chromium incl. sharing/editor specs → collaboration specs)
+- [ ] Commit alone: `refactor(client): injectable provider factory in useCollab`
+
+**Failure policy:** one evidence-backed retry max; otherwise drop D6 from scope (revert commit), MarkdownEditor story returns to deferred-residual status, Phase E proceeds without it.
+
+---
+
+## Layer 5: `client-quality/e-stories`
+
+Create with: `gh stack add client-quality/e-stories`. Verify `pnpm --filter client test` after each batch.
+
+### Task 5.1: Decorators in `.storybook/preview.ts`
+
+```tsx
+import type { Preview } from '@storybook/react-vite';
+import { MemoryRouter } from 'react-router';
+// mock auth context matching AuthContextType shape
+
+const preview: Preview = {
+ decorators: [
+ (Story) => ,
+ // ModalAncestor decorator: wraps story in root when story sets parameters.modalStory === true
+ ],
+};
+```
+
+Plus parameter-driven auth mock: decorator reading `parameters.auth` and supplying a matching context value (user/loading/isAuthenticated variants). Exact implementation follows the real `AuthContextType` interface read at execution time.
+Commit: `test(client): storybook router/auth/modal decorators`
+
+### Task 5.2: Gap-fill stories (12)
+
+Each: folder-per-component story file, `tags:['autodocs']`, states listed, play() where noted. Data via props.
+
+| Component dir | States | play() |
+|---|---|---|
+| `ui/Spinner` | sizes, color inheritance | — |
+| `ui/Seo` | render smoke + docs | — |
+| `ui/Auth` login-form | default, validation errors | fill+submit asserts validation |
+| `ui/Auth` register-form | same | same |
+| 4× layouts | render with child outlet content | — |
+| `ui/Form` Input | label, error, disabled | typing fires onChange |
+| `common/NewDocumentFormBody` | default, validation errors | valid submit fires onSubmit |
+| `Dashboard/…/DocumentCardDropdown` | closed/open items | open→select fires handler |
+| `Dashboard/…/NewDocumentModal` | open, open-with-form | open→close esc/cancel |
+| `DocumentPage/…/MarkdownStatusBar` | ready/saving states | — |
+| `DocumentPage/…/MarkdownToolbar` | active/inactive tools | tool click invokes command |
+| `DocumentPage/…/MarkdownEditor` | render smoke via offline provider adapter (contingent on D6 success; else deferred) | — |
+
+Also: add `NewDocumentModal/index.ts` barrel (sibling consistency).
+Batch commits: `test(client): stories for `
+
+### Task 5.3: Enrichment of existing 24 stories
+
+Recipe per component (worked exemplar — Button):
+
+```tsx
+import { expect, fn, userEvent, within } from 'storybook/test';
+
+const onClickFn = fn();
+
+export const Loading: Story = { args: { isLoading: true, children: 'Saving' } };
+export const Disabled: Story = { args: { disabled: true } };
+export const AsChild: Story = { args: { asChild: true }, render: (args) => (
+
+) };
+export const ClickBehavior: Story = {
+ args: { onClick: onClickFn, children: 'Click me' },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole('button'));
+ await expect(onClickFn).toHaveBeenCalledTimes(1);
+ },
+};
+```
+
+Application order & coverage targets:
+1. Primitives: Button (above), Modal (open/close/esc play), Dropdown (open→select play), Toast (show/autohide), Alert (variants), Avatar (image-break fallback), Skeleton, ToggleGroup (select play)
+2. Header (auth states via auth decorator)
+3. Dashboard: dashboard-main (loading skeleton/populated/empty grid), document-row + document-grid-card (long-title truncation, menu), sort-control (selection play)
+4. DocumentHeader cluster: title, toolbar, view-mode (toggle play), share-button (opens modal), options-dropdown, collaborators-dropdown, workspace-info, create-document-button (opens modal)
+5. MarkdownPreview: fixture markdown covering code blocks, math, mermaid, sanitization cases
+
+Commits: `test(client): state coverage for `
+
+---
+
+## Layer 6: `client-quality/f-ci`
+
+Create with: `gh stack add client-quality/f-ci`
+
+### Task 6.1: Remove temporary CI branch filters
+
+Revert both workflow `branches:` lists to `[main, develop]`.
+Commit: `ci: drop temporary client-quality branch triggers`
+
+### Task 6.2: AGENTS.md conventions section
+
+Add client section: folder-per-component layout, PascalCase dirs/kebab files, `autodocs` tag requirement, JSDoc-on-export policy (lint-enforced), story-state checklist, `pnpm --filter client test` command.
+Commit: `docs: record client conventions in AGENTS.md`
+
+### Task 6.3: ADR 0001 — defer auth token consolidation
+
+Write `docs/adr/0001-defer-auth-token-consolidation.md`: context (token triplication across context/utils/api.defaults + wasLoggedOut flag + deleted dead interceptor), decision (deferred to dedicated initiative), consequences.
+Commit: `docs: adr 0001 defer auth consolidation`
+
+### Task 6.4: Final gate
+
+`pnpm lint && pnpm typecheck && pnpm build && pnpm test` + `pnpm --filter client test:e2e`
+Commit (if anything outstanding): `chore: client quality initiative complete`
+
+---
+
+## Execution Handoff
+
+Run inline in this session (executing-plans style) given heavy local-verification coupling; dispatch subagents only for mechanical batches (JSDoc backfill, story enrichment) where context isolation helps.
diff --git a/docs/superpowers/specs/2026-08-23-client-quality-design.md b/docs/superpowers/specs/2026-08-23-client-quality-design.md
new file mode 100644
index 0000000..0c050b7
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-23-client-quality-design.md
@@ -0,0 +1,122 @@
+# Client Quality Initiative — Design Spec
+
+- **Date:** 2026-08-23
+- **Status:** Draft — awaiting user review before commit
+- **Plan:** `docs/superpowers/plans/2026-08-23-client-quality.md` (written after spec approval)
+
+## 1. Problem
+
+The client (`client/`) is hard to work with:
+
+1. **Confusing file structure** — mixed casing conventions, misplaced shared components, inconsistent folder shapes
+2. **Undocumented components** — zero JSDoc anywhere in `client/src`
+3. **Insufficient Storybook coverage** — 12 component dirs have no story; existing stories lack state coverage (e.g., `Button` has an `isLoading` prop but no loading story)
+4. **No component tests** — the `@storybook/addon-vitest` browser project is configured in `vitest.config.ts` but there is no `test` script; nothing runs it
+
+## 2. Goals / Non-goals
+
+**Goals:** one dedicated cleanup push that normalizes structure, enforces documentation via lint, brings every component into Storybook with real state coverage, makes stories executable as tests, and wires all of it into CI so it cannot regress.
+
+**Non-goals:** server-side changes; new runtime dependencies; MSW or network mocking; auth consolidation (deferred, see §9); hook unit-testing infrastructure beyond what stories provide; flipping a11y from `'todo'` to failing mode.
+
+## 3. Decisions Locked
+
+| Decision | Choice | Rationale |
+|---|---|---|
+| Effort shape | Dedicated cleanup push, phased | Conventions agreed once, mass migration once, lint keeps it honest |
+| Test strategy | Storybook interaction tests (`play()` via addon-vitest browser project) | Infra already 90% wired; one artifact = docs + visual states + test; zero new deps |
+| Structure scope | Full normalization | Mechanical `git mv`; partial fixes leave drift |
+| File naming | kebab-case for all `.ts`/`.tsx` | Matches ~90% majority, shadcn heritage, existing story names; ESLint rule flips to match reality |
+| Directory naming | PascalCase (unchanged) | Component identity matches exported symbol; dominant existing pattern |
+| JSDoc policy | Lint-enforced (`eslint-plugin-jsdoc`) | Backfill is pointless if new code can skip it |
+| Sequencing fix | Renames land **before** the KEBAB_CASE rule flips; JSDoc rule starts as `warn` | Rule-before-rename would go red mid-flight; `warn` interim because backfill hasn't happened |
+
+## 4. Target Conventions
+
+- Folder-per-component: `/{.tsx, index.ts, .stories.tsx}` — directories PascalCase, files kebab-case
+- Every story meta gets `tags: ['autodocs']` so JSDoc renders as Storybook docs pages automatically
+- Every interactive component gets state stories (loading/error/disabled/empty where applicable); interactive behavior gets `play()` assertions — stories are the component tests
+- Every export carries JSDoc (components, hooks, lib/utils functions)
+- Composite kits (e.g., `ui/Form`) may hold internal parts flat inside their folder when those parts have no external consumers; external API goes through the folder barrel
+
+## 5. Current-State Evidence
+
+- ~60 component `.tsx` files; 24 story files; 12 dirs without stories (enumerated in §7 Phase E)
+- Full uppercase-basename sweep found **29 violator files** for the kebab-case rule (all addressed in Phase B)
+- `lib/__tests__/api.test.tsx` is empty (0 lines) and attached to no runner
+- ESLint naming rule exists but is inert today (`button.tsx` passes; probe yields warning-level result only)
+- CI (`lint-type-check.yml`) already runs root `pnpm lint` / `pnpm test`, so client rules/tests flow into CI automatically once scripts exist; only Playwright chromium install needs adding
+- Playwright e2e project dependency chain: `auth-specs → setup → chromium → collaboration-specs → logout-specs`
+
+## 6. Architecture Findings Folded In
+
+Survey vocabulary: *module* = interface + implementation; *seam* = where an interface lives; *locality* = change concentrated in one place.
+
+| # | Finding | Disposition |
+|---|---|---|
+| 1 | Auth/token state triplicated: React context + `utils/token` localStorage + mutated `api.defaults.headers`, plus hidden `wasLoggedOut` flag and a fully commented-out 401-refresh interceptor (`api.ts:27-55`) | **Deferred** to its own initiative; ADR stub written in Phase F. Dead interceptor deleted in Phase D. |
+| 2 | All data hooks swallow errors (`console.error`, no `error` in return interface) — callers cannot render error states | **Folded in, minimal variant**: additive `error` field on hook returns during Phase D; enables real error-state stories in Phase E |
+| 3 | `useCollab.ts:18-22` constructs `HocuspocusProvider` inline — no seam; blocks any editor story without a live websocket | **Folded in as Task D6** (isolated behavioral commit + targeted e2e gate); un-defers the MarkdownEditor story |
+| 4 | Misc: bare API fns mixed into `useJoinRequests.ts`; `useAutoSave` interval-resets-on-edit semantics undocumented; `NewDocumentFormBody` renders Radix `ModalContent` needing a `` ancestor in stories | Folded into Phases D/E respectively |
+
+## 7. Phase Plan
+
+### Phase A — Non-breaking infra
+- **A1:** `"test": "vitest run"` in `client/package.json`; root `test:client`; local `playwright install chromium`; verify 24 existing stories pass
+- **A2:** Delete empty `lib/__tests__/api.test.tsx`
+- **A3:** Plop templates emit JSDoc stub, `autodocs` tag, typed meta, commented `play()` example
+
+### Phase B — Structural normalization (all `git mv`)
+- **B1:** Layouts → folder-per-component (`AuthLayout/auth-layout.tsx` + index, etc.)
+- **B2:** `context/auth`: `auth-context.tsx`, `auth-provider.tsx`, `use-auth.tsx`
+- **B3:** ui oddballs: `seo/`→`Seo/` (+`Head.tsx`→`head.tsx`+index), `auth/`→`Auth/`, `Header.stories.tsx`→`header.stories.tsx`; **dissolve `Form/Input/`** — `input-field.tsx`→`Form/input.tsx`, `variants.ts` up, `Form/index.ts` created, 3 external imports updated (evidence: only `Input` crosses the seam externally)
+- **B4:** `DashBoardMain`→`DashboardMain`; `common/forms/NewDocumentFormBody.tsx`→`common/NewDocumentFormBody/new-document-form-body.tsx` (**drop single-child `forms/` layer**; stays shared — used by Dashboard modal AND DocumentPage CreateDocumentButton, feature move would violate isolation lint)
+- **B5:** Hooks renames (7): `use-auto-save`, `use-collab`, `use-collaborators`, `use-document`, `use-join-requests`, `use-media-query`, `use-share-link`; 6 verified import sites across 4 files. No hook-name exemption: B2 already renames `useAuth.tsx`; function names keep `useX` casing (what react-hooks lint tracks)
+- **B6:** DocumentMain subtree (11 files): `document-main.tsx`, `markdown-editor.tsx`, `editor-extensions.ts`, `editor-theme.ts`, `key-map-extension.ts`, `spell-check.ts`, `use-editor-status.ts`, `use-markdown-commands.tsx`, `markdown-preview.tsx`, `mermaid-diagram.tsx`, `remark-decorations.tsx`; leaves: `rehype-copy-button.ts`, `generate-user-color.ts`. External consumers go through barrels — churn confined to barrels/siblings/one story import
+- Each task verified: typecheck + build; batch commits
+
+### Phase C — Enforcement rules
+- **C1:** Naming rule → `'**/*.{ts,tsx}': 'KEBAB_CASE'` (now passes post-B); remove stale `src/shared` ignores (dir doesn't exist). Dir convention documented, not linted
+- **C2:** Add `eslint-plugin-jsdoc` (sole new dev dep), `require-jsdoc` as **`warn`**, exports-focused, `require-param` off (types self-document)
+
+### Phase D — Documentation + seam fixes
+- JSDoc backfill batches (commit per batch): ui → auth forms → layouts → context/hooks → lib/utils → features (~70 exports); missing `autodocs` tags added on touch
+- Dead refresh interceptor deleted (`api.ts`); bare API fns relocated from `useJoinRequests.ts` to `lib/`; `error` fields added to data hooks (additive, non-breaking)
+- **D6 (isolated, last):** injectable provider factory param on `useCollab(docId, createProvider?)`, default preserves current behavior exactly. Verify: typecheck + build + `pnpm --filter client test` + `pnpm --filter client test:e2e -- --project=collaboration-specs` (dependency chain auto-covers setup/chromium/sharing/editor/collaboration specs). Own commit; **failure policy:** one evidenced retry max, else D6 drops from scope and the MarkdownEditor story returns to deferred-residual — the initiative never blocks on it
+- End of phase: flip `jsdoc/require-jsdoc` to `error`
+
+### Phase E — Stories & interaction tests
+- Groundwork decorators in `preview.ts`: global `MemoryRouter` (harmless to non-router stories), parameter-driven mock auth context, `Modal` ancestor wrapper for form-body stories
+- Gap-fill (12): Spinner (sizes), Seo (smoke), login/register forms (validation states + submit play), 4× layouts, Form/Input (label/error/disabled + typing play), NewDocumentFormBody (validation + submit play), DocumentCardDropdown (open/select play), NewDocumentModal (open/close play), NewDocumentModal gains its `index.ts`
+- MarkdownEditor story now feasible post-D6 via offline provider adapter; StatusBar/Toolbar get full state coverage regardless
+- Enrichment of existing 24: enumerate cva variants → one story each; boolean props → state story each; interactions → play() with `fn()` args + `within(canvasElement)` asserts. Fully worked exemplar: Button (Loading/Disabled/AsChild/ClickBehavior). Priority: primitives → Dashboard cards/menus → DocumentHeader cluster → MarkdownPreview fixtures (code/math/mermaid)
+- Verify each batch: `pnpm --filter client test` green
+
+### Phase F — CI, docs, records
+- `lint-type-check.yml`: add Playwright chromium install step before "Run tests"
+- AGENTS.md: client conventions section (folder shape, casing, autodocs, JSDoc policy, story checklist, test command)
+- Write `docs/adr/0001-defer-auth-token-consolidation.md` recording finding #1 + deferral rationale
+- Full gate: `pnpm lint && pnpm typecheck && pnpm build && pnpm test` + `pnpm --filter client test:e2e`
+
+## 8. Verification Gates & Failure Policies
+
+- Every phase ends green: lint + typecheck + build (tests from Phase A onward)
+- Renames never precede rule flips; rules never exceed current compliance level (naming after B; jsdoc `error` only after backfill)
+- D6 is the only runtime-behavioral commit in Phase D and carries its own e2e gate + drop-out policy
+- Existing Playwright e2e must stay green throughout; suites select by role/text, not filenames, so renames shouldn't touch them
+
+## 9. Residuals (explicit)
+
+- Auth token consolidation — deferred, recorded in ADR 0001
+- Hook internals (autosave timing, collab lifecycle) documented but not logic-tested under the storybook-only strategy; a node vitest project remains a cheap future add
+- MarkdownEditor story contingent on D6 success (else deferred again)
+- a11y stays `'todo'`; strictness flip is future work after violations triage
+
+## 10. Risks
+
+| Risk | Mitigation |
+|---|---|
+| Rename churn vs open branches | Land early on clean branch; phases ship as separate PR-sized chunks |
+| Vitest browser mode needs chromium locally | One-time `playwright install chromium`; documented in AGENTS.md |
+| D6 regression in collab path | Isolated commit, targeted e2e project run, explicit drop-out policy |
+| Scope creep beyond four complaints | §6 table bounds every architecture fold-in; anything else requires its own spec |
diff --git a/package.json b/package.json
index 0929a0c..74c5d19 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"typecheck": "run-p typecheck:*",
"typecheck:client": "pnpm --filter client typecheck",
"typecheck:server": "pnpm --filter server typecheck",
- "test": "run-p test:*",
+ "test": "run-s test:*",
+ "test:client": "pnpm --filter client test",
"test:server": "pnpm --filter server test",
"format": "pnpm --filter server lint:fix",
"prepare": "husky"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5a3a528..567676b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -268,6 +268,9 @@ importers:
eslint-plugin-import:
specifier: ^2.32.0
version: 2.32.0(eslint-import-resolver-typescript@4.4.4)(eslint@9.32.0(jiti@2.5.1))
+ eslint-plugin-jsdoc:
+ specifier: ^64.2.0
+ version: 64.2.0(eslint@9.32.0(jiti@2.5.1))
eslint-plugin-prettier:
specifier: ^5.5.1
version: 5.5.3(eslint-config-prettier@9.1.2(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1))(prettier@3.6.2)
@@ -346,6 +349,9 @@ importers:
express-async-handler:
specifier: ^1.2.0
version: 1.2.0
+ express-rate-limit:
+ specifier: ^8.6.2
+ version: 8.6.2(express@4.21.2)
express-ws:
specifier: ^5.0.2
version: 5.0.2(express@4.21.2)
@@ -696,6 +702,14 @@ packages:
'@emnapi/wasi-threads@1.0.4':
resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==}
+ '@es-joy/jsdoccomment@0.95.0':
+ resolution: {integrity: sha512-jbzwtRPuw1Nzld0lacvr1vwzPU/6zEHFGK5YOTstl2MQYMZBuKmSW3HMuEwsq1v4meK5Do4lizxyd/hi25rCZg==}
+ engines: {node: ^22.22.2 || >=24.15.0}
+
+ '@es-joy/resolve.exports@1.2.0':
+ resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==}
+ engines: {node: '>=10'}
+
'@esbuild/aix-ppc64@0.23.1':
resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
engines: {node: '>=18'}
@@ -1801,6 +1815,10 @@ packages:
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
+ '@sindresorhus/base62@1.0.0':
+ resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==}
+ engines: {node: '>=18'}
+
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
@@ -2158,6 +2176,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
'@types/express-serve-static-core@4.19.6':
resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==}
@@ -2319,6 +2340,10 @@ packages:
resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/types@8.67.0':
+ resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript-eslint/typescript-estree@8.38.0':
resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -2527,6 +2552,11 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ acorn@8.18.0:
+ resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+
aggregate-error@4.0.1:
resolution: {integrity: sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==}
engines: {node: '>=12'}
@@ -2570,6 +2600,10 @@ packages:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
+ are-docs-informative@0.1.1:
+ resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==}
+ engines: {node: '>=18'}
+
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
@@ -2896,6 +2930,10 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
+ comment-parser@1.4.8:
+ resolution: {integrity: sha512-rKZTGo4fzKYna8UcL0isTg5wkBNla7bxTypLwZQXjIdi++IdP1OJ41rI5Mti3/jltkPujbu4i9LIARYA+zpotQ==}
+ engines: {node: '>= 12.0.0'}
+
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
@@ -3172,6 +3210,15 @@ packages:
supports-color:
optional: true
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@@ -3467,6 +3514,12 @@ packages:
'@typescript-eslint/parser':
optional: true
+ eslint-plugin-jsdoc@64.2.0:
+ resolution: {integrity: sha512-z3zGmJoPhOdKnxzQ3R+8MZeJjW8vrRW8r7sYp/ErBp8K9+05RIa6vWXtbEGr7D1obBHk64LdKyLHoMLSzHFINA==}
+ engines: {node: ^22.22.2 || >=24.15.0}
+ peerDependencies:
+ eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
+
eslint-plugin-prettier@5.5.3:
resolution: {integrity: sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -3516,6 +3569,10 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ eslint-visitor-keys@5.0.1:
+ resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
eslint@9.32.0:
resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3530,6 +3587,10 @@ packages:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ espree@11.2.0:
+ resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
@@ -3539,6 +3600,10 @@ packages:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
engines: {node: '>=0.10'}
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
@@ -3582,6 +3647,12 @@ packages:
express-async-handler@1.2.0:
resolution: {integrity: sha512-rCSVtPXRmQSW8rmik/AIb2P0op6l7r1fMW538yyvTMltCO4xQEWMmobfrIxN2V1/mVrgxB8Az3reYF6yUZw37w==}
+ express-rate-limit@8.6.2:
+ resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==}
+ engines: {node: '>= 16'}
+ peerDependencies:
+ express: '>= 4.11'
+
express-ws@5.0.2:
resolution: {integrity: sha512-0uvmuk61O9HXgLhGl3QhNSEtRsQevtmbL94/eILaliEADZBHZOQUAiHFrGPrgsjikohyrmSG5g+sCfASTt0lkQ==}
engines: {node: '>=4.5.0'}
@@ -3942,6 +4013,9 @@ packages:
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
+ html-entities@2.6.0:
+ resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
+
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@@ -4043,6 +4117,10 @@ packages:
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
+ ip-address@10.5.0:
+ resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
+ engines: {node: '>= 12'}
+
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -4307,6 +4385,10 @@ packages:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
+ jsdoc-type-pratt-parser@9.1.1:
+ resolution: {integrity: sha512-kojSbQb9iQM2bk2PmHiKa3reG0U4v2gN9U8D8+eCQq+4KM5ZXBUyBVeF8KmR0RiwqyrW4+uVWYWTOLnpsavnkw==}
+ engines: {node: ^22.22.2 || >=24.15.0}
+
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -4917,6 +4999,9 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
+ object-deep-merge@2.0.1:
+ resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==}
+
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -5047,6 +5132,9 @@ packages:
resolution: {integrity: sha512-FwdRXKCohSVeXqwtYonZTXtbGJKrn+HNyWDYVcp5yuJlesTwNH4rsmRZ+GrKAPJ5bLpRxESMeS+Rl0VCHRvB2Q==}
engines: {node: '>=0.8'}
+ parse-imports-exports@0.2.4:
+ resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==}
+
parse-json@4.0.0:
resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==}
engines: {node: '>=4'}
@@ -5055,6 +5143,9 @@ packages:
resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==}
engines: {node: '>=0.10.0'}
+ parse-statements@1.0.11:
+ resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
+
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -5426,6 +5517,10 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+ reserved-identifiers@1.2.0:
+ resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==}
+ engines: {node: '>=18'}
+
resolve-dir@1.0.1:
resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==}
engines: {node: '>=0.10.0'}
@@ -5527,6 +5622,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
send@0.19.0:
resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==}
engines: {node: '>= 0.8.0'}
@@ -5658,6 +5758,9 @@ packages:
spdx-expression-parse@3.0.1:
resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
+ spdx-expression-parse@5.0.0:
+ resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==}
+
spdx-license-ids@3.0.21:
resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==}
@@ -5879,6 +5982,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ to-valid-identifier@1.0.0:
+ resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==}
+ engines: {node: '>=20'}
+
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -6486,7 +6593,7 @@ snapshots:
'@babel/parser': 7.28.0
'@babel/template': 7.27.2
'@babel/types': 7.28.2
- debug: 4.4.1(supports-color@5.5.0)
+ debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -6780,6 +6887,16 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@es-joy/jsdoccomment@0.95.0':
+ dependencies:
+ '@types/estree': 1.0.9
+ '@typescript-eslint/types': 8.67.0
+ comment-parser: 1.4.8
+ esquery: 1.7.0
+ jsdoc-type-pratt-parser: 9.1.1
+
+ '@es-joy/resolve.exports@1.2.0': {}
+
'@esbuild/aix-ppc64@0.23.1':
optional: true
@@ -7742,6 +7859,8 @@ snapshots:
'@scarf/scarf@1.4.0': {}
+ '@sindresorhus/base62@1.0.0': {}
+
'@sindresorhus/is@4.6.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -8133,6 +8252,8 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/estree@1.0.9': {}
+
'@types/express-serve-static-core@4.19.6':
dependencies:
'@types/node': 22.16.5
@@ -8302,7 +8423,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3)
'@typescript-eslint/types': 8.38.0
- debug: 4.4.1(supports-color@5.5.0)
+ debug: 4.4.3
typescript: 5.8.3
transitivePeerDependencies:
- supports-color
@@ -8321,7 +8442,7 @@ snapshots:
'@typescript-eslint/types': 8.38.0
'@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3)
'@typescript-eslint/utils': 8.38.0(eslint@9.32.0(jiti@2.5.1))(typescript@5.8.3)
- debug: 4.4.1(supports-color@5.5.0)
+ debug: 4.4.3
eslint: 9.32.0(jiti@2.5.1)
ts-api-utils: 2.1.0(typescript@5.8.3)
typescript: 5.8.3
@@ -8330,6 +8451,8 @@ snapshots:
'@typescript-eslint/types@8.38.0': {}
+ '@typescript-eslint/types@8.67.0': {}
+
'@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)':
dependencies:
'@typescript-eslint/project-service': 8.38.0(typescript@5.8.3)
@@ -8542,12 +8665,18 @@ snapshots:
dependencies:
acorn: 8.15.0
+ acorn-jsx@5.3.2(acorn@8.18.0):
+ dependencies:
+ acorn: 8.18.0
+
acorn-walk@8.3.4:
dependencies:
acorn: 8.15.0
acorn@8.15.0: {}
+ acorn@8.18.0: {}
+
aggregate-error@4.0.1:
dependencies:
clean-stack: 4.2.0
@@ -8589,6 +8718,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
+ are-docs-informative@0.1.1: {}
+
arg@4.1.3: {}
argparse@1.0.10:
@@ -8951,6 +9082,8 @@ snapshots:
commander@8.3.0: {}
+ comment-parser@1.4.8: {}
+
component-emitter@1.3.1: {}
concat-map@0.0.1: {}
@@ -9241,6 +9374,10 @@ snapshots:
optionalDependencies:
supports-color: 5.5.0
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
decode-named-character-reference@1.2.0:
dependencies:
character-entities: 2.0.2
@@ -9467,7 +9604,7 @@ snapshots:
esbuild-register@3.6.0(esbuild@0.23.1):
dependencies:
- debug: 4.4.1(supports-color@5.5.0)
+ debug: 4.4.3
esbuild: 0.23.1
transitivePeerDependencies:
- supports-color
@@ -9623,6 +9760,26 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
+ eslint-plugin-jsdoc@64.2.0(eslint@9.32.0(jiti@2.5.1)):
+ dependencies:
+ '@es-joy/jsdoccomment': 0.95.0
+ '@es-joy/resolve.exports': 1.2.0
+ are-docs-informative: 0.1.1
+ comment-parser: 1.4.8
+ debug: 4.4.3
+ escape-string-regexp: 5.0.0
+ eslint: 9.32.0(jiti@2.5.1)
+ espree: 11.2.0
+ esquery: 1.7.0
+ html-entities: 2.6.0
+ object-deep-merge: 2.0.1
+ parse-imports-exports: 0.2.4
+ semver: 7.8.5
+ spdx-expression-parse: 5.0.0
+ to-valid-identifier: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+
eslint-plugin-prettier@5.5.3(eslint-config-prettier@9.1.2(eslint@9.32.0(jiti@2.5.1)))(eslint@9.32.0(jiti@2.5.1))(prettier@3.6.2):
dependencies:
eslint: 9.32.0(jiti@2.5.1)
@@ -9662,6 +9819,8 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
+ eslint-visitor-keys@5.0.1: {}
+
eslint@9.32.0(jiti@2.5.1):
dependencies:
'@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0(jiti@2.5.1))
@@ -9710,12 +9869,22 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1
+ espree@11.2.0:
+ dependencies:
+ acorn: 8.18.0
+ acorn-jsx: 5.3.2(acorn@8.18.0)
+ eslint-visitor-keys: 5.0.1
+
esprima@4.0.1: {}
esquery@1.6.0:
dependencies:
estraverse: 5.3.0
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
@@ -9756,6 +9925,14 @@ snapshots:
express-async-handler@1.2.0: {}
+ express-rate-limit@8.6.2(express@4.21.2):
+ dependencies:
+ debug: 4.4.3
+ express: 4.21.2
+ ip-address: 10.5.0
+ transitivePeerDependencies:
+ - supports-color
+
express-ws@5.0.2(express@4.21.2):
dependencies:
express: 4.21.2
@@ -10242,6 +10419,8 @@ snapshots:
hosted-git-info@2.8.9: {}
+ html-entities@2.6.0: {}
+
html-escaper@2.0.2: {}
html-url-attributes@3.0.1: {}
@@ -10333,6 +10512,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
+ ip-address@10.5.0: {}
+
ipaddr.js@1.9.1: {}
is-absolute@1.0.0:
@@ -10565,6 +10746,10 @@ snapshots:
dependencies:
argparse: 2.0.1
+ jsdoc-type-pratt-parser@9.1.1:
+ dependencies:
+ '@types/estree': 1.0.9
+
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@@ -11239,7 +11424,7 @@ snapshots:
micromark@4.0.2:
dependencies:
'@types/debug': 4.1.12
- debug: 4.4.1(supports-color@5.5.0)
+ debug: 4.4.3
decode-named-character-reference: 1.2.0
devlop: 1.1.0
micromark-core-commonmark: 2.0.3
@@ -11401,6 +11586,8 @@ snapshots:
object-assign@4.1.1: {}
+ object-deep-merge@2.0.1: {}
+
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -11576,6 +11763,10 @@ snapshots:
map-cache: 0.2.2
path-root: 0.1.1
+ parse-imports-exports@0.2.4:
+ dependencies:
+ parse-statements: 1.0.11
+
parse-json@4.0.0:
dependencies:
error-ex: 1.3.2
@@ -11583,6 +11774,8 @@ snapshots:
parse-passwd@1.0.0: {}
+ parse-statements@1.0.11: {}
+
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -12007,6 +12200,8 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
+ reserved-identifiers@1.2.0: {}
+
resolve-dir@1.0.1:
dependencies:
expand-tilde: 2.0.2
@@ -12122,6 +12317,8 @@ snapshots:
semver@7.7.2: {}
+ semver@7.8.5: {}
+
send@0.19.0:
dependencies:
debug: 2.6.9
@@ -12286,6 +12483,11 @@ snapshots:
spdx-exceptions: 2.5.0
spdx-license-ids: 3.0.21
+ spdx-expression-parse@5.0.0:
+ dependencies:
+ spdx-exceptions: 2.5.0
+ spdx-license-ids: 3.0.21
+
spdx-license-ids@3.0.21: {}
sprintf-js@1.0.3: {}
@@ -12530,6 +12732,11 @@ snapshots:
dependencies:
is-number: 7.0.0
+ to-valid-identifier@1.0.0:
+ dependencies:
+ '@sindresorhus/base62': 1.0.0
+ reserved-identifiers: 1.2.0
+
toidentifier@1.0.1: {}
totalist@3.0.1: {}
diff --git a/server/package.json b/server/package.json
index 2a835e9..3ba70b7 100644
--- a/server/package.json
+++ b/server/package.json
@@ -39,6 +39,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-async-handler": "^1.2.0",
+ "express-rate-limit": "^8.6.2",
"express-ws": "^5.0.2",
"helmet": "^7.2.0",
"http-status-codes": "^2.3.0",
diff --git a/server/prisma/migrations/20260824200022_cascade_delete_collaboration_on_document/migration.sql b/server/prisma/migrations/20260824200022_cascade_delete_collaboration_on_document/migration.sql
new file mode 100644
index 0000000..138db62
--- /dev/null
+++ b/server/prisma/migrations/20260824200022_cascade_delete_collaboration_on_document/migration.sql
@@ -0,0 +1,11 @@
+-- DropForeignKey
+ALTER TABLE "collaboration_requests" DROP CONSTRAINT "collaboration_requests_documentId_fkey";
+
+-- DropForeignKey
+ALTER TABLE "collaborators" DROP CONSTRAINT "collaborators_documentId_fkey";
+
+-- AddForeignKey
+ALTER TABLE "collaborators" ADD CONSTRAINT "collaborators_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "collaboration_requests" ADD CONSTRAINT "collaboration_requests_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 8be423e..13a3117 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -46,7 +46,7 @@ model Document {
model Collaborator {
id String @id @default(uuid())
- document Document @relation(fields: [documentId], references: [id])
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
documentId String
user User @relation(fields: [userId], references: [id])
userId String
@@ -60,7 +60,7 @@ model CollaborationRequest {
id String @id @default(uuid())
user User @relation(fields: [userId], references: [id])
userId String
- document Document @relation(fields: [documentId], references: [id])
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
documentId String
status String @default("pending") // pending | accepted | rejected
permission String @default("edit") // "edit" or "view"
diff --git a/server/scripts/seed-test-user.ts b/server/scripts/seed-test-user.ts
index 92673b4..862a3da 100644
--- a/server/scripts/seed-test-user.ts
+++ b/server/scripts/seed-test-user.ts
@@ -18,12 +18,27 @@ async function main() {
},
});
- // Start every e2e run with a clean dashboard for the test user
+ // Start every e2e run with a clean dashboard for the test user.
+ // Collaborator rows referencing the documents must go first or the
+ // document delete fails on the collaborators_documentId_fkey constraint.
+ const testDocs = await prisma.document.findMany({
+ where: { authorId: user.id },
+ select: { id: true },
+ });
+ const testDocIds = testDocs.map(d => d.id);
+ const { count: removedCollaborators } = await prisma.collaborator.deleteMany({
+ where: { documentId: { in: testDocIds } },
+ });
+ const { count: removedRequests } = await prisma.collaborationRequest.deleteMany({
+ where: { documentId: { in: testDocIds } },
+ });
const { count } = await prisma.document.deleteMany({
where: { authorId: user.id },
});
- if (count > 0) {
- console.log(`🧹 Removed ${count} leftover test document(s)`);
+ if (count > 0 || removedCollaborators > 0 || removedRequests > 0) {
+ console.log(
+ `🧹 Removed ${count} leftover test document(s), ${removedCollaborators} collaborator link(s) and ${removedRequests} collaboration request(s)`
+ );
}
// Remove throwaway accounts created by the registration specs in past runs
diff --git a/server/src/controllers/document.controller.ts b/server/src/controllers/document.controller.ts
index 1867b3c..e958b23 100644
--- a/server/src/controllers/document.controller.ts
+++ b/server/src/controllers/document.controller.ts
@@ -1,17 +1,20 @@
-import chalk from 'chalk';
+import { Prisma } from '@prisma/client';
import { Response } from 'express';
import asyncErrorWrapper from 'express-async-handler';
import { StatusCodes } from 'http-status-codes';
+import { ConflictError } from '@/exceptions/ConflictError';
+import { NotFoundError } from '@/exceptions/NotFoundError';
import { logger } from '@/lib/logger';
import { prisma } from '@/lib/prisma';
import { generateShareToken, verifyShareToken } from '@/lib/shareToken';
import { getClientInfo } from '@/utils/getClientInfo';
+import { AddCollaboratorSchema } from '@/validations/addCollaborator.schema';
export const createDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res: Response) => {
const clientInfo = getClientInfo(req);
const userId = req.user?.userId;
- const { title, content = '', isPublic = false } = req.body;
+ const { title, isPublic = false } = req.body;
logger.debug('Document creation attempt', {
action: 'CREATE_DOCUMENT_ATTEMPT',
@@ -19,14 +22,15 @@ export const createDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res
userId,
title,
isPublic,
- contentLength: content.length,
});
try {
+ // `content` starts empty and is owned by Yjs sync afterwards (issue #47);
+ // accepting it here would store text no reader ever sees.
const newDoc = await prisma.document.create({
data: {
title,
- content,
+ content: '',
isPublic,
authorId: req.user?.userId,
},
@@ -191,18 +195,7 @@ export const updateDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res
const clientInfo = getClientInfo(req);
const userId = req.user?.userId;
const documentId = req.params.id;
- const { title, content, isPublic } = req.body;
-
- // logger.info('Document update attempt', {
- // action: 'UPDATE_DOCUMENT_ATTEMPT',
- // ...clientInfo,
- // userId,
- // documentId,
- // title,
- // isPublic,
- // contentLength: content?.length,
- // });
-
+ const { title, isPublic } = req.body;
try {
const doc = await prisma.document.findFirst({
where: { id: documentId },
@@ -228,11 +221,12 @@ export const updateDoc = asyncErrorWrapper(async (req: AuthenticatedRequest, res
return;
}
+ // `content` is intentionally not writable here: it is a mirror of the
+ // Yjs document state maintained by dbPersistence.store (issue #47).
const updatedDoc = await prisma.document.update({
where: { id: doc.id },
- data: { title, content, isPublic },
+ data: { title, isPublic },
});
-
// logger.info('Document updated successfully', {
// action: 'UPDATE_DOCUMENT_SUCCESS',
// ...clientInfo,
@@ -404,7 +398,6 @@ export const getDocByToken = asyncErrorWrapper(async (req: AuthenticatedRequest,
try {
decoded = verifyShareToken(token);
- console.log(chalk.bold.red('DECODED'), decoded);
} catch (error) {
logger.warn('Shared document access failed - token verification failed', {
action: 'ACCESS_SHARED_DOCUMENT_TOKEN_VERIFICATION_FAILED',
@@ -710,8 +703,44 @@ export const approveRequest = asyncErrorWrapper(async (req: AuthenticatedRequest
}
// Get the request to extract the requester user ID
- const request = await prisma.collaborationRequest.update({
+ const request = await prisma.collaborationRequest.findUnique({
where: { id: requestId },
+ });
+
+ if (!request || request.documentId !== documentId) {
+ logger.warn('Collaboration request approval failed - request not found on document', {
+ action: 'APPROVE_COLLABORATION_REQUEST_NOT_FOUND',
+ ...clientInfo,
+ userId,
+ documentId,
+ requestId,
+ requestExists: !!request,
+ belongsToDocument: request?.documentId === documentId,
+ });
+
+ res.status(StatusCodes.NOT_FOUND).json({ error: 'Request not found' });
+
+ return;
+ }
+
+ if (request.status !== 'pending') {
+ logger.warn('Collaboration request approval failed - request already decided', {
+ action: 'APPROVE_COLLABORATION_REQUEST_ALREADY_DECIDED',
+ ...clientInfo,
+ userId,
+ documentId,
+ requestId,
+ status: request.status,
+ });
+
+ res.status(StatusCodes.CONFLICT).json({ error: 'Request already decided' });
+
+ return;
+ }
+
+ // Scoped update as defense-in-depth against races between read and write
+ await prisma.collaborationRequest.updateMany({
+ where: { id: requestId, documentId, status: 'pending' },
data: { status: 'approved' },
});
@@ -784,7 +813,44 @@ export const rejectRequest = asyncErrorWrapper(async (req: AuthenticatedRequest,
return;
}
- await prisma.collaborationRequest.update({ where: { id: requestId }, data: { status: 'rejected' } });
+ const request = await prisma.collaborationRequest.findUnique({
+ where: { id: requestId },
+ });
+
+ if (!request || request.documentId !== id) {
+ logger.warn('Collaboration request rejection failed - request not found on document', {
+ action: 'REJECT_COLLABORATION_REQUEST_NOT_FOUND',
+ ...clientInfo,
+ userId,
+ documentId: id,
+ requestId,
+ requestExists: !!request,
+ belongsToDocument: request?.documentId === id,
+ });
+
+ res.status(StatusCodes.NOT_FOUND).json({ error: 'Request not found' });
+ return;
+ }
+
+ if (request.status !== 'pending') {
+ logger.warn('Collaboration request rejection failed - request already decided', {
+ action: 'REJECT_COLLABORATION_REQUEST_ALREADY_DECIDED',
+ ...clientInfo,
+ userId,
+ documentId: id,
+ requestId,
+ status: request.status,
+ });
+
+ res.status(StatusCodes.CONFLICT).json({ error: 'Request already decided' });
+ return;
+ }
+
+ // Scoped update as defense-in-depth against races between read and write
+ await prisma.collaborationRequest.updateMany({
+ where: { id: requestId, documentId: id, status: 'pending' },
+ data: { status: 'rejected' },
+ });
res.json({ message: 'Rejected' });
});
@@ -850,7 +916,7 @@ export const getCollaborators = asyncErrorWrapper(async (req: AuthenticatedReque
export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedRequest, res: Response) => {
const clientInfo = getClientInfo(req);
const { id } = req.params;
- const { userId: newCollaboratorId, permission } = req.body;
+ const { email, permission } = req.body as AddCollaboratorSchema;
const ownerId = req.user?.userId;
logger.debug('Add collaborator attempt', {
@@ -858,7 +924,7 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques
...clientInfo,
ownerId,
documentId: id,
- newCollaboratorId,
+ email,
permission,
});
@@ -871,7 +937,6 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques
...clientInfo,
ownerId,
documentId: id,
- newCollaboratorId,
documentExists: !!doc,
isOwner: doc?.authorId === ownerId,
});
@@ -880,16 +945,65 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques
return;
}
- const collab = await prisma.collaborator.create({
- data: { documentId: id, userId: newCollaboratorId, permission },
+ const user = await prisma.user.findUnique({ where: { email } });
+
+ if (!user) {
+ logger.warn('Add collaborator failed - unknown email', {
+ action: 'ADD_COLLABORATOR_UNKNOWN_EMAIL',
+ ...clientInfo,
+ ownerId,
+ documentId: id,
+ });
+
+ throw new NotFoundError('No user found with that email');
+ }
+
+ const existing = await prisma.collaborator.findUnique({
+ where: { documentId_userId: { documentId: id, userId: user.id } },
});
+ if (existing) {
+ logger.warn('Add collaborator failed - already a collaborator', {
+ action: 'ADD_COLLABORATOR_DUPLICATE',
+ ...clientInfo,
+ ownerId,
+ documentId: id,
+ newCollaboratorId: user.id,
+ });
+
+ throw new ConflictError('User is already a collaborator on this document');
+ }
+
+ let collab;
+
+ try {
+ collab = await prisma.collaborator.create({
+ data: { documentId: id, userId: user.id, permission },
+ });
+ } catch (error) {
+ // Safety net for the check-then-create race above: two concurrent adds
+ // can both pass the pre-check and the loser hits the unique constraint.
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
+ logger.warn('Add collaborator failed - concurrent duplicate insert', {
+ action: 'ADD_COLLABORATOR_RACE_DUPLICATE',
+ ...clientInfo,
+ ownerId,
+ documentId: id,
+ newCollaboratorId: user.id,
+ });
+
+ throw new ConflictError('User is already a collaborator on this document');
+ }
+
+ throw error;
+ }
+
logger.debug('Collaborator added successfully', {
action: 'ADD_COLLABORATOR_SUCCESS',
...clientInfo,
ownerId,
documentId: id,
- newCollaboratorId,
+ newCollaboratorId: user.id,
permission,
collaboratorId: collab.id,
});
@@ -901,7 +1015,6 @@ export const addCollaborator = asyncErrorWrapper(async (req: AuthenticatedReques
...clientInfo,
ownerId,
documentId: id,
- newCollaboratorId,
permission,
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
@@ -962,8 +1075,6 @@ export const removeCollaborator = asyncErrorWrapper(async (req: AuthenticatedReq
},
});
- console.log(chalk.red('HERE'), result, collaboratorId, id);
-
logger.debug('Collaborator removed successfully', {
action: 'REMOVE_COLLABORATOR_SUCCESS',
...clientInfo,
diff --git a/server/src/exceptions/ConflictError.ts b/server/src/exceptions/ConflictError.ts
new file mode 100644
index 0000000..e6f39c7
--- /dev/null
+++ b/server/src/exceptions/ConflictError.ts
@@ -0,0 +1,9 @@
+import { StatusCodes } from 'http-status-codes';
+
+import { AppError } from './AppError';
+
+export class ConflictError extends AppError {
+ constructor(message?: string) {
+ super(StatusCodes.CONFLICT, message ?? 'Conflict');
+ }
+}
diff --git a/server/src/lib/dbPersistence.ts b/server/src/lib/dbPersistence.ts
index 05bc50e..13fabe3 100644
--- a/server/src/lib/dbPersistence.ts
+++ b/server/src/lib/dbPersistence.ts
@@ -54,35 +54,38 @@ export const dbPersistence = new Database({
});
if (existing) {
- await prisma.yjsDocumentState.update({
- where: { documentId: id },
- data: {
- state: Buffer.from(state),
- version: { increment: 1 },
- },
- });
-
- await prisma.document.update({
- where: { id: id },
- data: { content: plainText },
- });
+ // Snapshot and its plaintext mirror must stay consistent: write both atomically.
+ await prisma.$transaction([
+ prisma.yjsDocumentState.update({
+ where: { documentId: id },
+ data: {
+ state: Buffer.from(state),
+ version: { increment: 1 },
+ },
+ }),
+ prisma.document.update({
+ where: { id: id },
+ data: { content: plainText },
+ }),
+ ]);
} else {
const documentExists = await prisma.document.findFirst({
where: { id: id },
});
if (documentExists) {
- await prisma.yjsDocumentState.create({
- data: {
- documentId: documentExists.id,
- state: Buffer.from(state),
- },
- });
-
- await prisma.document.update({
- where: { id: documentExists.id },
- data: { content: plainText },
- });
+ await prisma.$transaction([
+ prisma.yjsDocumentState.create({
+ data: {
+ documentId: documentExists.id,
+ state: Buffer.from(state),
+ },
+ }),
+ prisma.document.update({
+ where: { id: documentExists.id },
+ data: { content: plainText },
+ }),
+ ]);
} else {
logger.warn(`No Document found for ID prefix: ${documentName}`, {
action: 'DB_STORE_DOC_NOT_FOUND',
diff --git a/server/src/middlewares/rate-limit.middleware.ts b/server/src/middlewares/rate-limit.middleware.ts
new file mode 100644
index 0000000..e7de846
--- /dev/null
+++ b/server/src/middlewares/rate-limit.middleware.ts
@@ -0,0 +1,34 @@
+import { type Options, rateLimit } from 'express-rate-limit';
+
+import { logger } from '@/lib/logger';
+
+export const AUTH_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
+export const AUTH_RATE_LIMIT_MAX = 10;
+
+/**
+ * Builds a rate limiter for the auth endpoints (register/login/refresh).
+ *
+ * @param overrides Partial express-rate-limit options; used by tests to shrink the window/limit.
+ */
+export const createAuthRateLimiter = (overrides: Partial = {}) =>
+ rateLimit({
+ windowMs: AUTH_RATE_LIMIT_WINDOW_MS,
+ limit: AUTH_RATE_LIMIT_MAX,
+ standardHeaders: 'draft-7',
+ legacyHeaders: false,
+ // The limiter is a production safeguard only: in development/E2E the
+ // Playwright suite performs many logins from one IP and would trip it.
+ skip: () => process.env.NODE_ENV !== 'production',
+ handler: (req, res, _next, options) => {
+ logger.warn('Auth rate limit exceeded', {
+ action: 'AUTH_RATE_LIMIT_EXCEEDED',
+ ip: req.ip,
+ path: req.originalUrl,
+ });
+ res.status(options.statusCode).json({ error: 'Too many requests, please try again later.' });
+ },
+ ...overrides,
+ });
+
+/** Shared limiter instance applied to the unauthenticated auth endpoints. */
+export const authLimiter = createAuthRateLimiter();
diff --git a/server/src/routers/auth.router.ts b/server/src/routers/auth.router.ts
index 0eb2ee3..40b8c5f 100644
--- a/server/src/routers/auth.router.ts
+++ b/server/src/routers/auth.router.ts
@@ -2,13 +2,14 @@ import express from 'express';
import { loginUser, logoutUser, refreshToken, registerUser } from '@/controllers/auth.controller';
import { authenticate, validateRefreshToken } from '@/middlewares/auth.middleware';
+import { authLimiter } from '@/middlewares/rate-limit.middleware';
import { validate } from '@/middlewares/validation.middleware';
import { LoginUserSchema } from '@/validations/login.schema';
import { RegisterUserSchema } from '@/validations/register.schema';
export const authRouter = express.Router();
-authRouter.post('/register', validate({ body: RegisterUserSchema }), registerUser);
-authRouter.post('/login', validate({ body: LoginUserSchema }), loginUser);
+authRouter.post('/register', authLimiter, validate({ body: RegisterUserSchema }), registerUser);
+authRouter.post('/login', authLimiter, validate({ body: LoginUserSchema }), loginUser);
authRouter.post('/logout', authenticate, logoutUser);
-authRouter.post('/refresh', validateRefreshToken, refreshToken);
+authRouter.post('/refresh', authLimiter, validateRefreshToken, refreshToken);
diff --git a/server/src/routers/document.router.ts b/server/src/routers/document.router.ts
index 4daa8ee..c7bdddd 100644
--- a/server/src/routers/document.router.ts
+++ b/server/src/routers/document.router.ts
@@ -17,6 +17,8 @@ import {
updateDocSettings,
} from '@/controllers/document.controller';
import { authenticate } from '@/middlewares/auth.middleware';
+import { validate } from '@/middlewares/validation.middleware';
+import { AddCollaboratorSchema } from '@/validations/addCollaborator.schema';
export const docRouter = express.Router();
@@ -34,7 +36,7 @@ docRouter.patch('/:id/settings', updateDocSettings); // Used to toggle allowSelf
docRouter.get('/:id/share-link', getShareLink); // get the document share link with the share token
docRouter.get('/:id/collaborators', getCollaborators); // returns list
-docRouter.post('/:id/collaborators', addCollaborator); // adds a new one //!Owner only access
+docRouter.post('/:id/collaborators', validate({ body: AddCollaboratorSchema }), addCollaborator); // adds a new one by email //!Owner only access
docRouter.delete('/:id/collaborators/:userId', removeCollaborator); // optional
docRouter.get('/:id/requests', getRequests); // !Owner only access
diff --git a/server/src/utils/getClientInfo.ts b/server/src/utils/getClientInfo.ts
index 14e7740..ae9746d 100644
--- a/server/src/utils/getClientInfo.ts
+++ b/server/src/utils/getClientInfo.ts
@@ -7,7 +7,6 @@ export const getClientInfo = (req: Request) => {
ip = ip.replace('::ffff:', '');
}
- //console.log(ip);
return {
ip,
userAgent: req.get('User-Agent'),
diff --git a/server/src/validations/addCollaborator.schema.ts b/server/src/validations/addCollaborator.schema.ts
new file mode 100644
index 0000000..28fdb76
--- /dev/null
+++ b/server/src/validations/addCollaborator.schema.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod';
+
+export const AddCollaboratorSchema = z.object({
+ email: z
+ .string()
+ .email()
+ .transform(email => email.trim().toLowerCase()),
+ permission: z.enum(['edit', 'view']).default('edit'),
+});
+
+export type AddCollaboratorSchema = z.infer;
diff --git a/server/src/validations/login.schema.ts b/server/src/validations/login.schema.ts
index 098cd79..a852fe5 100644
--- a/server/src/validations/login.schema.ts
+++ b/server/src/validations/login.schema.ts
@@ -1,7 +1,10 @@
import { z } from 'zod';
export const LoginUserSchema = z.object({
- email: z.string().email(),
+ email: z
+ .string()
+ .email()
+ .transform(email => email.trim().toLowerCase()),
password: z.string().min(6),
});
diff --git a/server/src/validations/register.schema.ts b/server/src/validations/register.schema.ts
index dfe08e9..6895311 100644
--- a/server/src/validations/register.schema.ts
+++ b/server/src/validations/register.schema.ts
@@ -1,7 +1,10 @@
import { z } from 'zod';
export const RegisterUserSchema = z.object({
- email: z.string().email(),
+ email: z
+ .string()
+ .email()
+ .transform(email => email.trim().toLowerCase()),
username: z.string().min(3),
password: z.string().min(6),
fullName: z.string().optional(),
diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts
index 4ff56bc..2b1847d 100644
--- a/server/test/auth.test.ts
+++ b/server/test/auth.test.ts
@@ -38,6 +38,24 @@ describe('Auth Routes', () => {
expect(res.status).toBe(StatusCodes.CONFLICT);
});
+ it('should normalize email case on register and allow lowercase login', async () => {
+ const register = await request(app).post('/api/auth/register').send({
+ email: 'MixedCase@Test.DEV',
+ username: 'mixedcase',
+ password: 'secure123',
+ });
+
+ expect(register.status).toBe(StatusCodes.CREATED);
+
+ const res = await request(app).post('/api/auth/login').send({
+ email: 'mixedcase@test.dev',
+ password: 'secure123',
+ });
+
+ expect(res.status).toBe(StatusCodes.OK);
+ expect(res.body.user.email).toBe('mixedcase@test.dev');
+ });
+
it('should login with valid credentials and receive cookies', async () => {
await request(app).post('/api/auth/register').send({
email: 'test@test.dev',
diff --git a/server/test/document.test.ts b/server/test/document.test.ts
index 072ba37..41e3927 100644
--- a/server/test/document.test.ts
+++ b/server/test/document.test.ts
@@ -1,3 +1,4 @@
+import { Prisma } from '@prisma/client';
import { StatusCodes } from 'http-status-codes';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -95,6 +96,24 @@ describe('Document Routes', () => {
expect(res.body.title).toBe('Updated Title');
});
+ it('should not overwrite document content via REST update (Yjs is the source of truth)', async () => {
+ const created = await prisma.document.create({
+ data: { title: 'Synced Doc', content: 'yjs-derived-content', authorId: userId },
+ });
+
+ const res = await request(app)
+ .put(`/api/document/${created.id}`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ title: 'Renamed', content: 'rest-overwrite-attempt' });
+
+ expect(res.status).toBe(StatusCodes.OK);
+ expect(res.body.title).toBe('Renamed');
+ expect(res.body.content).toBe('yjs-derived-content');
+
+ const doc = await prisma.document.findUniqueOrThrow({ where: { id: created.id } });
+ expect(doc.content).toBe('yjs-derived-content');
+ });
+
it('should delete a document', async () => {
const created = await prisma.document.create({
data: {
@@ -108,6 +127,39 @@ describe('Document Routes', () => {
expect(res.status).toBe(StatusCodes.NO_CONTENT);
});
+ it('should delete a document that has collaborators and join requests', async () => {
+ const created = await prisma.document.create({
+ data: {
+ title: 'Shared ToDelete',
+ authorId: userId,
+ content: '',
+ },
+ });
+
+ const otherUser = await prisma.user.create({
+ data: {
+ email: 'shared-collab@test.dev',
+ username: 'sharedcollab',
+ password: 'hashedpass',
+ },
+ });
+
+ await prisma.collaborator.create({
+ data: { documentId: created.id, userId: otherUser.id },
+ });
+
+ await prisma.collaborationRequest.create({
+ data: { documentId: created.id, userId: otherUser.id },
+ });
+
+ const res = await request(app).delete(`/api/document/${created.id}`).set('Authorization', `Bearer ${token}`);
+ expect(res.status).toBe(StatusCodes.NO_CONTENT);
+
+ expect(await prisma.document.findUnique({ where: { id: created.id } })).toBeNull();
+ expect(await prisma.collaborator.count({ where: { documentId: created.id } })).toBe(0);
+ expect(await prisma.collaborationRequest.count({ where: { documentId: created.id } })).toBe(0);
+ });
+
it('should update document settings (allowSelfJoin)', async () => {
const created = await prisma.document.create({
data: {
@@ -233,9 +285,11 @@ describe('Document Routes', () => {
const addRes = await request(app)
.post(`/api/document/${doc.id}/collaborators`)
.set('Authorization', `Bearer ${token}`)
- .send({ userId: newUser.id, permission: 'edit' });
+ .send({ email: newUser.email, permission: 'edit' });
expect(addRes.status).toBe(StatusCodes.OK);
+ expect(addRes.body.userId).toBe(newUser.id);
+ expect(addRes.body.permission).toBe('edit');
const collaboratorId = addRes.body.id;
@@ -246,6 +300,146 @@ describe('Document Routes', () => {
expect(removeRes.status).toBe(StatusCodes.OK);
});
+ it('should add a collaborator by email with default edit permission', async () => {
+ const doc = await prisma.document.create({
+ data: { title: 'Default Permission', authorId: userId, content: '' },
+ });
+
+ const newUser = await prisma.user.create({
+ data: {
+ email: 'defaultperm@test.dev',
+ username: 'defaultperm',
+ password: 'hashedpass',
+ },
+ });
+
+ const res = await request(app)
+ .post(`/api/document/${doc.id}/collaborators`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ email: newUser.email });
+
+ expect(res.status).toBe(StatusCodes.OK);
+ expect(res.body.userId).toBe(newUser.id);
+ expect(res.body.permission).toBe('edit');
+ });
+
+ it('should add a collaborator by email regardless of case', async () => {
+ const doc = await prisma.document.create({
+ data: { title: 'Case Insensitive', authorId: userId, content: '' },
+ });
+
+ const newUser = await prisma.user.create({
+ data: {
+ email: 'caseinsensitive@test.dev',
+ username: 'caseinsensitive',
+ password: 'hashedpass',
+ },
+ });
+
+ const res = await request(app)
+ .post(`/api/document/${doc.id}/collaborators`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ email: 'CaseInsensitive@Test.DEV' });
+
+ expect(res.status).toBe(StatusCodes.OK);
+ expect(res.body.userId).toBe(newUser.id);
+ });
+
+ it('should return 404 when adding a collaborator with an unknown email', async () => {
+ const doc = await prisma.document.create({
+ data: { title: 'Unknown Email', authorId: userId, content: '' },
+ });
+
+ const res = await request(app)
+ .post(`/api/document/${doc.id}/collaborators`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ email: 'nobody@test.dev' });
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+ });
+
+ it('should return 409 when adding an existing collaborator', async () => {
+ const doc = await prisma.document.create({
+ data: { title: 'Duplicate Collab', authorId: userId, content: '' },
+ });
+
+ const newUser = await prisma.user.create({
+ data: {
+ email: 'dupcollab@test.dev',
+ username: 'dupcollab',
+ password: 'hashedpass',
+ },
+ });
+
+ const first = await request(app)
+ .post(`/api/document/${doc.id}/collaborators`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ email: newUser.email });
+
+ expect(first.status).toBe(StatusCodes.OK);
+
+ const second = await request(app)
+ .post(`/api/document/${doc.id}/collaborators`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ email: newUser.email });
+
+ expect(second.status).toBe(StatusCodes.CONFLICT);
+ });
+
+ it('should return 400 when adding a collaborator with an invalid body', async () => {
+ const doc = await prisma.document.create({
+ data: { title: 'Invalid Body', authorId: userId, content: '' },
+ });
+
+ const res = await request(app)
+ .post(`/api/document/${doc.id}/collaborators`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ email: 'not-an-email' });
+
+ expect(res.status).toBe(StatusCodes.BAD_REQUEST);
+ });
+
+ it('should return 409 when a concurrent duplicate insert loses the race (P2002)', async () => {
+ const doc = await prisma.document.create({
+ data: { title: 'Race Collab', authorId: userId, content: '' },
+ });
+
+ const newUser = await prisma.user.create({
+ data: {
+ email: 'racecollab@test.dev',
+ username: 'racecollab',
+ password: 'hashedpass',
+ },
+ });
+
+ // Simulate the losing insert of two concurrent adds that both passed the
+ // pre-check: the unique constraint on (documentId, userId) rejects it.
+ const p2002 = new Prisma.PrismaClientKnownRequestError(
+ 'Unique constraint failed on the fields: (`documentId`,`userId`)',
+ { code: 'P2002', clientVersion: '6.12.0' }
+ );
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ const originalFindUnique = prisma.collaborator.findUnique;
+ const originalCreate = prisma.collaborator.create;
+ (prisma.collaborator as any).findUnique = async () => null;
+ (prisma.collaborator as any).create = async () => {
+ throw p2002;
+ };
+
+ try {
+ const res = await request(app)
+ .post(`/api/document/${doc.id}/collaborators`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({ email: newUser.email });
+
+ expect(res.status).toBe(StatusCodes.CONFLICT);
+ } finally {
+ (prisma.collaborator as any).findUnique = originalFindUnique;
+ (prisma.collaborator as any).create = originalCreate;
+ /* eslint-enable @typescript-eslint/no-explicit-any */
+ }
+ });
+
it('should forbid non-owners from listing collaborators', async () => {
const doc = await prisma.document.create({
data: { title: 'Private Doc', authorId: userId, content: '' },
@@ -355,3 +549,107 @@ describe('Document Routes', () => {
expect(res.status).toBe(StatusCodes.FORBIDDEN);
});
});
+
+describe('Collaboration request decision scoping (#46)', () => {
+ async function createOwnedDocument(title: string) {
+ return prisma.document.create({
+ data: { title, authorId: userId, content: '' },
+ });
+ }
+
+ async function createPendingRequest(documentId: string, suffix: string) {
+ const requester = await prisma.user.create({
+ data: {
+ email: `requester-${suffix}@test.dev`,
+ username: `requester-${suffix}`,
+ password: 'hashedpw',
+ },
+ });
+
+ return prisma.collaborationRequest.create({
+ data: { userId: requester.id, documentId },
+ });
+ }
+
+ it('should not approve a request belonging to a different document', async () => {
+ const otherDoc = await createOwnedDocument('Other Doc');
+ const collabRequest = await createPendingRequest(otherDoc.id, 'a');
+ const targetDoc = await createOwnedDocument('Target Doc');
+
+ const res = await request(app)
+ .post(`/api/document/${targetDoc.id}/requests/${collabRequest.id}/approve`)
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+
+ const untouched = await prisma.collaborationRequest.findUnique({ where: { id: collabRequest.id } });
+ expect(untouched?.status).toBe('pending');
+ });
+
+ it('should not reject a request belonging to a different document', async () => {
+ const otherDoc = await createOwnedDocument('Other Doc');
+ const collabRequest = await createPendingRequest(otherDoc.id, 'b');
+ const targetDoc = await createOwnedDocument('Target Doc');
+
+ const res = await request(app)
+ .delete(`/api/document/${targetDoc.id}/requests/${collabRequest.id}/reject`)
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+
+ const untouched = await prisma.collaborationRequest.findUnique({ where: { id: collabRequest.id } });
+ expect(untouched?.status).toBe('pending');
+ });
+
+ it('should return 404 when approving a nonexistent request', async () => {
+ const doc = await createOwnedDocument('Target Doc');
+
+ const res = await request(app)
+ .post(`/api/document/${doc.id}/requests/00000000-0000-4000-8000-000000000000/approve`)
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+ });
+
+ it('should return 404 when rejecting a nonexistent request', async () => {
+ const doc = await createOwnedDocument('Target Doc');
+
+ const res = await request(app)
+ .delete(`/api/document/${doc.id}/requests/00000000-0000-4000-8000-000000000000/reject`)
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(res.status).toBe(StatusCodes.NOT_FOUND);
+ });
+
+ it('should return 409 when approving an already-approved request', async () => {
+ const doc = await createOwnedDocument('Target Doc');
+ const collabRequest = await createPendingRequest(doc.id, 'c');
+
+ const first = await request(app)
+ .post(`/api/document/${doc.id}/requests/${collabRequest.id}/approve`)
+ .set('Authorization', `Bearer ${token}`);
+ expect(first.status).toBe(StatusCodes.OK);
+
+ const second = await request(app)
+ .post(`/api/document/${doc.id}/requests/${collabRequest.id}/approve`)
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(second.status).toBe(StatusCodes.CONFLICT);
+ });
+
+ it('should return 409 when rejecting an already-rejected request', async () => {
+ const doc = await createOwnedDocument('Target Doc');
+ const collabRequest = await createPendingRequest(doc.id, 'd');
+
+ const first = await request(app)
+ .delete(`/api/document/${doc.id}/requests/${collabRequest.id}/reject`)
+ .set('Authorization', `Bearer ${token}`);
+ expect(first.status).toBe(StatusCodes.OK);
+
+ const second = await request(app)
+ .delete(`/api/document/${doc.id}/requests/${collabRequest.id}/reject`)
+ .set('Authorization', `Bearer ${token}`);
+
+ expect(second.status).toBe(StatusCodes.CONFLICT);
+ });
+});
diff --git a/server/test/rateLimit.test.ts b/server/test/rateLimit.test.ts
new file mode 100644
index 0000000..63de4e8
--- /dev/null
+++ b/server/test/rateLimit.test.ts
@@ -0,0 +1,39 @@
+import express from 'express';
+import { StatusCodes } from 'http-status-codes';
+import request from 'supertest';
+import { describe, expect, it } from 'vitest';
+
+import { createAuthRateLimiter } from '@/middlewares/rate-limit.middleware';
+
+const buildApp = () => {
+ const app = express();
+ app.use(express.json());
+ // The shared limiter skips outside production (vitest runs with NODE_ENV=test),
+ // so the tests force it on via the factory's overrides.
+ app.post('/login', createAuthRateLimiter({ limit: 3, windowMs: 60_000, skip: () => false }), (_req, res) => {
+ res.status(StatusCodes.OK).json({ ok: true });
+ });
+ return app;
+};
+
+describe('auth rate limiter', () => {
+ it('allows requests up to the limit and returns 429 afterwards', async () => {
+ const app = buildApp();
+
+ for (let i = 0; i < 3; i++) {
+ const res = await request(app).post('/login');
+ expect(res.status).toBe(StatusCodes.OK);
+ }
+
+ const limited = await request(app).post('/login');
+ expect(limited.status).toBe(StatusCodes.TOO_MANY_REQUESTS);
+ expect(limited.body).toEqual({ error: 'Too many requests, please try again later.' });
+ });
+
+ it('sets the standard RateLimit header', async () => {
+ const res = await request(buildApp()).post('/login');
+
+ expect(res.headers['ratelimit']).toBeDefined();
+ expect(res.headers['ratelimit-policy']).toBeDefined();
+ });
+});