@antelopejs/dms 0.7.11 → 0.7.13

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 (79) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/implementations/dms/page.js +14 -1
  3. package/dist/implementations/dms/page.js.map +1 -1
  4. package/dist/test/unit/implementations/dms/module-address-page.test.d.ts +1 -0
  5. package/dist/test/unit/implementations/dms/module-address-page.test.js +107 -0
  6. package/dist/test/unit/implementations/dms/module-address-page.test.js.map +1 -0
  7. package/dist/test/unit/interfaces/dms/page-topics.test.js +30 -0
  8. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  9. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js +7 -0
  10. package/dist/test/unit/interfaces/dms-base/block-parity-options.test.js.map +1 -1
  11. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +10 -0
  12. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  13. package/frontend-vue/layers/dms-core/app/build/composables/page/usePageRecordLabel.ts +7 -29
  14. package/frontend-vue/layers/dms-core/app/composables/page/usePageRecordLabel.ts +53 -0
  15. package/frontend-vue/layers/dms-core/app/composables/user/useRegionalFormat.ts +73 -20
  16. package/frontend-vue/layers/dms-core/app/utils/formatter.ts +37 -1
  17. package/frontend-vue/layers/dms-core/app/utils/regional.ts +20 -0
  18. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +23 -2
  19. package/frontend-vue/layers/dms-layout/app/middleware/module-routing.global.ts +34 -39
  20. package/frontend-vue/layers/dms-layout/app/utils/breadcrumb.ts +6 -3
  21. package/frontend-vue/layers/dms-layout/app/utils/moduleLanding.ts +56 -0
  22. package/frontend-vue/layers/dms-ui/app/build/components/table-view/LiveHoldBanner.vue +69 -17
  23. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +1 -1
  24. package/frontend-vue/layers/dms-ui/app/build/composables/form/enclosingForm.ts +38 -0
  25. package/frontend-vue/layers/dms-ui/app/build/composables/global/searchShortcuts.ts +7 -16
  26. package/frontend-vue/layers/dms-ui/app/build/composables/live/liveNotes.ts +54 -0
  27. package/frontend-vue/layers/dms-ui/app/build/composables/page/blockAnchors.ts +50 -0
  28. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableLiveHold.ts +78 -35
  29. package/frontend-vue/layers/dms-ui/app/build/utils/numberAdjustment.ts +60 -0
  30. package/frontend-vue/layers/dms-ui/app/components/RecursiveComponent.vue +11 -0
  31. package/frontend-vue/layers/dms-ui/app/components/activity/ActivityItem.vue +17 -9
  32. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +58 -7
  33. package/frontend-vue/layers/dms-ui/app/components/blocks/CodeBlock.vue +5 -1
  34. package/frontend-vue/layers/dms-ui/app/components/blocks/EmptyStateBlock.vue +2 -1
  35. package/frontend-vue/layers/dms-ui/app/components/blocks/LiveToggleBlock.vue +7 -20
  36. package/frontend-vue/layers/dms-ui/app/components/chart/Chart.vue +3 -0
  37. package/frontend-vue/layers/dms-ui/app/components/chart/ChartCard.vue +7 -1
  38. package/frontend-vue/layers/dms-ui/app/components/code-snippet/CodeSnippet.vue +51 -13
  39. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +38 -1
  40. package/frontend-vue/layers/dms-ui/app/components/form/components/InputNumber.vue +58 -3
  41. package/frontend-vue/layers/dms-ui/app/components/tab/Tab.vue +19 -1
  42. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +9 -1
  43. package/frontend-vue/layers/dms-ui/app/components/top-list/TopListCard.vue +2 -0
  44. package/frontend-vue/layers/dms-ui/app/composables/chart/types.ts +3 -0
  45. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.types.ts +2 -0
  46. package/frontend-vue/layers/dms-ui/app/composables/form/types/validation.ts +10 -0
  47. package/frontend-vue/layers/dms-ui/app/composables/form/useEnclosingForm.ts +61 -0
  48. package/frontend-vue/layers/dms-ui/app/composables/global/searchShortcuts.ts +24 -0
  49. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLive.ts +3 -2
  50. package/frontend-vue/layers/dms-ui/app/composables/live/usePageLiveRefresh.ts +103 -0
  51. package/frontend-vue/layers/dms-ui/app/composables/tab/types/composable.ts +1 -0
  52. package/frontend-vue/layers/dms-ui/app/composables/tab/types/item.ts +8 -1
  53. package/frontend-vue/layers/dms-ui/app/composables/tab/useTab.ts +35 -23
  54. package/frontend-vue/layers/dms-ui/app/types/input-number.ts +18 -0
  55. package/frontend-vue/layers/dms-ui/app/types/live.ts +6 -0
  56. package/frontend-vue/layers/dms-ui/app/utils/scrollToBlock.ts +23 -0
  57. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +9 -2
  58. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +9 -2
  59. package/frontend-vue/tests/activity-feed-fold.test.ts +119 -0
  60. package/frontend-vue/tests/block-fetch-options-forwarding.test.ts +5 -0
  61. package/frontend-vue/tests/breadcrumb-target.test.ts +10 -0
  62. package/frontend-vue/tests/chart-legend.test.ts +133 -0
  63. package/frontend-vue/tests/code-block.test.ts +28 -0
  64. package/frontend-vue/tests/field-suffix-count.test.ts +1 -0
  65. package/frontend-vue/tests/form-enclosing-form.test.ts +141 -0
  66. package/frontend-vue/tests/header-actions.test.ts +1 -1
  67. package/frontend-vue/tests/input-number-adjust.test.ts +176 -0
  68. package/frontend-vue/tests/live-hold-banner.test.ts +66 -8
  69. package/frontend-vue/tests/module-routing.test.ts +158 -0
  70. package/frontend-vue/tests/page-live-refresh.test.ts +169 -0
  71. package/frontend-vue/tests/page-record-label.test.ts +53 -0
  72. package/frontend-vue/tests/regional-format.test.ts +69 -2
  73. package/frontend-vue/tests/scroll-to-block.test.ts +171 -0
  74. package/frontend-vue/tests/settings-search-shortcuts.test.ts +17 -0
  75. package/frontend-vue/tests/tab-url-slot.test.ts +150 -0
  76. package/frontend-vue/tests/table-live-hold.test.ts +90 -0
  77. package/package.json +2 -2
  78. package/skills/dms/SKILL.md +28 -4
  79. package/skills/dms-module/SKILL.md +2 -1
