@omerdlw/base-framework 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +121 -52
  3. package/dist/hooks/index.d.ts +1 -10
  4. package/dist/hooks/index.d.ts.map +1 -1
  5. package/dist/hooks/index.js +2 -538
  6. package/dist/hooks/index.js.map +1 -1
  7. package/dist/hooks/types.d.ts +0 -53
  8. package/dist/hooks/types.d.ts.map +1 -1
  9. package/dist/hooks/use-global-event.d.ts +0 -2
  10. package/dist/hooks/use-global-event.d.ts.map +1 -1
  11. package/dist/index.js +138 -742
  12. package/dist/index.js.map +1 -1
  13. package/dist/kernel/index.js +3 -3
  14. package/dist/kernel/index.js.map +1 -1
  15. package/dist/modules/ambient/index.js.map +1 -1
  16. package/dist/modules/background/index.js +5 -5
  17. package/dist/modules/background/index.js.map +1 -1
  18. package/dist/modules/context-menu/index.js +4 -4
  19. package/dist/modules/context-menu/index.js.map +1 -1
  20. package/dist/modules/controls/index.js +3 -3
  21. package/dist/modules/controls/index.js.map +1 -1
  22. package/dist/modules/dock/cards.d.ts +1 -1
  23. package/dist/modules/dock/hooks.d.ts +1 -1
  24. package/dist/modules/dock/index.js +3 -3
  25. package/dist/modules/dock/index.js.map +1 -1
  26. package/dist/modules/index.js +34 -115
  27. package/dist/modules/index.js.map +1 -1
  28. package/dist/modules/loading/index.js +3 -3
  29. package/dist/modules/loading/index.js.map +1 -1
  30. package/dist/modules/media/index.js.map +1 -1
  31. package/dist/modules/modal/index.js +11 -95
  32. package/dist/modules/modal/index.js.map +1 -1
  33. package/dist/modules/notification/index.js +7 -5
  34. package/dist/modules/notification/index.js.map +1 -1
  35. package/dist/provider.js.map +1 -1
  36. package/dist/utils/dom.d.ts +0 -2
  37. package/dist/utils/dom.d.ts.map +1 -1
  38. package/dist/utils/index.d.ts +5 -6
  39. package/dist/utils/index.d.ts.map +1 -1
  40. package/dist/utils/index.js +19 -84
  41. package/dist/utils/index.js.map +1 -1
  42. package/dist/utils/number.d.ts +0 -1
  43. package/dist/utils/number.d.ts.map +1 -1
  44. package/dist/utils/object.d.ts +0 -2
  45. package/dist/utils/object.d.ts.map +1 -1
  46. package/dist/utils/string.d.ts +0 -3
  47. package/dist/utils/string.d.ts.map +1 -1
  48. package/dist/utils/timing.d.ts +0 -1
  49. package/dist/utils/timing.d.ts.map +1 -1
  50. package/docs/README.md +124 -0
  51. package/docs/architecture-and-rules.md +129 -0
  52. package/docs/core-engine.md +196 -0
  53. package/docs/custom-modules.md +267 -0
  54. package/docs/getting-started.md +199 -0
  55. package/docs/modules/README.md +176 -0
  56. package/docs/modules/ambient.md +150 -0
  57. package/docs/modules/background.md +195 -0
  58. package/docs/modules/context-menu.md +248 -0
  59. package/docs/modules/controls.md +159 -0
  60. package/docs/modules/dock.md +734 -0
  61. package/docs/modules/error-boundary.md +160 -0
  62. package/docs/modules/kernel.md +266 -0
  63. package/docs/modules/loading.md +157 -0
  64. package/docs/modules/media.md +196 -0
  65. package/docs/modules/modal.md +229 -0
  66. package/docs/modules/notification.md +161 -0
  67. package/docs/modules/result.md +135 -0
  68. package/docs/testing.md +160 -0
  69. package/docs/theming.md +185 -0
  70. package/package.json +3 -1
  71. package/templates/AGENTS.md +165 -0
