diff --git a/apps/web/src/components/knowledge/__tests__/KnowledgeEditor.test.tsx b/apps/web/src/components/knowledge/__tests__/KnowledgeEditor.test.tsx
new file mode 100644
index 0000000..14821dc
--- /dev/null
+++ b/apps/web/src/components/knowledge/__tests__/KnowledgeEditor.test.tsx
@@ -0,0 +1,89 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { KnowledgeEditor } from "../KnowledgeEditor";
+
+// Mock Tiptap since it requires a full DOM with contenteditable support
+vi.mock("@tiptap/react", () => {
+ const EditorContent = ({ editor }: { editor: unknown }): React.JSX.Element => (
+
+ );
+
+ return {
+ useEditor: (): null => null,
+ EditorContent,
+ };
+});
+
+// Mock tiptap-markdown
+vi.mock("tiptap-markdown", () => ({
+ Markdown: {
+ configure: vi.fn().mockReturnValue({}),
+ },
+}));
+
+// Mock lowlight
+vi.mock("lowlight", () => ({
+ common: {},
+ createLowlight: vi.fn().mockReturnValue({}),
+}));
+
+// Mock extensions
+vi.mock("@tiptap/starter-kit", () => ({
+ default: { configure: vi.fn().mockReturnValue({}) },
+}));
+vi.mock("@tiptap/extension-link", () => ({
+ default: { configure: vi.fn().mockReturnValue({}) },
+}));
+vi.mock("@tiptap/extension-table", () => ({
+ Table: { configure: vi.fn().mockReturnValue({}) },
+}));
+vi.mock("@tiptap/extension-table-row", () => ({
+ TableRow: {},
+}));
+vi.mock("@tiptap/extension-table-cell", () => ({
+ TableCell: {},
+}));
+vi.mock("@tiptap/extension-table-header", () => ({
+ TableHeader: {},
+}));
+vi.mock("@tiptap/extension-code-block-lowlight", () => ({
+ default: { configure: vi.fn().mockReturnValue({}) },
+}));
+vi.mock("@tiptap/extension-placeholder", () => ({
+ default: { configure: vi.fn().mockReturnValue({}) },
+}));
+
+describe("KnowledgeEditor", (): void => {
+ const defaultProps = {
+ content: "",
+ onChange: vi.fn(),
+ };
+
+ beforeEach((): void => {
+ vi.clearAllMocks();
+ });
+
+ it("should render loading state when editor is null", (): void => {
+ render();
+ expect(screen.getByText("Loading editor...")).toBeInTheDocument();
+ });
+
+ it("should render with knowledge-editor class", (): void => {
+ // When editor is null, the loading fallback renders instead
+ const { container } = render();
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ it("should accept optional placeholder prop", (): void => {
+ // Smoke test that it doesn't crash with custom placeholder
+ render();
+ expect(screen.getByText("Loading editor...")).toBeInTheDocument();
+ });
+
+ it("should accept optional editable prop", (): void => {
+ // Smoke test that it doesn't crash when read-only
+ render();
+ expect(screen.getByText("Loading editor...")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/widgets/__tests__/WidgetConfigDialog.test.tsx b/apps/web/src/components/widgets/__tests__/WidgetConfigDialog.test.tsx
new file mode 100644
index 0000000..e07d9cd
--- /dev/null
+++ b/apps/web/src/components/widgets/__tests__/WidgetConfigDialog.test.tsx
@@ -0,0 +1,63 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { WidgetConfigDialog } from "../WidgetConfigDialog";
+
+describe("WidgetConfigDialog", (): void => {
+ const defaultProps = {
+ widgetId: "TasksWidget-abc123",
+ open: true,
+ onClose: vi.fn(),
+ };
+
+ beforeEach((): void => {
+ vi.clearAllMocks();
+ });
+
+ it("should render nothing when closed", (): void => {
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("should render dialog when open", (): void => {
+ render();
+ expect(screen.getByRole("dialog", { name: "Widget Settings" })).toBeInTheDocument();
+ });
+
+ it("should show widget name in header", (): void => {
+ render();
+ // TasksWidget is registered with displayName "Tasks"
+ expect(screen.getByText(/Tasks.*Settings/)).toBeInTheDocument();
+ });
+
+ it("should show placeholder message when no config schema", (): void => {
+ render();
+ expect(
+ screen.getByText("No configuration options available for this widget yet.")
+ ).toBeInTheDocument();
+ });
+
+ it("should call onClose when close button is clicked", async (): Promise => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByLabelText("Close"));
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onClose when footer Close button is clicked", async (): Promise => {
+ const user = userEvent.setup();
+ render();
+
+ // Footer has a "Close" text button
+ await user.click(screen.getByText("Close"));
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should handle unknown widget type gracefully", (): void => {
+ render();
+ // Should show fallback "Widget Settings" when type is not in registry
+ expect(screen.getByText("Widget Settings")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx b/apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
new file mode 100644
index 0000000..dfc680a
--- /dev/null
+++ b/apps/web/src/components/widgets/__tests__/WidgetPicker.test.tsx
@@ -0,0 +1,101 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { WidgetPicker } from "../WidgetPicker";
+import type { WidgetPlacement } from "@mosaic/shared";
+
+describe("WidgetPicker", (): void => {
+ const defaultProps = {
+ open: true,
+ onClose: vi.fn(),
+ onAddWidget: vi.fn(),
+ currentLayout: [] as WidgetPlacement[],
+ };
+
+ beforeEach((): void => {
+ vi.clearAllMocks();
+ });
+
+ it("should render nothing when closed", (): void => {
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("should render dialog when open", (): void => {
+ render();
+ expect(screen.getByRole("dialog", { name: "Add Widget" })).toBeInTheDocument();
+ });
+
+ it("should display Add Widget heading", (): void => {
+ render();
+ expect(screen.getByText("Add Widget")).toBeInTheDocument();
+ });
+
+ it("should render search input", (): void => {
+ render();
+ expect(screen.getByPlaceholderText("Search widgets...")).toBeInTheDocument();
+ });
+
+ it("should display available widgets", (): void => {
+ render();
+ // Widget registry has multiple widgets; at least one Add button should appear
+ const addButtons = screen.getAllByText("Add");
+ expect(addButtons.length).toBeGreaterThan(0);
+ });
+
+ it("should filter widgets by search text", async (): Promise => {
+ const user = userEvent.setup();
+ render();
+
+ const searchInput = screen.getByPlaceholderText("Search widgets...");
+ // Type a search term that won't match any widget
+ await user.type(searchInput, "zzz-nonexistent-widget-zzz");
+
+ expect(screen.getByText("No widgets found")).toBeInTheDocument();
+ });
+
+ it("should call onAddWidget when Add is clicked", async (): Promise => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ render();
+
+ const addButtons = screen.getAllByText("Add");
+ await user.click(addButtons[0]!);
+
+ expect(onAdd).toHaveBeenCalledTimes(1);
+ const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
+ expect(placement).toHaveProperty("i");
+ expect(placement).toHaveProperty("x");
+ expect(placement).toHaveProperty("y");
+ expect(placement).toHaveProperty("w");
+ expect(placement).toHaveProperty("h");
+ });
+
+ it("should call onClose when close button is clicked", async (): Promise => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByLabelText("Close"));
+ expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it("should place new widgets after existing layout items", async (): Promise => {
+ const user = userEvent.setup();
+ const onAdd = vi.fn();
+ const existingLayout: WidgetPlacement[] = [
+ { i: "test-1", x: 0, y: 0, w: 6, h: 3 },
+ { i: "test-2", x: 0, y: 3, w: 6, h: 2 },
+ ];
+ render();
+
+ const addButtons = screen.getAllByText("Add");
+ await user.click(addButtons[0]!);
+
+ const placement = onAdd.mock.calls[0]![0] as WidgetPlacement;
+ // Should be placed at y=5 (3+2) to avoid overlap
+ expect(placement.y).toBe(5);
+ });
+});
diff --git a/docs/TASKS.md b/docs/TASKS.md
index 7b47bfd..32cf633 100644
--- a/docs/TASKS.md
+++ b/docs/TASKS.md
@@ -14,21 +14,21 @@
| TW-WDG-004 | done | Widget configuration UI — Per-widget settings dialog using configSchema, configure data source/filters/colors/title | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~8K | PR #499 merged (bundled with WDG-005) |
| TW-WDG-005 | done | Layout management UI — Save/rename/switch/delete layouts, reset to default. UI controls in dashboard header area | #488 | web | feat/ms18-layout-management | TW-WDG-002 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 20K | ~8K | PR #499 merged (bundled with WDG-004) |
| TW-EDT-001 | done | Tiptap integration — Install @tiptap/react + extensions, build KnowledgeEditor component with toolbar (headings, bold, italic, lists, code, links, tables) | #489 | web | feat/ms18-tiptap-editor | TW-PLAN-001 | TW-EDT-002 | worker | 2026-02-23 | 2026-02-23 | 35K | ~12K | PR #500 merged |
-| TW-EDT-002 | done | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | feat/ms18-markdown-roundtrip | TW-EDT-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #501 (pending) |
-| TW-KBN-001 | not-started | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | TBD | TW-PLAN-001 | TW-VER-001 | worker | — | — | 30K | — | |
-| TW-VER-001 | not-started | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | TBD | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | — | — | 25K | — | |
+| TW-EDT-002 | done | Markdown round-trip + File Manager integration — Import markdown to Tiptap, export to markdown + HTML. Replace textarea in knowledge create/edit | #489 | web | feat/ms18-markdown-roundtrip | TW-EDT-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #501 merged |
+| TW-KBN-001 | done | Kanban filtering — Add filter bar (project, assignee, priority, search). Support project-level and user-level views. URL param persistence | #490 | web | feat/ms18-kanban-filtering | TW-PLAN-001 | TW-VER-001 | worker | 2026-02-23 | 2026-02-23 | 30K | ~10K | PR #502 merged |
+| TW-VER-001 | done | Tests — Unit tests for new components, update existing tests, fix any regressions | #491 | web | feat/ms18-verification-tests | TW-WDG-003,TW-WDG-004,TW-WDG-005,TW-EDT-002,TW-KBN-001 | TW-VER-002,TW-DOC-001 | worker | 2026-02-23 | 2026-02-23 | 25K | ~8K | PR pending; 20 new tests (1195 total) |
| TW-VER-002 | not-started | Theme verification — Verify all 5 themes render correctly on all pages, no broken colors/contrast issues | #491 | web | TBD | TW-THM-003,TW-VER-001 | TW-DOC-001 | worker | — | — | 15K | — | |
| TW-DOC-001 | not-started | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #491 | — | — | TW-VER-001,TW-VER-002 | TW-VER-003 | orchestrator | — | — | 10K | — | |
| TW-VER-003 | not-started | Deploy to Coolify + smoke test — Deploy, verify themes/widgets/editor/kanban all functional, auth working, no console errors | #491 | — | — | TW-DOC-001 | | orchestrator | — | — | 15K | — | |
## Summary
-| Metric | Value |
-| ------------- | ---------------------------------------------------- |
-| Total tasks | 16 |
-| Completed | 12 (PLAN-001, THM-001–003, WDG-001–005, EDT-001–002) |
-| In Progress | 0 |
-| Remaining | 4 |
-| PRs merged | #493–#500, #501 (pending) |
-| Issues closed | — |
-| Milestone | MS18-ThemeWidgets |
+| Metric | Value |
+| ------------- | ---------------------------------------------------------------------- |
+| Total tasks | 16 |
+| Completed | 14 (PLAN-001, THM-001–003, WDG-001–005, EDT-001–002, KBN-001, VER-001) |
+| In Progress | 0 |
+| Remaining | 2 |
+| PRs merged | #493–#502 |
+| Issues closed | #487, #488, #489, #490 |
+| Milestone | MS18-ThemeWidgets |