@@ -0,0 +1,150 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A tab set keeping its open tab in the URL writes the tab's slot
4
+ * (`?tab=tester`), which stays right when tabs are added or reordered, and
5
+ * still opens a tab named by its index (`?tab=2`). A tab's badge is a
6
+ * BlockText: an i18n key is written in the reader's language.
7
+ */
8
+ import * as vue from "vue";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { useTab } from "../layers/dms-ui/app/composables/tab/useTab";
11
+ import { TabEvents } from "../layers/dms-ui/app/composables/tab/types/events";
12
+ import type { TabProps } from "../layers/dms-ui/app/composables/tab/types";
13
+ import { AxeOrientation } from "../layers/dms-core/app/types/orientation";
14
+ import { Color } from "../layers/dms-core/app/types/color";
15
+ import { Size } from "../layers/dms-core/app/types/size";
16
+
17
+ vi.mock("../layers/dms-ui/app/composables/chart/useChartFetch", () => ({
18
+ useChartFetch: () => ({ data: vue.ref(null) }),
19
+ }));
20
+
21
+ const ITEMS = [
22
+ { label: "Statistics", slot: "statistics" },
23
+ { label: "Documentation", slot: "documentation" },
24
+ { label: "Tester", slot: "tester" },
25
+ ];
26
+
27
+ let route: { query: Record<string, string> };
28
+ const push = vi.fn(async (to: { query: Record<string, string> }) => {
29
+ route.query = to.query;
30
+ });
31
+
32
+ function tabsAt(query: Record<string, string>) {
33
+ route.query = query;
34
+ return useTab(ITEMS.length, {
35
+ items: ITEMS,
36
+ persistState: true,
37
+ } as TabProps);
38
+ }
39
+
40
+ beforeEach(() => {
41
+ route = vue.reactive({ query: {} });
42
+ push.mockClear();
43
+ for (const name of ["computed", "ref", "watch", "useTemplateRef"] as const)
44
+ vi.stubGlobal(name, vue[name]);
45
+ vi.stubGlobal("TabEvents", TabEvents);
46
+ vi.stubGlobal("isNumber", (value: unknown) => typeof value === "number");
47
+ vi.stubGlobal("isString", (value: unknown) => typeof value === "string");
48
+ vi.stubGlobal("useComponentEvent", () => ({ sendComponentEvent: vi.fn() }));
49
+ vi.stubGlobal("useWatch", () => ({
50
+ isLoading: vue.ref(false),
51
+ state: vue.ref({}),
52
+ }));
53
+ vi.stubGlobal("useDmsRoute", () => route);
54
+ vi.stubGlobal("useDmsRouter", () => ({ push }));
55
+ });
56
+
57
+ afterEach(() => vi.unstubAllGlobals());
58
+
59
+ describe("a tab in the URL", () => {
60
+ it("opens the tab its slot names", () => {
61
+ expect(tabsAt({ tab: "tester" }).activeTab.value).toBe("2");
62
+ });
63
+
64
+ it("still opens a tab named by its index", () => {
65
+ expect(tabsAt({ tab: "1" }).activeTab.value).toBe("1");
66
+ });
67
+
68
+ it("opens the first tab on a name it does not know", () => {
69
+ expect(tabsAt({ tab: "settings" }).activeTab.value).toBe("0");
70
+ expect(tabsAt({ tab: "7" }).activeTab.value).toBe("0");
71
+ });
72
+
73
+ it("writes the open tab's slot, and nothing for the first", async () => {
74
+ const { goToTab } = tabsAt({});
75
+ goToTab(2);
76
+ await vue.nextTick();
77
+ expect(push).toHaveBeenLastCalledWith({ query: { tab: "tester" } });
78
+ goToTab("statistics");
79
+ await vue.nextTick();
80
+ expect(push).toHaveBeenLastCalledWith({ query: {} });
81
+ });
82
+
83
+ it("follows a URL naming another slot", async () => {
84
+ const { activeTab } = tabsAt({});
85
+ route.query = { tab: "documentation" };
86
+ await vue.nextTick();
87
+ expect(activeTab.value).toBe("1");
88
+ });
89
+ });
90
+
91
+ describe("a tab's badge", () => {
92
+ interface TabsItem {
93
+ badge?: unknown;
94
+ }
95
+ let received: TabsItem[] = [];
96
+ let app: vue.App | undefined;
97
+
98
+ const Tabs = vue.defineComponent({
99
+ props: { items: { type: Array, default: () => [] } },
100
+ setup: (props) => () => {
101
+ received = props.items as TabsItem[];
102
+ return vue.h("div");
103
+ },
104
+ });
105
+
106
+ async function badgesOf(badges: unknown[]): Promise<unknown[]> {
107
+ vi.stubGlobal("AxeOrientation", AxeOrientation);
108
+ vi.stubGlobal("Color", Color);
109
+ vi.stubGlobal("Size", Size);
110
+ vi.stubGlobal("useI18n", () => ({
111
+ locale: vue.ref("en"),
112
+ t: (key: string) => `t(${key})`,
113
+ }));
114
+ vi.stubGlobal("useTranslation", () => ({ processI18n: String }));
115
+ vi.stubGlobal("useTab", () => ({ activeTab: vue.ref("0") }));
116
+ vi.stubGlobal("useTabShortcuts", () => {});
117
+ const { default: Tab } = await import(
118
+ "../layers/dms-ui/app/components/tab/Tab.vue"
119
+ );
120
+ app = vue.createApp({
121
+ render: () =>
122
+ vue.h(Tab, {
123
+ items: badges.map((badge, index) => ({ ...ITEMS[index], badge })),
124
+ }),
125
+ });
126
+ app.component("UTabs", Tabs);
127
+ app.mount(document.createElement("div"));
128
+ await vue.nextTick();
129
+ app.unmount();
130
+ return received.map((item) => item.badge);
131
+ }
132
+
133
+ it("writes an i18n key, a composed text and a badge label in the reader's language", async () => {
134
+ expect(
135
+ await badgesOf([
136
+ "$api.tabs.beta",
137
+ { key: "api.tabs.count" },
138
+ { label: "$api.tabs.new", color: "primary" },
139
+ ]),
140
+ ).toEqual([
141
+ "t(api.tabs.beta)",
142
+ "t(api.tabs.count)",
143
+ { label: "t(api.tabs.new)", color: "primary" },
144
+ ]);
145
+ });
146
+
147
+ it("keeps a count and a plain text as they are", async () => {
148
+ expect(await badgesOf([3, "Beta"])).toEqual([3, "Beta"]);
149
+ });
150
+ });
@@ -292,3 +292,93 @@ it("holds the table while its first row is scrolled out of view", async () => {
292
292
  expect(refresh).toHaveBeenCalledTimes(2);
293
293
  element.remove();
294
294
  });