@@ -0,0 +1,160 @@
1
+ # Error Boundary — `@omerdlw/base-framework/error`
2
+
3
+ > **Read this first if you need to:** stop one crash from taking down the app, wrap a risky widget, send errors to Sentry (or another sink), or understand how a runtime error becomes the dock's error card.
4
+
5
+ ## 1. At a glance
6
+
7
+ | | |
8
+ | :--------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | **Import** | `import { ComponentError, getErrorReporter, … } from "@omerdlw/base-framework/error"` |
10
+ | **Layer** | `src/core` (frozen upstream). Client-only (`"use client"`). |
11
+ | **Installed by** | `CoreProvider`: `GlobalError` wraps everything, `ModuleError` is the boundary around every module backdrop/overlay, `GlobalErrorListener` is mounted once |
12
+ | **Emits** | `EVENT_TYPES.APP_ERROR` (`{ error, errorInfo?, message, resetError? }`) |
13
+ | **Theme** | `errorTheme` slots `screen`, `icon`, `title`, `retryButton` (`src/config/error.core.theme.ts`) |
14
+ | **Tests** | `tests/core/error.test.ts` |
15
+
16
+ ## 2. Mental model
17
+
18
+ ```
19
+ render error ──▶ nearest boundary (Component < Module < Global)
20
+ ├─ onError(error, info, context) (your hook)
21
+ ├─ globalEvents.emit(APP_ERROR, …) unless silent → dock shows its error card
22
+ ├─ ErrorReporter.captureError(error, context) → handlers (console / Sentry / yours)
23
+ └─ renders the fallback (default card with "Try again")
24
+
25
+ window "error" / "unhandledrejection" ──▶ GlobalErrorListener (filtered, throttled)
26
+ ├─ ErrorReporter.captureError
27
+ └─ globalEvents.emit(APP_ERROR)
28
+
29
+ report(scope, error) anywhere ──▶ the reporter sink installed by GlobalErrorListener
30
+ ```
31
+
32
+ Boundaries are **class components** (React requires it) configured through props. Raw `Error.message` values never reach the UI: the displayed text is a fixed copy or `toUserMessage(error)`.
33
+
34
+ ## 3. Quick start
35
+
36
+ ```tsx
37
+ // Inline: a failing widget degrades to a small card with "Try again"
38
+ <ComponentError message="Chart failed to load">
39
+ <RevenueChart />
40
+ </ComponentError>
41
+
42
+ // Custom fallback (render prop)
43
+ <ComponentError fallback={({ error, resetError }) => <MyEmpty onRetry={resetError} />}>…</ComponentError>
44
+
45
+ // Reset when a key changes (e.g. the selected id) and observe errors
46
+ <ErrorBoundaryCore resetKey={id} variant="inline" onError={(e, info, ctx) => track(ctx.route)}>…</ErrorBoundaryCore>
47
+ ```
48
+
49
+ ```ts
50
+ // Wire a reporting sink once, e.g. in an app-level client component
51
+ import * as Sentry from "@sentry/nextjs";
52
+ getErrorReporter({ sampleRate: 0.5 })
53
+ .addHandler(createSentryHandler(Sentry))
54
+ .setContext("release", process.env.NEXT_PUBLIC_RELEASE)
55
+ .setTag("app", "web");
56
+ ```
57
+
58
+ ## 4. Boundaries
59
+
60
+ | Component | Variant | Fallback copy | Resets when | Notes |
61
+ | :------------------ | :------- | :--------------------------------------------------------------- | :-------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------- |
62
+ | `GlobalError` | `full` | "We ran into an unexpected problem. Please try again" | the **pathname** changes (and on "Try again") | Outermost; also emits `APP_ERROR`. |
63
+ | `ModuleError` | `module` | "This part of the app isn't working right now. Please try again" | "Try again" | Used by `ModuleHost` around every module's backdrop and overlay and by `ModuleBoundary`; `name` identifies the module in the report context. |
64
+ | `ComponentError` | `inline` | `message` or "This section couldn't be loaded. Please try again" | "Try again" | For widgets. |
65
+ | `ErrorBoundaryCore` | any | `message` / `toUserMessage(error)` | "Try again" or when `resetKey` changes | The shared implementation. |
66
+
67
+ `ErrorBoundaryCore` props:
68
+
69
+ | Prop | Meaning |
70
+ | :------------------------------ | :----------------------------------------------------------------------------- |
71
+ | `fallback` | A node, or `({ error, resetError }) => node`. |
72
+ | `message`, `title` | Default-card text; `title` and `name` are also recorded in the report context. |
73
+ | `name`, `variant` | Reported as context (`name`, `variant`). |
74
+ | `resetKey` | When it changes the boundary clears its error state. |
75
+ | `onError(error, info, context)` | Called first; exceptions inside it are caught and logged as warnings. |
76
+ | `onReset` | Called after "Try again" / `resetError()`. |
77
+ | `silent` | Do **not** emit `APP_ERROR` (no dock card); the reporter is still called. |
78
+
79
+ The `context` passed to `onError` and the reporter: `{ componentStack, route, userAgent, timestamp, name, variant, source: "ErrorBoundary" }`.
80
+
81
+ ## 5. `GlobalErrorListener`
82
+
83
+ Mounted once by `CoreProvider`. It listens to `window` `error` and `unhandledrejection`, then:
84
+
85
+ 1. **Ignores** noise (`shouldIgnoreError`): empty errors, Next.js not-found errors, and messages matching `ResizeObserver loop`, `Network request failed`, `Loading chunk`, `Unexpected end of input`, `Failed to fetch`, `Script error`, `HTTP 404`.
86
+ 2. **Throttles:** at most **10 errors** per page lifetime, at least **2 s** apart, and each distinct message **once**.
87
+ 3. Sends the error to the reporter (`source: "window.onerror" | "unhandledrejection"`) and emits `APP_ERROR` with `message: toUserMessage(error)`.
88
+
89
+ It also installs the reporter as the **sink** of `report()` (below).
90
+
91
+ ## 6. `ErrorReporter`
92
+
93
+ `getErrorReporter(options?)` returns a **singleton** (options apply on the first call only).
94
+
95
+ | Option | Default | Meaning |
96
+ | :------------------- | :-------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
97
+ | `enabled` | `true` | Master switch. |
98
+ | `sampleRate` | `1` | 0–1 probability per capture. |
99
+ | `deduplicateWindow` | 60 000 ms | A report with the same **fingerprint** (`componentStack top :: message[0..100] :: name :: route`) is dropped inside the window; at most 100 fingerprints are tracked. |
100
+ | `beforeSend(report)` | — | Return a changed report, or `null` to drop it. |
101
+
102
+ Methods: `addHandler({ name, handle })`, `removeHandler(name)`, `setContext(key, value)` (max 10 keys), `setTag(key, value)`, `captureError(error, extraContext?)` → `ErrorReport | undefined`, `captureMessage(message, level?, context?)`.
103
+
104
+ Default handlers are added lazily in the browser **only if you have not added any**: a console handler (silent in production) and a Sentry handler when `window.Sentry` exists. `createSentryHandler(Sentry)` falls back to the console handler when the object lacks `captureException` / `withScope`; it sets fingerprint, user, tags, `environment` and `custom` contexts and the component stack. Handler exceptions are swallowed.
105
+
106
+ `ErrorReport`: `{ error: { name, message, stack }, fingerprint, timestamp, environment (route, url, userAgent, platform, language, online), componentStack, context, tags, user? }`.
107
+
108
+ ## 7. User-facing messages and `report()`
109
+
110
+ Raw `Error.message` values (database, SDK, vendor wording) never reach the UI. `toUserMessage(error, { fallback?, codes? })` from `@omerdlw/base-framework/utils` is the single gate: only a `UserError`, a string `Result` error, or a 4xx payload from our own API is shown as written; everything else maps to a fixed sentence in `USER_MESSAGES` (`generic`, `network`, `timeout`, `unauthorized`, `forbidden`, `notFound`, `rateLimited`, `server`). Server code throws `UserError` for validation/permission text it wants shown; route handlers answer with `apiErrorResponse` from `@/infrastructure/http/api-error`.
111
+
112
+ `report(scope, error, level?)` from `@omerdlw/base-framework/utils` replaces `console.*` everywhere. On the client, `GlobalErrorListener` installs the reporter as the sink; without a sink, browsers stay silent in production and servers always log. Only the reporter's dev-only console handler touches `console`.
113
+
114
+ ## 8. Public API
115
+
116
+ | Export | Kind | Description |
117
+ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
118
+ | `GlobalError`, `ModuleError`, `ComponentError` | components | The three boundaries (§4). |
119
+ | `ErrorBoundaryCore` | class component | Shared implementation. |
120
+ | `GlobalErrorListener` | component | Window error/rejection listener (§5). |
121
+ | `getErrorReporter(options?)` | factory | Singleton reporter (§6). |
122
+ | `createConsoleHandler()`, `createSentryHandler(Sentry)` | factories | Sinks. |
123
+ | `createReport`, `createErrorContext`, `fingerprint`, `getErrorMessage`, `shouldIgnoreError`, `getBrowserEnvironment`, `normalizeSampleRate`, `normalizeDedupeWindow`, `getRuntimePath`, `getUserAgent` | utils | Report construction and filtering. |
124
+ | `ERROR_MESSAGES`, `ERROR_LISTENER_CONFIG`, `DEFAULT_DEDUPE_WINDOW` (60 000), `MAX_CONTEXT` (10), `MAX_FINGERPRINTS` (100), `errorTheme` | constants | Copy, limits, theme spec. |
125
+ | Types | | `ErrorReport`, `ErrorReporterHandler`, `ErrorReporterOptions`, `ErrorBoundaryCoreProps`, `ErrorFallbackRender`, `ErrorContextData`, `BrowserEnvironment`, `ErrorThemeSlot`, … |
126
+
127
+ ## 9. Recipes and gotchas
128
+
129
+ | I want to… | Do this |
130
+ | :----------------------------------------- | :---------------------------------------------------------------------- |
131
+ | Isolate a risky widget | `<ComponentError>`. |
132
+ | Handle an error myself without a dock card | `<ErrorBoundaryCore silent onError={…}>`. |
133
+ | Send errors to Sentry | `getErrorReporter().addHandler(createSentryHandler(Sentry))`. |
134
+ | Report a handled error | `report("Checkout", error)` (and keep the UI message user-friendly). |
135
+ | Drop PII before sending | `getErrorReporter({ beforeSend: (r) => ({ ...r, user: undefined }) })`. |
136
+
137
+ - Error boundaries catch **render/lifecycle** errors, not errors in event handlers or async code; those reach the `GlobalErrorListener` (or call `report`).
138
+ - `getErrorReporter` ignores options after the first call; configure it once, early.
139
+ - The default fallback does not render `title`; it shows `message` (or the mapped user message).
140
+ - A `GlobalError` reset on navigation means a crash on one route does not stick to the next.
141
+
142
+ ## 10. File map
143
+
144
+ | File | Responsibility |
145
+ | :------------- | :-------------------------------------------------------------------------------------------------------- |
146
+ | `index.ts` | Public barrel. |
147
+ | `types.ts` | Report, context, environment, reporter options, boundary props, Sentry scope. |
148
+ | `constants.ts` | Messages, listener config (limits, ignore patterns), dedupe limits. |
149
+ | `utils.ts` | Fingerprinting, report creation, environment capture, error normalization and ignore rules. |
150
+ | `boundary.tsx` | `ErrorBoundaryCore`, default fallback, `GlobalError`, `ModuleError`, `ComponentError`: the module's view. |
151
+ | `listener.ts` | `GlobalErrorListener`. |
152
+ | `reporter.ts` | `ErrorReporter` (sampling, dedupe, handlers) and sink factories. |
153
+ | `theme.ts` | `errorTheme` spec. |
154
+
155
+ `builder.ts`, `provider.tsx`, `view.tsx` and `motion.ts` are intentionally absent ([README](./README.md#42-why-some-optional-slots-are-absent)).
156
+
157
+ ## 11. Dependencies
158
+
159
+ - **Uses:** `@omerdlw/base-framework/events` (`APP_ERROR`), `@omerdlw/base-framework/atoms` (`Button`, `Icon` for the fallback), `@omerdlw/base-framework/theme`, `@omerdlw/base-framework/utils` (`report`, `setReportSink`, `toUserMessage`), `next/navigation` (`usePathname`).
160
+ - **Used by:** `src/core/provider.tsx`; the dock (turns `APP_ERROR` into its error status) and the notification module (toasts `APP_ERROR` with `notify: true`) react to the event without importing this module.
@@ -0,0 +1,266 @@
1
+ # Kernel — `@omerdlw/base-framework/kernel`
2
+
3
+ > **Read this first if you need to:** register something for the current page (`usePage`), understand how modules install and find each other, write your own module, or debug why a registration did or did not win.
4
+
5
+ ## 1. At a glance
6
+
7
+ | | |
8
+ | :------------------- | :-------------------------------------------------------------------------------------------------------------------- |
9
+ | **Import** | `import { usePage, defineModule, definePeer, useModule, … } from "@omerdlw/base-framework/kernel"` |
10
+ | **Layer** | `src/core` (frozen upstream). Knows **no module by name**; never imports `@omerdlw/base-framework/modules/**`, `@/features/**`, `@/app/**`. |
11
+ | **Mounted by** | `CoreProvider` (`src/core/provider.tsx`) → `ModuleHost` |
12
+ | **State** | A scoped, subscribable **registry** (`createRegistryStore`) and a **page controller stack** |
13
+ | **Extension points** | `defineModule({...})`, module augmentation of `CoreModules`, `PageConfig`, `RegistrySchema` |
14
+ | **Tests** | `tests/core/kernel.test.ts` |
15
+
16
+ ## 2. Mental model
17
+
18
+ ```
19
+ CoreProvider
20
+ └─ GlobalError ─ Tooltip ─ ModuleHost ───────────────────────────────────────────────┐
21
+ │ 1. sortModules(modules) ← dependency order from `uses`, validates ids │
22
+ │ 2. RegistryProvider ← one registry; types come from module `registry` definitions
23
+ │ 3. ModuleHost context ← id → module map, boundary component
24
+ │ 4. PageControllerProvider ← the stack of mounted usePage() controllers
25
+ │ 5. providers nested (module N inside the modules it `uses`)
26
+ │ 6. <Backdrop/> per module → {children} → <Overlay/> per module (each in an error boundary)
27
+ └────────────────────────────────────────────────────────────────────────────────┘
28
+
29
+ usePage(config)
30
+ for every installed module: page.select(config) → slice
31
+ page.entries(slice) → registry entries (applied in ONE batch)
32
+ page.use(slice, {set}) → page.modules[id] (the module's page API)
33
+ ```
34
+
35
+ Three pieces:
36
+
37
+ 1. **Modules** — self-describing units (`defineModule`). They reach each other only through `useModule` / `useModuleState` / `definePeer`, never by importing.
38
+ 2. **The registry** — a key/value store per module type. Several _sources_ can register the same key; the kernel picks (or merges) the winner and cleans up per lifecycle.
39
+ 3. **Pages** — `usePage(config)` turns one object into every module's registrations and page API. The kernel itself only owns `title`, `registry`, `set` and `reset`.
40
+
41
+ ## 3. Quick start
42
+
43
+ ```tsx
44
+ "use client";
45
+ import { usePage } from "@omerdlw/base-framework/kernel";
46
+
47
+ export function DashboardClient() {
48
+ const page = usePage({
49
+ title: "Dashboard", // kernel: also the dock title fallback
50
+ dock: { description: "Overview", icon: "solar:chart-2-bold" },
51
+ background: { image: "/images/dashboard.jpg", overlay: true },
52
+ loading: isFetching,
53
+ contextMenu: {
54
+ items: [{ key: "refresh", label: "Refresh", onSelect: refetch }],
55
+ },
56
+ });
57
+
58
+ return (
59
+ <Button onClick={() => page.modules.notification?.toast("Saved")}>
60
+ Save
61
+ </Button>
62
+ );
63
+ }
64
+ ```
65
+
66
+ ```ts
67
+ // Static route metadata: src/app/registry.tsx (registry type = module id)
68
+ export const APP_REGISTRY_ENTRIES = [
69
+ {
70
+ type: "dock",
71
+ items: { "/": { title: "Home", icon: "solar:home-2-bold", path: "/" } },
72
+ },
73
+ ];
74
+ ```
75
+
76
+ ## 4. `usePage`
77
+
78
+ ```ts
79
+ const page = usePage(config?: PageConfig, options?: PageOptions): PageController
80
+ ```
81
+
82
+ | `PageController` member | Meaning |
83
+ | :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
84
+ | `config` | The effective config (your object merged with runtime `set` overrides). |
85
+ | `modules` | Each module's **page API** (`page.modules.dock`, `.background`, `.notification` …). Present only when the module is installed **and** defines `page.use`. Always use optional chaining: `page.modules.notification?.toast(...)`. |
86
+ | `set(partial)` | Shallow-merge runtime overrides into the config (objects one level deep). Modules expose typed wrappers (`page.modules.dock.set({...})`). |
87
+ | `reset()` | Drop all runtime overrides. |
88
+ | `Provider` | A component that scopes this controller to a subtree (`usePageController()` inside it returns _this_ controller). |
89
+
90
+ `PageConfig` always has `title?` and `registry?`; every module **augments** it (`dock?`, `background?`, `loading?`, `modal?`, `controls?`, `contextMenu?`, `ambient?`, `media?`, `notification?`).
91
+
92
+ `PageOptions` (second argument): registry metadata applied to **all** the page's registrations (`priority`, `source`, `lifecycle`, `scope`, `instanceId`, …). A module slice can override it per item with its own `registry` key.
93
+
94
+ ### 4.1 What happens on each render
95
+
96
+ 1. `config` + runtime overrides → `effectiveConfig`.
97
+ 2. Every module's `page.select` computes its slice; `createModulePayload` keeps modules that have `page.entries` and a non-null slice.
98
+ 3. **Stabilization.** The payload is walked and (a) object/array identity is reused when nothing changed, (b) function values (arrow functions, handlers) are replaced by **stable proxies** that always call the latest function, and (c) React elements are cloned with stabilized props. Result: inline `onClick`/`items: () => …` do **not** cause re-registration on every render. Functions whose name starts with a capital letter (components) and direct component values of `valueKind: "component"` types are passed through by identity.
99
+ 4. `applyRegistryConfig` validates (`definition.validate`), then registers every entry in **one batch**; the cleanup runs when the payload changes or the component unmounts.
100
+ 5. Each module's `page.use(slice, { set })` runs (it is a hook, so it must not be conditional) and the results become `page.modules`.
101
+
102
+ ### 4.2 Rules
103
+
104
+ - Call `usePage` once per page component (or layout) at the top level. It is a hook; do not call it conditionally.
105
+ - `usePageController()` is the read-only variant: it returns the active controller without registering anything (used by chrome that needs the current page).
106
+ - Server components cannot call it: put it in a `"use client"` component.
107
+
108
+ ## 5. Registry
109
+
110
+ ### 5.1 Type definitions (`RegistryDefinition`, on `defineModule({ registry })`)
111
+
112
+ | Field | Meaning |
113
+ | :----------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
114
+ | `keyPolicy` | `"singleton"` (one fixed `singletonKey`), `"named"` (any non-empty string), `"path"` (must start with `/`), `"route"` (a `/…` path **or** one of `reservedKeys`). |
115
+ | `lifecycle` | Default lifecycle (below). |
116
+ | `cleanupDelayMs` | Default delay for delayed cleanup. |
117
+ | `valueKind` | `"object"` (default: values must be plain objects) or `"component"` (functions or component objects). |
118
+ | `merge(valuesLowToHigh)` | Combine several sources instead of picking one (dock merges card fields and styles). |
119
+ | `validate(value)` | Return `{ valid, issues }`; invalid values are skipped with a warning (`strict` mode also validates metadata). |
120
+ | `reservedKeys`, `singletonKey` | See key policies. |
121
+
122
+ ### 5.2 Who wins
123
+
124
+ For one `(type, key)` every registration is a **record** with `source`, `priority`, and a sequence number. Records are ordered by:
125
+
126
+ 1. `priority` — explicit number, otherwise derived from the source (`static` 100, `dynamic` 200, `user` 300);
127
+ 2. source rank (`static` < `dynamic` < `user`);
128
+ 3. recency (the later registration wins ties).
129
+
130
+ The highest record is the value, **or** `definition.merge` receives all values from lowest to highest (only when all are plain objects).
131
+
132
+ | Source | Typical use |
133
+ | :-------- | :--------------------------------------------------- |
134
+ | `static` | `src/app/registry.tsx` initial entries. |
135
+ | `dynamic` | **Default** for `usePage` / `useModuleRegistration`. |
136
+ | `user` | User-driven overrides. Pass `{ source: "user" }`. |
137
+
138
+ `REGISTRY_SOURCES` exposes the vocabulary.
139
+
140
+ ### 5.3 Lifecycles
141
+
142
+ | Lifecycle | Cleanup when the registering page/component goes away |
143
+ | :------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------ |
144
+ | `immediate` | Entry removed in the same batch (forces a delay of 0 even if the type declares one). |
145
+ | `graceful`, `route` | Removed after `cleanupDelayMs` (from options or the type definition) — so route transitions do not flicker. With no delay they behave like `immediate`. |
146
+ | `persistent` | Never removed by the page; use `unregister`. |
147
+
148
+ A pending cleanup is **cancelled** if the same entry is re-registered before the timer fires.
149
+
150
+ ### 5.4 Metadata keys (`RegistryMetadata`)
151
+
152
+ `lifecycle` (or `cleanup`), `cleanupDelayMs`, `priority`, `source`, `scope`, `instanceId`, `validation` (`"warn"` default or `"strict"`). Anything else is reported by `validateRegistryMetadata`. `scope` partitions entries (default `"app"`); `instanceId` is stamped by the kernel so a component only unregisters its own entries.
153
+
154
+ ### 5.5 Reading the registry
155
+
156
+ | Hook | Returns |
157
+ | :---------------------------- | :--------------------------------------------------------------- |
158
+ | `useRegistryValue(type, key)` | The resolved value of one key (re-renders only when it changes). |
159
+ | `useRegistryEntries(type)` | `Record<key, value>` of every key of a type. |
160
+
161
+ The store also supports `register` / `unregister` / `batch` / `transaction` (atomic multi-entry change with a trace id) through `createRegistryStore`; most code never calls them directly (`usePage` and `useModuleRegistration` do).
162
+
163
+ ## 6. Modules
164
+
165
+ ### 6.1 `defineModule`
166
+
167
+ ```ts
168
+ export const myModule = defineModule({
169
+ id: "announcement", // camelCase: /^[a-z][a-zA-Z0-9]*$/ ("contextMenu", not "context-menu")
170
+ context: MyContext, // React context carrying { actions, store } (ModuleRuntime)
171
+ Provider: MyProvider, // wraps the app (nested after the modules it `uses`)
172
+ Backdrop: MyBackdrop, // rendered BEFORE children (behind the page)
173
+ Overlay: MyOverlay, // rendered AFTER children (above the page)
174
+ uses: ["background"], // optional peers (ordering + dev warning if missing)
175
+ registry: { keyPolicy: "singleton", singletonKey: "current", lifecycle: "route" },
176
+ page: {
177
+ select: (config) => config.announcement, // default: config[id]
178
+ entries: (slice, { pathname }) => [{ key: "current", value: slice }],
179
+ use: (slice, { set }) => ({ show: () => set({ announcement: {...} }) }), // page.modules.announcement
180
+ },
181
+ });
182
+
183
+ declare module "@omerdlw/base-framework/kernel" {
184
+ interface CoreModules { announcement: typeof myModule }
185
+ interface PageConfig { announcement?: AnnouncementConfig }
186
+ }
187
+ ```
188
+
189
+ `defineModule` freezes and returns the definition. `ModuleHost` throws on **invalid ids**, **duplicate ids**, and **dependency cycles**; in development it warns when a `uses` peer is not installed. Every field except `id` is optional: a module can be only an overlay, only a registry type, only a provider.
190
+
191
+ ### 6.2 Talking to other modules
192
+
193
+ | API | Use |
194
+ | :------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
195
+ | `useModule(id)` | The module's runtime `{ actions, store }`, or `null` when not installed. |
196
+ | `useModuleState(id, selector, fallback, isEqual?)` | A slice of another module's state; returns `fallback` when it is not installed. Only re-renders when the slice changes. |
197
+ | `definePeer(id, inert)` | A typed reader `{ useRuntime, useActions, useState }` that falls back to the `inert` stand-in when the module is missing. **List the id in `uses`.** This is the pattern the dock, background and ambient use. |
198
+ | `useIsModuleInstalled(id)` | Boolean. |
199
+ | `ModuleBoundary` | Wrap module UI in the host's error boundary (`name` is shown in the error UI). |
200
+
201
+ Pass the module id to `useModule`, `useModuleState` and `definePeer` as a **string literal** (`definePeer("media", …)`) and list it in the module's `uses`: `npm run check:architecture` greps for both.
202
+
203
+ ### 6.3 Install order and rendering
204
+
205
+ `sortModules` orders modules so that every module comes **after** the modules it `uses`; `ModuleHost` nests providers in that order (a later module's provider is _inside_ earlier ones, so it can read them). All **Backdrops** render first, then `children`, then all **Overlays**, each wrapped in the boundary passed to the host (`ModuleError` from `@omerdlw/base-framework/error` in `CoreProvider`).
206
+
207
+ ### 6.4 Registering without `usePage`
208
+
209
+ `useModuleRegistration(id, value, { enabled?, ...metadata })` registers one module's slice (each module re-exports a named wrapper: `useDockRegistration`, `useBackgroundRegistration`, `useLoadingRegistration`, …). `enabled: false` or a `null` value removes it.
210
+
211
+ ## 7. Public API
212
+
213
+ | Export | Kind | Description |
214
+ | :-------------------------------------------------------------------------------------------------------------------------------- | :------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
215
+ | `defineModule(definition)` | factory | Declare a module (§6.1). |
216
+ | `ModuleHost`, `ModuleBoundary` | components | Install modules; wrap UI in the module error boundary. |
217
+ | `useModule`, `useModuleState`, `useIsModuleInstalled`, `definePeer` | hooks / factory | Module-to-module access (§6.2). |
218
+ | `useModuleRegistration` | hook | Register one slice outside `usePage`. |
219
+ | `usePage`, `usePageController`, `PageControllerProvider` | hooks / provider | Page registration and read access (§4). |
220
+ | `RegistryProvider`, `useRegistryValue`, `useRegistryEntries`, `createRegistryStore` | provider / hooks / factory | Registry access (§5). |
221
+ | `createRegistryOperations`, `createModuleRegistryDefinitions`, `sortModules`, `applyRegistryConfig`, `createRegistryApplyContext` | functions | Lower-level pieces (used by the host and by tests). |
222
+ | `Compose`, `ProviderEntry` | component / type | Nest providers from a list (`[Component, props]` tuples allowed). |
223
+ | `REGISTRY_SOURCES`, `hasOwnProperty` | constants / util | |
224
+ | Types (`export * from "./types"`) | | `CoreModule`, `CoreModules`, `ModuleId`, `ModuleRuntime`, `ModuleStateOf`, `PageConfig`, `PageController`, `PageModules`, `PageOptions`, `RegistryDefinition`, `RegistryMetadata`, `RegistrySchema`, `AppRegistryEntry`, `ValidationResult`, … |
225
+
226
+ ## 8. Recipes and gotchas
227
+
228
+ | I want to… | Do this |
229
+ | :--------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------- |
230
+ | Static page metadata for every route | Add to `APP_REGISTRY_ENTRIES` in `src/app/registry.tsx`. |
231
+ | Let a layout set a default that pages override | Register in the layout with `{ source: "static" }` (or a lower `priority`); pages use the default `dynamic`. |
232
+ | Install only some modules | `<CoreProvider modules={[dockModule, notificationModule]}>`. Modules that `use` a missing peer fall back to inert stand-ins. |
233
+ | Add my own module | Follow §6.1; export it, append it to `modules`, augment the kernel types. No edits to `src/core`. |
234
+ | Read a module's state without coupling | `useModuleState("dock", s => s.expanded, false)`. |
235
+
236
+ - `page.modules.x` is `undefined` for modules that are not installed or have no `page.use`: use `?.`.
237
+ - `ModuleHost` reads the `modules` list **once** (at mount). Changing the array later has no effect; decide the module set before rendering `CoreProvider`.
238
+ - A `useRegistryValue` read happens **after** the registering effect: the first render of the registering component sees `undefined`.
239
+ - `usePage` registrations are page-scoped: they disappear (per lifecycle) when the component unmounts, so call it in the component that owns the page.
240
+ - A module id is an identifier everywhere (registry type, `page.modules[id]`, `uses`): keep it camelCase and stable.
241
+
242
+ ## 9. File map
243
+
244
+ | File | Responsibility |
245
+ | :-------------------- | :------------------------------------------------------------------------------------------------------------------- |
246
+ | `index.ts` | Public barrel. |
247
+ | `types.ts` | Registry, module, page-config and controller contracts. |
248
+ | `constants.ts` | Registry sources, source priority/rank, lifecycles, metadata keys, default scope. |
249
+ | `utils.ts` | Input and scope resolution, config merging (`mergeModuleConfigs`), small guards. |
250
+ | `schema.ts` | Key/value/metadata validation, metadata normalization (lifecycle → cleanup delay). |
251
+ | `operations.ts` | Pure registry state machine (`createRegistryOperations`): records, ordering, merge, resolution cache. |
252
+ | `store.ts` | `createRegistryStore`: subscriptions, snapshots, handles, `batch`, `transaction`. |
253
+ | `runtime.ts` | Transactions. |
254
+ | `provider.tsx` | `RegistryProvider`, `useRegistryValue`, `useRegistryEntries`, `useRegistryActions`. |
255
+ | `handlers.ts` | `applyRegistryConfig`: slices → entries in one batch, cleanup per lifecycle. |
256
+ | `hooks.ts` | `useRegistry` (select → stabilize → apply), `createRegistryApplyContext`, `useShallowStable`, config stabilization. |
257
+ | `adapters.tsx` | `useModuleRegistration`. |
258
+ | `installed.ts` | Host context, `sortModules`, payload creation, registry definitions from modules. |
259
+ | `module.tsx` | `defineModule`, `ModuleHost`, `useModule`, `useModuleState`, `definePeer`, `ModuleBoundary`, `useIsModuleInstalled`. |
260
+ | `page-controller.tsx` | `usePage` (producer), `usePageController` (consumer), controller stack and scopes. |
261
+ | `compose.ts` | `Compose` and provider-entry helpers. |
262
+
263
+ ## 10. Dependencies
264
+
265
+ - **Uses:** `@omerdlw/base-framework/utils`, `@omerdlw/base-framework/hooks` only. It names no module and imports no upper layer.
266
+ - **Used by:** every module (`defineModule`, registry hooks, peers), `src/core/provider.tsx` (mounts `ModuleHost`), `src/app/registry.tsx` (initial entries), pages and features (`usePage`, `useModuleState`).
@@ -0,0 +1,157 @@
1
+ # Loading — `@omerdlw/base-framework/modules/loading`
2
+
3
+ > **Read this first if you need to:** show a full-screen loading state for a page or an async action, keep a spinner up long enough not to flicker, supply your own skeleton, or read "is the app busy" from another module.
4
+
5
+ ## 1. At a glance
6
+
7
+ | | |
8
+ | :----------------- | :-------------------------------------------------------------------------------------------------------- |
9
+ | **Import** | `import { useLoadingActions, useLoading, defineLoading, … } from "@omerdlw/base-framework/modules/loading"` |
10
+ | **Module id** | `loading` (`loadingModule`, in `defaultModules`) |
11
+ | **Renders** | `LoadingOverlay`: a fixed full-screen `<div role="status" aria-busy>` with a `Spinner` (or your skeleton) |
12
+ | **Registry type** | `loading` — singleton key `page-loading`, lifecycle `graceful`, cleanup delay 600 ms |
13
+ | **Page slice** | `usePage({ loading })` → `page.modules.loading` (all actions + `set`) |
14
+ | **Peers (`uses`)** | none (the dock reads _this_ module through `definePeer("loading")`) |
15
+ | **Theme** | `loadingTheme` slot `overlay` in `src/config/loading.module.theme.ts` |
16
+ | **z-index** | `Z_INDEX.LOADING` (set in the theme `styles`) |
17
+ | **Tests** | `tests/modules/loading.test.ts` |
18
+
19
+ ## 2. Mental model
20
+
21
+ There are two independent sources of "loading", merged into one state:
22
+
23
+ ```
24
+ page-registered (usePage({ loading }), useLoading(config)) ← declarative, tied to the route
25
+ manual (startLoading / stopLoading / withLoading) ← imperative, tied to a task
26
+ └────────────── LoadingStateWithPage ──────────────┘
27
+ ```
28
+
29
+ - **Page-registered wins.** If the registered config has `isLoading: true`, its `message`, `skeleton`, `showOverlay` (and the fact that it is loading) are used and the manual state is ignored until it turns `false`.
30
+ - Otherwise the **manual** state is used.
31
+ - The overlay appears when `isLoading && showOverlay` **and** no `FullscreenState` primitive is active (a full-screen empty/error state always takes precedence).
32
+ - Separately from the overlay, the **dock** reacts to `isLoading` (it shows its loading card; see [dock.md §7](./dock.md#7-attention-who-owns-the-top-card)). `showOverlay: false` suppresses only the full-screen overlay, not the dock's loading card.
33
+
34
+ ## 3. Quick start
35
+
36
+ ```tsx
37
+ // Declarative: loading while a route-level query resolves
38
+ usePage({ loading: isFetching ? "Loading profile…" : false }); // string = message (and loading)
39
+
40
+ // Imperative: the overlay stays up at least `minDuration`
41
+ const { withLoading } = useLoadingActions();
42
+ await withLoading(saveSettings(), { message: "Saving…", minDuration: 400 });
43
+ await withLoading(() => refresh(), "Refreshing…"); // a string is the message
44
+
45
+ // From the page controller
46
+ const page = usePage({ loading: false });
47
+ page.modules.loading?.set("Refreshing…");
48
+ ```
49
+
50
+ ```tsx
51
+ // Reusable preset
52
+ const CheckoutLoading = defineLoading({
53
+ message: "Processing payment",
54
+ showOverlay: true,
55
+ });
56
+ CheckoutLoading.use(isSubmitting); // boolean | message string | LoadingOptions
57
+ ```
58
+
59
+ ## 4. Public API
60
+
61
+ | Export | Kind | Description |
62
+ | :-------------------------------------------------------------- | :-------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
63
+ | `loadingModule` | module | `defineModule` definition. |
64
+ | `LoadingProvider` | component | Holds manual state, timers, merges the page-registered value, publishes the store. Mounted by the host. |
65
+ | `LoadingOverlay` | component | The overlay. Mounted by the host. |
66
+ | `useLoading(config?, options?)` | hook | Registers `config` as the page's loading config **and** returns `{ ...state, ...actions }`. `options`: `RegistryMetadata & { enabled? }`. |
67
+ | `useLoadingState()` | hook | `LoadingStateWithPage` (re-renders on change). Throws outside the provider. |
68
+ | `useLoadingActions()` | hook | Stable actions. |
69
+ | `useLoadingRegistration(config, options?)` | hook | Register only (what `usePage` does). |
70
+ | `defineLoading(def)` | builder | `defineLoading(options \| message).use(overrides?, options?)` → `LoadingStateWithPage & LoadingActions`. |
71
+ | `LoadingContext` | context | For tests and the dock's peer reader. |
72
+ | `DEFAULT_LOADING_STATE`, `LOADING_REGISTRY_KEY`, `loadingTheme` | constants | |
73
+ | `normalizeLoadingOptions(options)`, `selectPageLoading(input)` | utils | Coerce loose input; map `boolean` / `string` / object page input to `LoadingOptions`. |
74
+ | Types | | `LoadingState`, `LoadingStateWithPage`, `LoadingOptions`, `LoadingActions`, `LoadingPageConfig`, `DefinedLoading`, … |
75
+
76
+ ### 4.1 `LoadingOptions`
77
+
78
+ | Option | Default | Meaning |
79
+ | :------------ | :------ | :----------------------------------------------------------------------- |
80
+ | `isLoading` | — | Only meaningful for page-registered configs. |
81
+ | `message` | `null` | Text (non-strings are dropped). Available in state for custom skeletons. |
82
+ | `skeleton` | `null` | A node shown instead of the spinner. |
83
+ | `minDuration` | `0` | Minimum ms the **manual** loading stays up. Non-positive / invalid → 0. |
84
+ | `showOverlay` | `true` | `false` keeps state without the full-screen overlay. |
85
+
86
+ ### 4.2 `LoadingStateWithPage`
87
+
88
+ `{ isLoading, isPageLoading, message, skeleton, minDuration, showOverlay }`. `isPageLoading` is `true` whenever loading is on (page-registered or manual).
89
+
90
+ ### 4.3 Actions
91
+
92
+ | Action | Behaviour |
93
+ | :-------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------- |
94
+ | `startLoading(options?)` | Turn manual loading on (cancels a pending delayed stop) and record the start time. |
95
+ | `stopLoading()` | Turn it off. If `minDuration` has not elapsed yet, the stop is **delayed** by the remaining time. With no `minDuration` it is immediate. |
96
+ | `setLoading(boolean)` | Shorthand for the two above (no options). |
97
+ | `setSkeleton(nodeOrUpdater)` | Replace the manual skeleton; accepts `(current) => next`. No re-render when unchanged. |
98
+ | `withLoading(task, options \| message)` | `startLoading`, await the promise (or call the function and await its result), `stopLoading` in `finally`. Rejections propagate. |
99
+
100
+ `page.modules.loading` = these actions plus `set(loading)` (`page.set({ loading })`).
101
+
102
+ ### 4.4 Page input forms
103
+
104
+ | `usePage({ loading })` | Becomes |
105
+ | :--------------------- | :------------------------------------------- |
106
+ | `true` / `false` | `{ isLoading }` |
107
+ | `"Loading…"` | `{ isLoading: true, message: "Loading…" }` |
108
+ | object | used as is (needs `isLoading: true` to show) |
109
+ | `null` / `undefined` | nothing registered |
110
+
111
+ The same value also feeds the dock's `isLoading` card flag (`usePage({ dock })` derives it from `loading`).
112
+
113
+ ## 5. Behaviour details
114
+
115
+ - **Graceful cleanup.** The registry entry survives 600 ms after the page unmounts, so a route change does not flash the overlay off and on between pages.
116
+ - **`minDuration` is manual-only.** Page-registered loading ends as soon as the page stops declaring it.
117
+ - **Navigation.** The dock calls `stopLoading()` after a navigation completes or times out (15 s), so a `startLoading()` made before navigating does not stick.
118
+ - **No counter.** Manual loading is a single flag. Two overlapping `withLoading` calls share it: the **first** to finish stops it. Use a page-level `loading` flag derived from your own pending count if you need reference counting.
119
+ - **Fullscreen suppression.** While `useIsFullscreenStateActive()` is true (a full-screen view is active) the overlay is hidden.
120
+ - **Accessibility.** The overlay has `role="status"`, `aria-busy="true"` and `aria-label="Loading"`.
121
+ - **Next.js `loading.tsx`.** `src/app/loading.tsx` (a bare `Spinner`) is the route-segment fallback and is independent of this module.
122
+
123
+ ## 6. Theming
124
+
125
+ No classes or inline styles in the module. The `loadingTheme` slot `overlay` (starter: `center fixed inset-0 h-screen w-screen`) and its `styles` (`zIndex: Z_INDEX.LOADING`) come from `src/config/loading.module.theme.ts`. The default content is `<Spinner size={30} />`; pass a `skeleton` to replace it. The overlay has no animation (it mounts and unmounts instantly; [README](./README.md#42-why-some-optional-slots-are-absent)).
126
+
127
+ ## 7. Recipes and gotchas
128
+
129
+ | I want to… | Do this |
130
+ | :------------------------------------------ | :------------------------------------------------------------------------------------------- |
131
+ | Block the UI during a mutation | `await withLoading(mutate(), "Saving…")` |
132
+ | Avoid a flash for fast tasks | `minDuration: 300` (only delays the _stop_; it does not delay showing). |
133
+ | Loading state without the overlay | `showOverlay: false` and read `useLoadingState().isLoading` yourself. |
134
+ | Know if the app is busy from another module | `definePeer("loading", inert)` and `useState((s) => s.isLoading)`; list `loading` in `uses`. |
135
+ | A custom loader | `skeleton: <MyLoader />` in the options. |
136
+
137
+ - `useLoading(config)` registers the config for the **page**, not for the component; two components registering different configs fight over the singleton key (higher registry priority wins).
138
+ - `defineLoading(...).use(false)` registers `{ isLoading: false }`, which is a no-op loading state (useful to switch it off).
139
+ - A `message` is stored in state but the default overlay does not print it; render it in your `skeleton`.
140
+
141
+ ## 8. File map
142
+
143
+ | File | Responsibility |
144
+ | :------------- | :------------------------------------------------------------------------------------------------ |
145
+ | `index.ts` | Public barrel. |
146
+ | `types.ts` | State, options, actions, definition, page-config and theme contracts. |
147
+ | `constants.ts` | `DEFAULT_LOADING_STATE`, registry key, `loadingTheme`. |
148
+ | `utils.ts` | `normalizeLoadingOptions`, `selectPageLoading`. |
149
+ | `context.tsx` | `LoadingProvider` (manual state, min-duration timer, registry merge, store), `useLoading*` hooks. |
150
+ | `hooks.ts` | `useLoadingOverlayModel` (visibility incl. fullscreen check, theme). |
151
+ | `overlay.tsx` | `LoadingOverlay`. |
152
+ | `module.tsx` | `loadingModule`, page API, `defineLoading`, kernel type augmentation. |
153
+
154
+ ## 9. Dependencies
155
+
156
+ - **Uses:** `@omerdlw/base-framework/kernel` (`defineModule`, `useModuleRegistration`, `useRegistryValue`), `@omerdlw/base-framework/atoms` (`Spinner`, `useIsFullscreenStateActive`), `@omerdlw/base-framework/hooks`, `@omerdlw/base-framework/utils` (`createStore`), `@omerdlw/base-framework/theme`.
157
+ - **Used by:** `src/core/provider.tsx` (installs it); the **dock**, which reads `isLoading` and calls `stopLoading` through `definePeer("loading")` (inert when not installed); pages and features through `useLoading*` / `page.modules.loading`.