# 04 — Module loader & registry ## Goal Implement the module system that everything else hangs off: each module declares a manifest, the loader composes them at startup, and core services iterate the registry instead of hardcoding entity types. ## Why This is the load-bearing extensibility piece. Get this wrong and every later module needs core changes. The widget contract in particular must be set in stone now — modules and dashboards depend on it. ## Depends on - 03 (DB) ## Scope ### Module manifest type (`src/modules/_core/module.ts`) ```ts export type ModuleManifest = { id: string; // "calendar", "lists", "notes" name: string; // human-readable nav?: { href: string; label: string; icon?: string }; entities: EntityTypeRegistration[]; dashboardWidgets?: DashboardWidget[]; quickAdds?: QuickAddAction[]; }; export type EntityTypeRegistration = { type: string; // "calendar.event", "calendar.calendar", "lists.list", "notes.note" label: { singular: string; plural: string }; share?: ShareCapabilities; // omit = not shareable reminder?: ReminderCapabilities; // omit = not remindable search?: SearchAdapter; // omit = not searchable resolveUrl: (id: string) => string; loadForShare?: (id: string) => Promise; }; ``` ### Widget contract — uniform, configurable, reusable Every widget — no singletons, no exceptions — declares a config schema. The schema can be small, but the shape is the same. Every placement on a dashboard is an independent instance with its own config; the same widget can appear N times on the same dashboard pointed at different things. ```ts export type DashboardWidget = { id: string; // "calendar.upcoming", "lists.list", "core.activity" title: string; // shown in picker + drag handle description: string; // shown in picker category?: string; // grouping in picker defaultSize: { w: number; h: number }; minSize?: { w: number; h: number }; maxSize?: { w: number; h: number }; defaultPriority: number; // initial seed order on a fresh dashboard configSchema: ZodSchema; // always present; may be empty (z.object({})) defaultConfig: unknown; // returned by the picker when a user adds the widget resolveConfigOptions?: (ctx: WidgetContext) => Promise; // returns whatever the configurator UI needs (e.g. the user's accessible // calendars). Called by the picker; not by render. render: (props: { config: unknown; ctx: WidgetContext }) => ReactNode; }; export type WidgetContext = { userId: string; householdId: string; }; ``` Conventions every parameterized widget follows: - Selection fields use the standardized `"all" | string[]` pattern (e.g. `calendarIds: "all" | string[]`). `"all"` means "every instance the user can access". Default config = `"all"`. - Render must tolerate any config the schema accepts — an empty selection is "show empty state with a 'configure' link". ### Registry (`src/modules/_core/registry.ts`) - `registerModule(manifest)`, `getRegistry()` returning frozen views. - `getEntityType(type)` lookup. - `getWidget(id)` lookup. - Import-time side-effect free: modules are listed and registered explicitly in `src/modules/index.ts`. ### Loader (`src/modules/index.ts`) - Imports each module's manifest and calls `registerModule`. Order is deterministic. - Stub modules for now: `calendar`, `lists`, `notes` each export an empty-but-valid manifest so the loader has something to register. ### Wiring - Root layout reads the registry to render nav. - A throwaway `/debug/registry` page (dev-only) dumps the loaded registry as JSON, including every widget's id, config schema (rendered via `zod-to-json-schema`), and default config. ## Out of scope - Real implementations of share / reminder / search adapters (later phases). - Real widget rendering (modules ship those when they're built). - Dynamic plugin loading from disk. Modules are statically imported. ## Acceptance criteria - [ ] `src/modules/index.ts` registers three stub modules. - [ ] Nav renders entries from the registry, not from a hardcoded list. - [ ] `/debug/registry` shows all three module manifests in dev. - [ ] Adding a fourth stub module requires only creating its folder + adding one line to `src/modules/index.ts`. - [ ] All types exported from `_core` so other modules can import without circulars. - [ ] No widget is special-cased in `_core` — the registry treats every widget uniformly. ## Notes - Use `zod` for `configSchema`. Pin a single major version across the repo so types stay compatible. - `resolveConfigOptions` is for the picker's data, never for rendering. Keep it cheap (it runs on add-widget); render is what fetches displayed data. - Frozen objects (`Object.freeze`) on registry exposure are cheap insurance against accidental mutation. - Resist the urge to make this a fancy DI container. A plain map is enough.