295
+
296
+ it("holds the table alone while searching or sorted, saying why", async () => {
297
+ const isSearching = ref(false);
298
+ const isSorted = ref(false);
299
+ const isRowOpen = ref(false);
300
+ const { refresh, hold } = mountTable({
301
+ liveHold: { whileSearching: true, whileSorted: true, whileRowOpen: true },
302
+ signals: {
303
+ isSearching: () => isSearching.value,
304
+ isSorted: () => isSorted.value,
305
+ isRowOpen: () => isRowOpen.value,
306
+ },
307
+ });
308
+ expect(hold.reason.value).toBe(null);
309
+
310
+ isSorted.value = true;
311
+ await nextTick();
312
+ publish();
313
+ expect(refresh).not.toHaveBeenCalled();
314
+ expect(hold.reason.value).toBe("sorted");
315
+
316
+ isSearching.value = true;
317
+ await nextTick();
318
+ expect(hold.reason.value).toBe("searching");
319
+
320
+ isRowOpen.value = true;
321
+ await nextTick();
322
+ expect(hold.reason.value).toBe("rowOpen");
323
+
324
+ // Paused, the page's toggle says why: the table names no reason.
325
+ live.pause();
326
+ await nextTick();
327
+ expect(hold.isHolding.value).toBe(true);
328
+ expect(hold.reason.value).toBe(null);
329
+ });
330
+
331
+ it("holds for no search nor sort the options leave off", async () => {
332
+ const { refresh, hold } = mountTable({
333
+ liveHold: { whileRowOpen: true },
334
+ signals: { isSearching: () => true, isSorted: () => true },
335
+ });
336
+ publish();
337
+ expect(refresh).toHaveBeenCalledTimes(1);
338
+ expect(hold.reason.value).toBe(null);
339
+ });
340
+
341
+ it("tells the hold about the table's search and sort, and the banner why", () => {
342
+ expect(tableViewSource).toContain(
343
+ "isSearching: () => !!effectiveGlobalFilter.value,",
344
+ );
345
+ expect(tableViewSource).toContain(
346
+ "isSorted: () => !isSameSorting(effectiveSorting.value, defaultSortState),",
347
+ );
348
+ expect(tableViewSource).toMatch(
349
+ /v-if="liveHold\.newCount\.value > 0 \|\| liveHold\.reason\.value"/,
350
+ );
351
+ expect(tableViewSource).toContain(':reason="liveHold.reason.value"');
352
+ });
353
+
354
+ it("hands the strip the paused page's breakdown and the table's missedLabel", async () => {
355
+ const isRowOpen = ref(false);
356
+ const { hold } = mountTable({
357
+ liveHold: { whileRowOpen: true, missedLabel: "$api.live.new_requests" },
358
+ signals: { isRowOpen: () => isRowOpen.value },
359
+ });
360
+ expect(hold.missedLabel).toBe("$api.live.new_requests");
361
+ const detail = { text: "$api.live.in_5xx", params: { count: 1 } };
362
+
363
+ // Held by an open row, the page live: the page has no breakdown to give.
364
+ isRowOpen.value = true;
365
+ await nextTick();
366
+ publish();
367
+ live.setMissedDetail(detail);
368
+ expect(hold.missedDetail.value).toBe(null);
369
+
370
+ live.pause();
371
+ await nextTick();
372
+ publish();
373
+ live.setMissedDetail(detail);
374
+ expect(hold.missedDetail.value).toEqual(detail);
375
+
376
+ live.resume();
377
+ await nextTick();
378
+ expect(hold.missedDetail.value).toBe(null);
379
+ });
380
+
381
+ it("passes the strip its wording and breakdown", () => {
382
+ expect(tableViewSource).toContain(':missed-label="liveHold.missedLabel"');
383
+ expect(tableViewSource).toContain(':detail="liveHold.missedDetail.value"');
384
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.7.11",
3
+ "version": "0.7.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"
@@ -37,7 +37,7 @@
37
37
  "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
38
38
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
39
39
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
40
- "@antelopejs/interface-dms": ">=0.5.6 <0.6.0",
40
+ "@antelopejs/interface-dms": ">=0.5.8 <0.6.0",
41
41
  "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
42
42
  "@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
43
43
  "archiver": "^7.0.1",
@@ -42,12 +42,21 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
42
42
  (`slowRateAfter: { period: "7d", maxRate: 60 }` for long periods, `rememberKey: "<module>"` to share the
43
43
  choice between pages; `maxRate` is also the polling interval while the stream is down) and places a
44
44
  `LiveToggle()` (Live / Pause, L key; `variant: "pill"`, `missedLabel` for "N new requests").
45
- `liveHold: true` keeps a table's rows while paused; `liveHold: { whileRowOpen, whileScrolled }` also
46
- holds the table alone. Paused, an event counts as its `payload.ids` rows or its `payload.count`, so a
45
+ `liveHold: true` keeps a table's rows while paused; `liveHold: { whileRowOpen, whileScrolled,
46
+ whileSearching, whileSorted }` also holds the table alone, its strip saying why ("Table on hold ·
47
+ search active"); `liveHold.missedLabel` words its "N new", and paused, it adds the
48
+ `setMissedDetail()` breakdown ("12 new requests · 2 in 5xx"). Paused, an event counts as its `payload.ids` rows or its `payload.count`, so a
47
49
  module may batch publishes with `{ count }`. A hidden tab and `?live=0` pause the page too.
48
50
  A custom component follows that state with `usePageLive()`, holds the page with `hold(reason)` /
49
- `release()` and adds a breakdown with `setMissedDetail()`; `refreshPageBlocks()` (user actions) always
51
+ `release()` and adds a breakdown with `setMissedDetail()`; a custom component that reads its own data
52
+ on a topic calls `usePageLiveRefresh(refresh, { realtimeTopic, idleRefresh })`, gated exactly like a
53
+ built-in block (never a throttle of its own); `refreshPageBlocks()` (user actions) always
50
54
  reads, `refreshLivePageBlocks()` (timers, feeds) is held while paused.
55
+ A custom component on a record page names its record at the end of the breadcrumb with
56
+ `usePageRecordLabel().setLabel(useDmsRoute().path, label)` (auto-imported; never the `build/` path).
57
+ A custom component formats through `useRegionalFormat()` (`formatNumber` & co. return `string` for a
58
+ number); `useRegionalFormat({ locale: "format" })` writes in `formatLocale`, the locale of the DMS's
59
+ texts (`en` → `en-GB`) — never a hand-rolled `en` → `en-GB` mapping.
51
60
  A status the backend computes (health, outage) is a `Banner({ fetchUrl })`: the route answers
52
61
  a `BannerContent` (its buttons may run `api` / modal / quick-action targets with `confirm`)
53
62
  or `null` for no banner — the block then draws nothing.
@@ -66,6 +75,8 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
66
75
  duration, an error message), and `DurationDisplay({ format: "ms" })` writes "2,149 ms" on every row;
67
76
  and a `PillsDisplay` item may be `{ label, tone?, variant: "soft" | "outline" }`, styled per row.
68
77
  A `StatGroup` detail and a `NavCardGrid` card title take up to two lines before their ellipsis.
78
+ An `ActivityFeed` whose entries must be read whole sets `wrap: true` (no ellipsis; leave `fillHeight`
79
+ off so the card grows) and `visibleItems: N` for a "Show N more" fold.
69
80
  A unit after a number field is `NumberType({ suffix: "$…days" })`, a character counter on a
70
81
  text field `StringType({ maxLength, showCount: true })` — no custom `inputComponent` for either.
71
82
  A `TableView` over a shared controller hides a column on one page with
@@ -73,8 +84,21 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
73
84
  just to hide it.
74
85
  A long sectioned `Form` groups its navigation with `FormSection.navGroup` ("Billing", "Legal"),
75
86
  in the side list and in the `sectionNav: "jump"` chips alike (a form whose side column holds a preview).
87
+ A module's own `inputComponent` submits its form, adds a validator and focuses a field with
88
+ `useEnclosingForm()` (never `closest("form")` / `requestSubmit()`), and a link ending in `#<fieldId>`
89
+ opens the form on that field, focused. `DmsInputNumber` emits `adjust` (`{ typed, value, reason:
90
+ "min" | "max" | "step" }`) when it keeps another value than the one typed, and redraws the kept
91
+ value itself: a note "1020 → 1,000" listens to it — never a forced remount (`:key`).
76
92
  `TopListCard({ actions })` puts link buttons in its head ("Catalogue →"), and `Tab({ fit: true })`
77
- keeps a pill tab list to its tabs' width.
93
+ keeps a pill tab list to its tabs' width; with `persistState` the URL names the open tab by its slot
94
+ (`?tab=tester`, an index still read), and a tab `badge` takes a `$` key or a `ComposedText`. A `ChartCard` plotting several series names them with
95
+ `showSeriesLegend: true` (placed by the chart's `legendPosition: "top" | "bottom"`) — never a raw
96
+ Apex `legend` in `rawOptions`.
97
+ A custom component brings a block of its page into view with `scrollToBlock("tabs.statistics")`
98
+ (the block's key path; `false` when it is not on screen) — never a DOM query of its own.
99
+ Code in a custom component is a `DmsCodeSnippet` (`language`, `title`, `maxLines` or a CSS
100
+ `maxHeight`, `wrap: true | "anywhere"`; without title and language its copy button floats in the
101
+ corner) — never a `<pre>` and copy button of the module's own.
78
102
  A `KeyValueList` row takes `copy: true` (a copy button for its shown value) or `copyValue`
79
103
  (the text to copy instead, e.g. a full URL behind a short label).
80
104
  A table ordered by hand (`reorder: { field }`) moves its rows on the grid and its cards on
@@ -93,7 +93,8 @@ Three constraints are enforced at registration: a page's `category` must **desce
93
93
  module root** (nest under what `RegisterModule` returned, or rely on `defaultCategory`),
94
94
  module pages cannot use the Settings category, and the module must exist before a page names
95
95
  it. Pages resolve at `/modules/<moduleId>/<urlSlug>`; entering the module lands on
96
- `landingPage` if set, else the first page by `order`. Access is **owner-only by design**: the
96
+ `landingPage` if set, else the first page by `order` — `/modules/<id>` itself redirects there (server
97
+ redirect, query and hash kept), so never register a hidden page to forward it. Access is **owner-only by design**: the
97
98
  Modules group is visible to the platform owner alone (the `*` holder), and the
98
99
  `modules.<moduleId>.…` permission ids are module-scoped — never grantable through Roles. On a
99
100
  SaaS deployment that means you, the maker, not your customers; a screen for end users belongs