diff --git a/STATUS.md b/STATUS.md
index 8a2b0fd..c825f33 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -7,10 +7,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
+- **04 — Module loader & registry**. `src/modules/_core/module.ts` types (`ModuleManifest`, `EntityTypeRegistration`, `DashboardWidget`, etc.), `registry.ts` singleton with `registerModule`/`getRegistry`/`getEntityType`/`getWidget`, barrel `_core/index.ts`. Stub manifests for `calendar`, `lists`, `notes`. `src/modules/index.ts` loader. Root layout imports loader; `AppNav` reads registry for nav links. `/debug/registry` dumps full registry JSON (dev only). Uses zod v4 + built-in `z.toJSONSchema()`. `tsc --noEmit`, `pnpm build`, `pnpm lint` all clean.
## Next up
-- **04 — Module loader** ([brief](docs/tasks/04-module-loader.md)). Phase 1 is sequential through 08.
+- **05 — Compose + Caddy** ([brief](docs/tasks/05-compose-caddy.md)).
## Phase 1 remaining
diff --git a/package.json b/package.json
index 5dd3c94..cd9156c 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,8 @@
"react-dom": "^19.2.5",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.5.0",
- "tw-animate-css": "^1.4.0"
+ "tw-animate-css": "^1.4.0",
+ "zod": "^4.4.3",
+ "zod-to-json-schema": "^3.25.2"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d3baa20..d347444 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -44,6 +44,12 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ zod-to-json-schema:
+ specifier: ^3.25.2
+ version: 3.25.2(zod@4.4.3)
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.5
@@ -7142,6 +7148,10 @@ snapshots:
dependencies:
zod: 3.25.76
+ zod-to-json-schema@3.25.2(zod@4.4.3):
+ dependencies:
+ zod: 4.4.3
+
zod-validation-error@4.0.2(zod@4.4.3):
dependencies:
zod: 4.4.3
diff --git a/src/app/debug/registry/page.tsx b/src/app/debug/registry/page.tsx
new file mode 100644
index 0000000..98cc0cf
--- /dev/null
+++ b/src/app/debug/registry/page.tsx
@@ -0,0 +1,51 @@
+import { notFound } from "next/navigation";
+import { z } from "zod";
+import { getRegistry } from "@/modules/_core/registry";
+import "@/modules";
+
+export default function RegistryDebugPage() {
+ if (process.env.NODE_ENV !== "development") {
+ notFound();
+ }
+
+ const { modules } = getRegistry();
+
+ const output = {
+ modules: modules.map((m) => ({
+ id: m.id,
+ name: m.name,
+ nav: m.nav ?? null,
+ entities: m.entities.map((e) => ({
+ type: e.type,
+ label: e.label,
+ hasShare: !!e.share,
+ hasReminder: !!e.reminder,
+ hasSearch: !!e.search,
+ })),
+ dashboardWidgets: (m.dashboardWidgets ?? []).map((w) => ({
+ id: w.id,
+ title: w.title,
+ description: w.description,
+ category: w.category ?? null,
+ defaultSize: w.defaultSize,
+ defaultPriority: w.defaultPriority,
+ configSchema: z.toJSONSchema(w.configSchema),
+ defaultConfig: w.defaultConfig,
+ })),
+ quickAdds: (m.quickAdds ?? []).map((q) => ({
+ id: q.id,
+ label: q.label,
+ icon: q.icon ?? null,
+ })),
+ })),
+ };
+
+ return (
+
+
Module Registry (dev only)
+
+ {JSON.stringify(output, null, 2)}
+
+
+ );
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 05472e2..5273a42 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
+import { AppNav } from "@/components/app-nav";
+import "@/modules"; // registers all module manifests
-const geist = Geist({subsets:['latin'],variable:'--font-sans'});
+const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: "famapp",
@@ -17,7 +19,10 @@ export default function RootLayout({
}) {
return (
- {children}
+
+
+ {children}
+
);
}
diff --git a/src/components/app-nav.tsx b/src/components/app-nav.tsx
new file mode 100644
index 0000000..df2fbae
--- /dev/null
+++ b/src/components/app-nav.tsx
@@ -0,0 +1,24 @@
+import Link from "next/link";
+import { getRegistry } from "@/modules/_core/registry";
+
+export function AppNav() {
+ const { modules } = getRegistry();
+ const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : []));
+
+ return (
+
+ );
+}
diff --git a/src/modules/_core/index.ts b/src/modules/_core/index.ts
new file mode 100644
index 0000000..fd530eb
--- /dev/null
+++ b/src/modules/_core/index.ts
@@ -0,0 +1,12 @@
+export type {
+ ModuleManifest,
+ EntityTypeRegistration,
+ DashboardWidget,
+ QuickAddAction,
+ WidgetContext,
+ ShareCapabilities,
+ ReminderCapabilities,
+ SearchAdapter,
+ SearchResult,
+} from "./module";
+export { registerModule, getRegistry, getEntityType, getWidget } from "./registry";
diff --git a/src/modules/_core/module.ts b/src/modules/_core/module.ts
new file mode 100644
index 0000000..e35f3b2
--- /dev/null
+++ b/src/modules/_core/module.ts
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+import type { ZodType } from "zod";
+
+export type ShareCapabilities = {
+ canShare: boolean;
+ defaultCapabilities?: string[];
+};
+
+export type ReminderCapabilities = {
+ canRemind: boolean;
+};
+
+export type SearchResult = {
+ id: string;
+ title: string;
+ url: string;
+ excerpt?: string;
+};
+
+export type SearchAdapter = {
+ search: (query: string, householdId: string) => Promise;
+};
+
+export type WidgetContext = {
+ userId: string;
+ householdId: string;
+};
+
+export type DashboardWidget = {
+ id: string;
+ title: string;
+ description: string;
+ category?: string;
+ defaultSize: { w: number; h: number };
+ minSize?: { w: number; h: number };
+ maxSize?: { w: number; h: number };
+ defaultPriority: number;
+ configSchema: ZodType;
+ defaultConfig: unknown;
+ resolveConfigOptions?: (ctx: WidgetContext) => Promise;
+ render: (props: { config: unknown; ctx: WidgetContext }) => ReactNode;
+};
+
+export type QuickAddAction = {
+ id: string;
+ label: string;
+ icon?: string;
+ action: (ctx: WidgetContext) => void | Promise;
+};
+
+export type EntityTypeRegistration = {
+ type: string;
+ label: { singular: string; plural: string };
+ share?: ShareCapabilities;
+ reminder?: ReminderCapabilities;
+ search?: SearchAdapter;
+ resolveUrl: (id: string) => string;
+ loadForShare?: (id: string) => Promise;
+};
+
+export type ModuleManifest = {
+ id: string;
+ name: string;
+ nav?: { href: string; label: string; icon?: string };
+ entities: EntityTypeRegistration[];
+ dashboardWidgets?: DashboardWidget[];
+ quickAdds?: QuickAddAction[];
+};
diff --git a/src/modules/_core/registry.ts b/src/modules/_core/registry.ts
new file mode 100644
index 0000000..7cb98a3
--- /dev/null
+++ b/src/modules/_core/registry.ts
@@ -0,0 +1,31 @@
+import type { ModuleManifest, EntityTypeRegistration, DashboardWidget } from "./module";
+
+const modules = new Map();
+const entityTypes = new Map();
+const widgets = new Map();
+
+export function registerModule(manifest: ModuleManifest): void {
+ modules.set(manifest.id, manifest);
+ for (const entity of manifest.entities) {
+ entityTypes.set(entity.type, entity);
+ }
+ for (const widget of manifest.dashboardWidgets ?? []) {
+ widgets.set(widget.id, widget);
+ }
+}
+
+export function getRegistry() {
+ return Object.freeze({
+ modules: Object.freeze([...modules.values()]),
+ entityTypes: Object.freeze([...entityTypes.values()]),
+ widgets: Object.freeze([...widgets.values()]),
+ });
+}
+
+export function getEntityType(type: string): EntityTypeRegistration | undefined {
+ return entityTypes.get(type);
+}
+
+export function getWidget(id: string): DashboardWidget | undefined {
+ return widgets.get(id);
+}
diff --git a/src/modules/calendar/manifest.ts b/src/modules/calendar/manifest.ts
new file mode 100644
index 0000000..abf7310
--- /dev/null
+++ b/src/modules/calendar/manifest.ts
@@ -0,0 +1,23 @@
+import type { ModuleManifest } from "../_core/module";
+
+const manifest: ModuleManifest = {
+ id: "calendar",
+ name: "Calendar",
+ nav: { href: "/calendar", label: "Calendar", icon: "calendar" },
+ entities: [
+ {
+ type: "calendar.calendar",
+ label: { singular: "Calendar", plural: "Calendars" },
+ resolveUrl: (id) => `/calendar?id=${id}`,
+ },
+ {
+ type: "calendar.event",
+ label: { singular: "Event", plural: "Events" },
+ resolveUrl: (id) => `/calendar/events/${id}`,
+ },
+ ],
+ dashboardWidgets: [],
+ quickAdds: [],
+};
+
+export default manifest;
diff --git a/src/modules/index.ts b/src/modules/index.ts
new file mode 100644
index 0000000..4ea8d1b
--- /dev/null
+++ b/src/modules/index.ts
@@ -0,0 +1,8 @@
+import { registerModule } from "./_core/registry";
+import calendarManifest from "./calendar/manifest";
+import listsManifest from "./lists/manifest";
+import notesManifest from "./notes/manifest";
+
+registerModule(calendarManifest);
+registerModule(listsManifest);
+registerModule(notesManifest);
diff --git a/src/modules/lists/manifest.ts b/src/modules/lists/manifest.ts
new file mode 100644
index 0000000..0c2b2ab
--- /dev/null
+++ b/src/modules/lists/manifest.ts
@@ -0,0 +1,23 @@
+import type { ModuleManifest } from "../_core/module";
+
+const manifest: ModuleManifest = {
+ id: "lists",
+ name: "Lists",
+ nav: { href: "/lists", label: "Lists", icon: "list" },
+ entities: [
+ {
+ type: "lists.list",
+ label: { singular: "List", plural: "Lists" },
+ resolveUrl: (id) => `/lists/${id}`,
+ },
+ {
+ type: "lists.item",
+ label: { singular: "List item", plural: "List items" },
+ resolveUrl: (id) => `/lists/items/${id}`,
+ },
+ ],
+ dashboardWidgets: [],
+ quickAdds: [],
+};
+
+export default manifest;
diff --git a/src/modules/notes/manifest.ts b/src/modules/notes/manifest.ts
new file mode 100644
index 0000000..9730d53
--- /dev/null
+++ b/src/modules/notes/manifest.ts
@@ -0,0 +1,18 @@
+import type { ModuleManifest } from "../_core/module";
+
+const manifest: ModuleManifest = {
+ id: "notes",
+ name: "Notes",
+ nav: { href: "/notes", label: "Notes", icon: "file-text" },
+ entities: [
+ {
+ type: "notes.note",
+ label: { singular: "Note", plural: "Notes" },
+ resolveUrl: (id) => `/notes/${id}`,
+ },
+ ],
+ dashboardWidgets: [],
+ quickAdds: [],
+};
+
+export default manifest;