@pilotiq/pilotiq 0.6.1 → 0.7.0

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 (212) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/CHANGELOG.md +614 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Column.d.ts +35 -0
  5. package/dist/Column.d.ts.map +1 -1
  6. package/dist/Column.js +41 -0
  7. package/dist/Column.js.map +1 -1
  8. package/dist/Page.d.ts +13 -4
  9. package/dist/Page.d.ts.map +1 -1
  10. package/dist/Page.js +9 -2
  11. package/dist/Page.js.map +1 -1
  12. package/dist/Pilotiq.d.ts +84 -0
  13. package/dist/Pilotiq.d.ts.map +1 -1
  14. package/dist/Pilotiq.js +66 -0
  15. package/dist/Pilotiq.js.map +1 -1
  16. package/dist/Resource.d.ts +26 -0
  17. package/dist/Resource.d.ts.map +1 -1
  18. package/dist/Resource.js +9 -0
  19. package/dist/Resource.js.map +1 -1
  20. package/dist/actions/exportFactory.js +1 -1
  21. package/dist/actions/exportFactory.js.map +1 -1
  22. package/dist/columns/SelectColumn.d.ts +32 -5
  23. package/dist/columns/SelectColumn.d.ts.map +1 -1
  24. package/dist/columns/SelectColumn.js +37 -7
  25. package/dist/columns/SelectColumn.js.map +1 -1
  26. package/dist/defaultPages.d.ts.map +1 -1
  27. package/dist/defaultPages.js +3 -0
  28. package/dist/defaultPages.js.map +1 -1
  29. package/dist/elements/Form.d.ts +17 -0
  30. package/dist/elements/Form.d.ts.map +1 -1
  31. package/dist/elements/Form.js +17 -0
  32. package/dist/elements/Form.js.map +1 -1
  33. package/dist/elements/Table.d.ts +26 -0
  34. package/dist/elements/Table.d.ts.map +1 -1
  35. package/dist/elements/Table.js +15 -1
  36. package/dist/elements/Table.js.map +1 -1
  37. package/dist/elements/TableGroup.d.ts +84 -0
  38. package/dist/elements/TableGroup.d.ts.map +1 -1
  39. package/dist/elements/TableGroup.js +103 -0
  40. package/dist/elements/TableGroup.js.map +1 -1
  41. package/dist/elements/dispatchForm.d.ts.map +1 -1
  42. package/dist/elements/dispatchForm.js +36 -6
  43. package/dist/elements/dispatchForm.js.map +1 -1
  44. package/dist/elements/dispatchTable.d.ts +12 -0
  45. package/dist/elements/dispatchTable.d.ts.map +1 -1
  46. package/dist/elements/dispatchTable.js +104 -29
  47. package/dist/elements/dispatchTable.js.map +1 -1
  48. package/dist/fields/Field.d.ts +7 -2
  49. package/dist/fields/Field.d.ts.map +1 -1
  50. package/dist/fields/Field.js +8 -3
  51. package/dist/fields/Field.js.map +1 -1
  52. package/dist/fields/RepeaterField.d.ts +65 -0
  53. package/dist/fields/RepeaterField.d.ts.map +1 -1
  54. package/dist/fields/RepeaterField.js +48 -0
  55. package/dist/fields/RepeaterField.js.map +1 -1
  56. package/dist/orm/modelDefaults.d.ts.map +1 -1
  57. package/dist/orm/modelDefaults.js +19 -0
  58. package/dist/orm/modelDefaults.js.map +1 -1
  59. package/dist/pageData.d.ts +20 -0
  60. package/dist/pageData.d.ts.map +1 -1
  61. package/dist/pageData.js +242 -34
  62. package/dist/pageData.js.map +1 -1
  63. package/dist/react/AppShell.d.ts +17 -1
  64. package/dist/react/AppShell.d.ts.map +1 -1
  65. package/dist/react/AppShell.js +34 -3
  66. package/dist/react/AppShell.js.map +1 -1
  67. package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
  68. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
  69. package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
  70. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
  71. package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
  72. package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
  73. package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
  74. package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
  75. package/dist/react/PendingSuggestionsContext.d.ts +153 -0
  76. package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
  77. package/dist/react/PendingSuggestionsContext.js +46 -0
  78. package/dist/react/PendingSuggestionsContext.js.map +1 -0
  79. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  80. package/dist/react/SchemaRenderer.js +312 -39
  81. package/dist/react/SchemaRenderer.js.map +1 -1
  82. package/dist/react/cells/EditableCell.d.ts +8 -0
  83. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  84. package/dist/react/cells/EditableCell.js +6 -2
  85. package/dist/react/cells/EditableCell.js.map +1 -1
  86. package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
  87. package/dist/react/fields/CheckboxListInput.js +29 -2
  88. package/dist/react/fields/CheckboxListInput.js.map +1 -1
  89. package/dist/react/fields/ColorInput.d.ts.map +1 -1
  90. package/dist/react/fields/ColorInput.js +28 -2
  91. package/dist/react/fields/ColorInput.js.map +1 -1
  92. package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
  93. package/dist/react/fields/DateTimeInput.js +28 -2
  94. package/dist/react/fields/DateTimeInput.js.map +1 -1
  95. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  96. package/dist/react/fields/FieldShell.js +161 -3
  97. package/dist/react/fields/FieldShell.js.map +1 -1
  98. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  99. package/dist/react/fields/FileUploadInput.js +27 -2
  100. package/dist/react/fields/FileUploadInput.js.map +1 -1
  101. package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
  102. package/dist/react/fields/KeyValueInput.js +33 -2
  103. package/dist/react/fields/KeyValueInput.js.map +1 -1
  104. package/dist/react/fields/RadioInput.d.ts.map +1 -1
  105. package/dist/react/fields/RadioInput.js +28 -2
  106. package/dist/react/fields/RadioInput.js.map +1 -1
  107. package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
  108. package/dist/react/fields/SelectFieldInput.js +31 -2
  109. package/dist/react/fields/SelectFieldInput.js.map +1 -1
  110. package/dist/react/fields/SliderInput.d.ts.map +1 -1
  111. package/dist/react/fields/SliderInput.js +26 -2
  112. package/dist/react/fields/SliderInput.js.map +1 -1
  113. package/dist/react/fields/TagsInput.d.ts.map +1 -1
  114. package/dist/react/fields/TagsInput.js +26 -2
  115. package/dist/react/fields/TagsInput.js.map +1 -1
  116. package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
  117. package/dist/react/fields/ToggleFieldInput.js +29 -2
  118. package/dist/react/fields/ToggleFieldInput.js.map +1 -1
  119. package/dist/react/index.d.ts +3 -0
  120. package/dist/react/index.d.ts.map +1 -1
  121. package/dist/react/index.js +3 -0
  122. package/dist/react/index.js.map +1 -1
  123. package/dist/routes.d.ts.map +1 -1
  124. package/dist/routes.js +55 -2
  125. package/dist/routes.js.map +1 -1
  126. package/dist/schema/Html.d.ts +2 -2
  127. package/dist/schema/Html.d.ts.map +1 -1
  128. package/dist/schema/Html.js +2 -2
  129. package/dist/schema/Html.js.map +1 -1
  130. package/dist/schema/Markdown.d.ts +2 -2
  131. package/dist/schema/Markdown.d.ts.map +1 -1
  132. package/dist/schema/Markdown.js +2 -2
  133. package/dist/schema/Markdown.js.map +1 -1
  134. package/dist/schema/Section.d.ts +16 -0
  135. package/dist/schema/Section.d.ts.map +1 -1
  136. package/dist/schema/Section.js +16 -0
  137. package/dist/schema/Section.js.map +1 -1
  138. package/dist/schema/Wizard.d.ts +45 -0
  139. package/dist/schema/Wizard.d.ts.map +1 -1
  140. package/dist/schema/Wizard.js +50 -0
  141. package/dist/schema/Wizard.js.map +1 -1
  142. package/dist/schema/resolveSchema.d.ts +8 -0
  143. package/dist/schema/resolveSchema.d.ts.map +1 -1
  144. package/dist/schema/resolveSchema.js +70 -1
  145. package/dist/schema/resolveSchema.js.map +1 -1
  146. package/dist/schema/sanitize.d.ts +3 -3
  147. package/dist/schema/sanitize.d.ts.map +1 -1
  148. package/dist/schema/sanitize.js +10 -3
  149. package/dist/schema/sanitize.js.map +1 -1
  150. package/dist/sessionFilters.d.ts.map +1 -1
  151. package/dist/sessionFilters.js +12 -1
  152. package/dist/sessionFilters.js.map +1 -1
  153. package/dist/styles/file-upload.css +13 -0
  154. package/dist/vite.d.ts.map +1 -1
  155. package/dist/vite.js +9 -2
  156. package/dist/vite.js.map +1 -1
  157. package/package.json +6 -4
  158. package/src/Column.test.ts +36 -0
  159. package/src/Column.ts +54 -0
  160. package/src/Page.ts +13 -4
  161. package/src/Pilotiq.ts +109 -0
  162. package/src/Resource.ts +29 -0
  163. package/src/actions/exportFactory.ts +1 -1
  164. package/src/columns/SelectColumn.ts +46 -8
  165. package/src/columns/editableColumns.test.ts +45 -0
  166. package/src/defaultPages.ts +3 -0
  167. package/src/elements/Form.ts +19 -0
  168. package/src/elements/Table.ts +35 -1
  169. package/src/elements/TableGroup.test.ts +111 -0
  170. package/src/elements/TableGroup.ts +135 -0
  171. package/src/elements/dispatchForm.ts +34 -7
  172. package/src/elements/dispatchTable.test.ts +267 -0
  173. package/src/elements/dispatchTable.ts +112 -33
  174. package/src/fields/Field.test.ts +15 -0
  175. package/src/fields/Field.ts +8 -3
  176. package/src/fields/RepeaterField.ts +104 -0
  177. package/src/fields/RepeaterRelationship.test.ts +173 -0
  178. package/src/nestedRelationManagerData.test.ts +21 -0
  179. package/src/orm/modelDefaults.ts +21 -0
  180. package/src/pageData.ts +267 -47
  181. package/src/react/AppShell.tsx +55 -4
  182. package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
  183. package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
  184. package/src/react/PendingSuggestionsContext.tsx +172 -0
  185. package/src/react/SchemaRenderer.tsx +504 -95
  186. package/src/react/cells/EditableCell.tsx +11 -2
  187. package/src/react/fields/CheckboxListInput.tsx +23 -2
  188. package/src/react/fields/ColorInput.tsx +22 -2
  189. package/src/react/fields/DateTimeInput.tsx +22 -2
  190. package/src/react/fields/FieldShell.tsx +167 -3
  191. package/src/react/fields/FileUploadInput.tsx +21 -2
  192. package/src/react/fields/KeyValueInput.tsx +32 -2
  193. package/src/react/fields/RadioInput.tsx +23 -2
  194. package/src/react/fields/SelectFieldInput.tsx +25 -2
  195. package/src/react/fields/SliderInput.tsx +20 -2
  196. package/src/react/fields/TagsInput.tsx +20 -2
  197. package/src/react/fields/ToggleFieldInput.tsx +23 -2
  198. package/src/react/index.ts +18 -0
  199. package/src/relationManagerData.test.ts +451 -2
  200. package/src/routes.ts +58 -2
  201. package/src/schema/Html.ts +2 -2
  202. package/src/schema/Markdown.ts +2 -2
  203. package/src/schema/Section.ts +17 -0
  204. package/src/schema/Wizard.ts +67 -0
  205. package/src/schema/containers.test.ts +90 -0
  206. package/src/schema/resolveSchema.test.ts +50 -0
  207. package/src/schema/resolveSchema.ts +79 -1
  208. package/src/schema/sanitize.ts +13 -4
  209. package/src/sessionFilters.test.ts +23 -0
  210. package/src/sessionFilters.ts +11 -1
  211. package/src/styles/file-upload.css +13 -0
  212. package/src/vite.ts +9 -2
package/dist/pageData.js CHANGED
@@ -40,6 +40,12 @@ export async function panelInfo(pilotiq, req, route = {}) {
40
40
  buildRightSidebarMeta(cfg, user),
41
41
  ]);
42
42
  const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user);
43
+ // AI suggestion mode — sparse: omit when 'auto' (the default) so the
44
+ // wire shape stays minimal for panels that don't opt into review mode.
45
+ // Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
46
+ // this to decide whether to apply writes immediately or stage them as
47
+ // PendingSuggestions for user approval.
48
+ const aiSuggestionsMode = pilotiq.getAiSuggestionsMode();
43
49
  return {
44
50
  name: cfg.name,
45
51
  branding: cfg.branding,
@@ -50,6 +56,7 @@ export async function panelInfo(pilotiq, req, route = {}) {
50
56
  ...(databaseNotifications ? { databaseNotifications } : {}),
51
57
  ...(rightSidebar ? { rightSidebar } : {}),
52
58
  ...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
59
+ ...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
53
60
  };
54
61
  }
55
62
  /**
@@ -1570,7 +1577,7 @@ export async function resourceEditData(pilotiq, slug, recordId, prefill, req) {
1570
1577
  // navigation strip so users can drill into each manager's table
1571
1578
  // without leaving the parent record context. The "Edit" tab is
1572
1579
  // active here.
1573
- const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__edit');
1580
+ const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__edit', user, record);
1574
1581
  if (relationTabsEl)
1575
1582
  elements.unshift(relationTabsEl);
1576
1583
  const recordTitle = record !== undefined && record !== null
@@ -1832,7 +1839,7 @@ async function buildRelationListData(pilotiq, R, M, Related, parentRecord, scope
1832
1839
  const elements = [table];
1833
1840
  tagActionDispatch(elements, listUrl);
1834
1841
  await loadTableRecords(elements, scope.query ?? {}, listUrl, user);
1835
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
1842
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
1836
1843
  if (tabs)
1837
1844
  elements.unshift(tabs);
1838
1845
  const breadcrumbs = relationListBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord));
@@ -1889,7 +1896,7 @@ async function buildRelationCreateData(pilotiq, R, M, Related, parentRecord, sco
1889
1896
  if (scope.prefill.errors)
1890
1897
  form.withErrors(scope.prefill.errors);
1891
1898
  }
1892
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
1899
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
1893
1900
  if (tabs)
1894
1901
  elements.unshift(tabs);
1895
1902
  const breadcrumbs = relationCreateBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord));
@@ -1956,10 +1963,10 @@ async function buildRelationViewData(pilotiq, R, M, Related, parentRecord, scope
1956
1963
  // view straight into a grandchild list / create / view / edit page.
1957
1964
  // Active key `'__view'` because the user is currently viewing the
1958
1965
  // leaf parent record itself, not any nested manager.
1959
- const nestedTabs = buildNestedRelationTabs(R, M, base, { recordId: scope.recordId, relationship: scope.relationship }, scope.childId, '__view');
1966
+ const nestedTabs = await buildNestedRelationTabs(R, M, base, { recordId: scope.recordId, relationship: scope.relationship }, scope.childId, '__view', user, child);
1960
1967
  if (nestedTabs)
1961
1968
  elements.unshift(nestedTabs);
1962
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
1969
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
1963
1970
  if (tabs)
1964
1971
  elements.unshift(tabs);
1965
1972
  const breadcrumbs = relationViewBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord), deriveParentTitle(Related, child, M));
@@ -2041,7 +2048,7 @@ async function buildRelationEditData(pilotiq, R, M, Related, parentRecord, scope
2041
2048
  const values = await applyFillPipeline(form, child);
2042
2049
  form.withValues(values);
2043
2050
  }
2044
- const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
2051
+ const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
2045
2052
  if (tabs)
2046
2053
  elements.unshift(tabs);
2047
2054
  const breadcrumbs = relationEditBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord), scope.childId, deriveParentTitle(Related, child, M));
@@ -2272,7 +2279,7 @@ async function buildNestedRelationListData(pilotiq, scope, resolved, req, user)
2272
2279
  const elements = [table];
2273
2280
  tagActionDispatch(elements, listUrl);
2274
2281
  await loadTableRecords(elements, scope.query ?? {}, listUrl, user);
2275
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
2282
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
2276
2283
  if (tabs)
2277
2284
  elements.unshift(tabs);
2278
2285
  const breadcrumbs = nestedRelationListBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(Related1, child1, resolved.M1));
@@ -2307,7 +2314,7 @@ async function buildNestedRelationCreateData(pilotiq, scope, resolved, req, user
2307
2314
  if (scope.prefill.errors)
2308
2315
  form.withErrors(scope.prefill.errors);
2309
2316
  }
2310
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
2317
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
2311
2318
  if (tabs)
2312
2319
  elements.unshift(tabs);
2313
2320
  const breadcrumbs = nestedRelationCreateBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(resolved.Related1, child1, resolved.M1));
@@ -2347,7 +2354,7 @@ async function buildNestedRelationViewData(pilotiq, scope, resolved, req, user)
2347
2354
  const cfg = pilotiq.getConfig();
2348
2355
  const base = cfg.path;
2349
2356
  const elements = M2.detail(child2, child1);
2350
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
2357
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
2351
2358
  if (tabs)
2352
2359
  elements.unshift(tabs);
2353
2360
  const breadcrumbs = nestedRelationViewBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(Related1, child1, resolved.M1), deriveParentTitle(Related2, child2, M2));
@@ -2403,7 +2410,7 @@ async function buildNestedRelationEditData(pilotiq, scope, resolved, req, user)
2403
2410
  const values = await applyFillPipeline(form, child2);
2404
2411
  form.withValues(values);
2405
2412
  }
2406
- const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
2413
+ const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
2407
2414
  if (tabs)
2408
2415
  elements.unshift(tabs);
2409
2416
  const breadcrumbs = nestedRelationEditBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(Related1, child1, resolved.M1), scope.childId, deriveParentTitle(Related2, child2, M2));
@@ -2436,13 +2443,27 @@ async function buildNestedRelationEditData(pilotiq, scope, resolved, req, user)
2436
2443
  * absent that, callers skip the prepend so single-manager surfaces stay
2437
2444
  * clean. `activeKey` accepts the literal `'__view'` for the leaf
2438
2445
  * parent's view tab, or any sibling manager's relationship key.
2446
+ *
2447
+ * Per-tab `canX` gating (2026-05-11) — sibling nested-manager tabs run
2448
+ * `N.canViewAny(user, child1Record)` (with fall-through to the related
2449
+ * Resource via `safeManagerPolicy`) so the strip hides tabs the user
2450
+ * couldn't reach anyway. The back-link `__view` stays unconditional
2451
+ * since the user is already on a page scoped under `M.canViewAny` —
2452
+ * they reached this strip, they can navigate back to it.
2439
2453
  */
2440
- function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
2454
+ async function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey, user, child1Record) {
2441
2455
  const siblings = M.relations();
2442
2456
  if (siblings.length === 0)
2443
2457
  return undefined;
2444
2458
  const resourceBase = resourceBasePath(basePath, R);
2445
2459
  const parentBase = `${resourceBase}/${step0.recordId}/${step0.relationship}`;
2460
+ // Sibling gating runs in parallel — each predicate may hit auth /
2461
+ // db, so don't serialize them.
2462
+ const siblingGates = siblings.map(N => {
2463
+ const Related = N.relatedResource;
2464
+ return safeManagerPolicyImpl(N, 'canViewAny', Related, user, child1Record);
2465
+ });
2466
+ const siblingVisible = await Promise.all(siblingGates);
2446
2467
  const tabs = [];
2447
2468
  // Back-link: depth-2 view page for the leaf parent record. Acts as
2448
2469
  // the "View" tab in the same way `__view` does on depth-1 strips.
@@ -2454,13 +2475,15 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
2454
2475
  icon: M.getIcon(),
2455
2476
  iconOwner: M.name,
2456
2477
  }));
2457
- for (const N of siblings) {
2478
+ siblings.forEach((N, i) => {
2479
+ if (!siblingVisible[i])
2480
+ return;
2458
2481
  let nestedRel = '';
2459
2482
  try {
2460
2483
  nestedRel = N.getRelationship();
2461
2484
  }
2462
2485
  catch {
2463
- continue;
2486
+ return;
2464
2487
  }
2465
2488
  const icon = N.getIcon();
2466
2489
  tabs.push(relationTab({
@@ -2470,7 +2493,12 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
2470
2493
  active: activeKey === nestedRel,
2471
2494
  ...(icon !== undefined ? { icon, iconOwner: N.name } : {}),
2472
2495
  }));
2473
- }
2496
+ });
2497
+ // After gating, only the back-link may remain — one tab isn't a
2498
+ // useful sub-nav. Drop the strip in that case (consistent with
2499
+ // depth-1's empty-tabs branch).
2500
+ if (tabs.length <= 1)
2501
+ return undefined;
2474
2502
  return RelationTabs.make(tabs);
2475
2503
  }
2476
2504
  /**
@@ -2481,11 +2509,25 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
2481
2509
  * the manager's relationship key for a manager tab.
2482
2510
  *
2483
2511
  * Sub-nav follow-up (2026-05-03 cont'd) — emit BOTH `__view` and
2484
- * `__edit` as sibling tabs (Filament-style record sub-navigation)
2485
- * instead of one parent tab whose label depends on mode. Tabs are
2486
- * dropped when the corresponding page role isn't registered (a
2487
- * Resource overriding `pages()` to omit `view` or `edit` shouldn't
2488
- * surface a tab that 404s).
2512
+ * `__edit` as sibling tabs (record sub-navigation) instead of one
2513
+ * parent tab whose label depends on mode. Tabs are dropped when the
2514
+ * corresponding page role isn't registered (a Resource overriding
2515
+ * `pages()` to omit `view` or `edit` shouldn't surface a tab that
2516
+ * 404s).
2517
+ *
2518
+ * Per-tab `canX` gating (2026-05-11) — the strip now also evaluates
2519
+ * the matching predicate for each tab and drops the entry when the
2520
+ * user can't reach it. Routes still enforce; this is presentation
2521
+ * polish so the chrome doesn't promise a link that 403s on click.
2522
+ *
2523
+ * - `__view` → `R.canView(user, parentRecord)` (skip gating when
2524
+ * `parentRecord` is undefined — record load failed,
2525
+ * so the route's own load+gate will surface a 404/403
2526
+ * rather than the strip hiding silently).
2527
+ * - `__edit` → `R.canEdit(user, parentRecord)` (same posture).
2528
+ * - manager → `safeManagerPolicy(M, 'canViewAny', Related, user,
2529
+ * parentRecord)` (falls through to Related's
2530
+ * `canViewAny` when the manager hasn't overridden).
2489
2531
  *
2490
2532
  * Returns `undefined` when the resource has no relation managers — the
2491
2533
  * caller can then skip the prepend entirely so resources without
@@ -2493,17 +2535,55 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
2493
2535
  * (View+Edit sub-nav alone isn't worth a tab strip; users navigate
2494
2536
  * those via headerActions or the back link.)
2495
2537
  */
2496
- function buildRelationTabs(R, recordId, basePath, activeKey) {
2538
+ async function buildRelationTabs(R, recordId, basePath, activeKey, user, parentRecord) {
2497
2539
  const managers = R.relations();
2498
- if (managers.length === 0)
2540
+ const recordPageMap = R.getRecordPages();
2541
+ const recordPageSlugs = Object.keys(recordPageMap);
2542
+ // No managers AND no record sub-pages → no strip. View+Edit alone
2543
+ // isn't worth a tab strip; users navigate those via headerActions or
2544
+ // the back link. (When either is non-empty, the strip is worth
2545
+ // mounting even if all the dynamic tabs end up gated away — the
2546
+ // post-gate emptiness check below catches that.)
2547
+ if (managers.length === 0 && recordPageSlugs.length === 0)
2499
2548
  return undefined;
2500
2549
  const resourceBase = resourceBasePath(basePath, R);
2501
2550
  const pages = R.resolvePages();
2551
+ // Evaluate every per-tab predicate in parallel. The arrays line up
2552
+ // 1:1 with `pages.view` / `pages.edit` / `recordPageSlugs` /
2553
+ // `managers` below — we resolve all gates first so the tab-build
2554
+ // loop stays straight-line.
2555
+ // Record-aware predicates short-circuit to `true` when no parent
2556
+ // record was loaded (presentation should never hide more aggressively
2557
+ // than the route can enforce; a missing record means the route will
2558
+ // 404/403 on click and the strip stays consistent with that).
2559
+ const canViewPromise = pages.view && parentRecord !== undefined && parentRecord !== null
2560
+ ? safeBool(() => R.canView(user, parentRecord))
2561
+ : Promise.resolve(true);
2562
+ const canEditPromise = pages.edit && parentRecord !== undefined && parentRecord !== null
2563
+ ? safeBool(() => R.canEdit(user, parentRecord))
2564
+ : Promise.resolve(true);
2565
+ const recordPageGates = recordPageSlugs.map(subSlug => {
2566
+ // Record sub-page gates run against the parent record verbatim —
2567
+ // missing record still calls the predicate so a sub-page that
2568
+ // gates on global user state (no record needed) still evaluates.
2569
+ // safeBool fails closed for throwing predicates.
2570
+ return safeBool(() => recordPageMap[subSlug].canAccess(user, parentRecord));
2571
+ });
2572
+ const managerGates = managers.map(M => {
2573
+ const Related = M.relatedResource;
2574
+ return safeManagerPolicyImpl(M, 'canViewAny', Related, user, parentRecord);
2575
+ });
2576
+ const gateResults = await Promise.all([
2577
+ canViewPromise, canEditPromise,
2578
+ ...recordPageGates,
2579
+ ...managerGates,
2580
+ ]);
2581
+ const canView = gateResults[0];
2582
+ const canEdit = gateResults[1];
2583
+ const recordPageVisible = gateResults.slice(2, 2 + recordPageSlugs.length);
2584
+ const managerVisible = gateResults.slice(2 + recordPageSlugs.length);
2502
2585
  const tabs = [];
2503
- // View tab only when the resource has a ViewPage registered.
2504
- // Defaults always include one; users who pruned ViewPage in their
2505
- // `static pages()` override get no broken link.
2506
- if (pages.view) {
2586
+ if (pages.view && canView) {
2507
2587
  tabs.push(relationTab({
2508
2588
  key: '__view',
2509
2589
  label: 'View',
@@ -2513,8 +2593,7 @@ function buildRelationTabs(R, recordId, basePath, activeKey) {
2513
2593
  iconOwner: R.name,
2514
2594
  }));
2515
2595
  }
2516
- // Edit tab same defensive check.
2517
- if (pages.edit) {
2596
+ if (pages.edit && canEdit) {
2518
2597
  tabs.push(relationTab({
2519
2598
  key: '__edit',
2520
2599
  label: 'Edit',
@@ -2527,13 +2606,34 @@ function buildRelationTabs(R, recordId, basePath, activeKey) {
2527
2606
  iconOwner: R.name,
2528
2607
  }));
2529
2608
  }
2530
- for (const M of managers) {
2609
+ // Record sub-page tabs — between Edit and the managers, in declaration
2610
+ // order. Tab label inherits from the sub-page's class (`getLabel()`);
2611
+ // icon picks up the sub-page's static `icon` when set. Slug doubles as
2612
+ // the URL segment AND the `activeKey` discriminator the data builder
2613
+ // passes when rendering the sub-page.
2614
+ recordPageSlugs.forEach((subSlug, i) => {
2615
+ if (!recordPageVisible[i])
2616
+ return;
2617
+ const SubPage = recordPageMap[subSlug];
2618
+ tabs.push(relationTab({
2619
+ key: subSlug,
2620
+ label: SubPage.getLabel(),
2621
+ url: `${resourceBase}/${recordId}/${subSlug}`,
2622
+ active: activeKey === subSlug,
2623
+ ...(SubPage.icon !== undefined
2624
+ ? { icon: SubPage.icon, iconOwner: SubPage.name }
2625
+ : {}),
2626
+ }));
2627
+ });
2628
+ managers.forEach((M, i) => {
2629
+ if (!managerVisible[i])
2630
+ return;
2531
2631
  let rel = '';
2532
2632
  try {
2533
2633
  rel = M.getRelationship();
2534
2634
  }
2535
2635
  catch {
2536
- continue;
2636
+ return;
2537
2637
  }
2538
2638
  const icon = M.getIcon();
2539
2639
  tabs.push(relationTab({
@@ -2543,9 +2643,27 @@ function buildRelationTabs(R, recordId, basePath, activeKey) {
2543
2643
  active: activeKey === rel,
2544
2644
  ...(icon !== undefined ? { icon, iconOwner: M.name } : {}),
2545
2645
  }));
2546
- }
2646
+ });
2647
+ // After gating, the strip may collapse to zero entries. Mirror the
2648
+ // "no managers + no sub-pages" branch above — no strip is friendlier
2649
+ // than a one-tab strip with just the active page.
2650
+ if (tabs.length === 0)
2651
+ return undefined;
2547
2652
  return RelationTabs.make(tabs);
2548
2653
  }
2654
+ /**
2655
+ * Tiny shim over `try { Boolean(await fn()) } catch { false }` so the
2656
+ * relation-tabs builder stays straight-line — mirrors `checkPolicy`
2657
+ * in `routes.ts` but kept local to avoid cross-module imports.
2658
+ */
2659
+ async function safeBool(fn) {
2660
+ try {
2661
+ return Boolean(await fn());
2662
+ }
2663
+ catch {
2664
+ return false;
2665
+ }
2666
+ }
2549
2667
  /** Pull a human-readable title off a parent record for breadcrumb /
2550
2668
  * page-title use. Falls back through `recordTitleAttribute` →
2551
2669
  * `name` → `title` → primary key value → 'Record'. */
@@ -3330,7 +3448,7 @@ export async function resourceViewData(pilotiq, slug, recordId, req) {
3330
3448
  }
3331
3449
  // Plan #11 — prepend the relation tabs strip with the "Details" tab
3332
3450
  // active when the resource has relation managers configured.
3333
- const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__view');
3451
+ const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__view', user, record);
3334
3452
  if (relationTabsEl)
3335
3453
  elements.unshift(relationTabsEl);
3336
3454
  const recordTitle = record !== undefined && record !== null
@@ -3353,6 +3471,91 @@ export async function resourceViewData(pilotiq, slug, recordId, req) {
3353
3471
  notifications: consumeFlashedNotifications(req),
3354
3472
  };
3355
3473
  }
3474
+ /**
3475
+ * Custom record sub-page data builder. Mounted at
3476
+ * `${resourceBase}/${slug}/:id/${subPageSlug}` for each entry in
3477
+ * `Resource.pages().record`. Mirrors `resourceViewData`'s shape: load
3478
+ * the record, run R.canAccess + R.canView (parent-resource gates),
3479
+ * then SubPage.canAccess(user, record) (sub-page-specific gate),
3480
+ * then render the sub-page's schema with `ctx.record` set. Tab strip
3481
+ * carries the sub-page slug as the active key so the matching record
3482
+ * sub-page tab highlights.
3483
+ *
3484
+ * Returns:
3485
+ * - `null` — resource / sub-page slug not found (404 upstream).
3486
+ * - `{ ok: false, status: 403 }` — any gate fails or throws.
3487
+ * - resolved page data — on success.
3488
+ */
3489
+ export async function resourceRecordPageData(pilotiq, slug, recordId, subPageSlug, req) {
3490
+ const cfg = pilotiq.getConfig();
3491
+ const R = cfg.resources.find(r => r.getSlug() === slug);
3492
+ if (!R)
3493
+ return null;
3494
+ const recordPages = R.getRecordPages();
3495
+ const PageClass = recordPages[subPageSlug];
3496
+ if (!PageClass)
3497
+ return null;
3498
+ const user = await pilotiq.resolveUser(req);
3499
+ // Load the parent record before gating so canView / SubPage.canAccess
3500
+ // can branch on record state. Sub-pages without a Resource.model
3501
+ // still get gated against an `undefined` record — the same posture as
3502
+ // resourceViewData when no model is bound.
3503
+ let record = undefined;
3504
+ if (R.model) {
3505
+ try {
3506
+ record = await findRecord(R, recordId, { user });
3507
+ }
3508
+ catch { /* ignore */ }
3509
+ }
3510
+ if (record === undefined || record === null) {
3511
+ // Distinguish "model bound but record missing" (route should 404)
3512
+ // from "no model bound" (treat record as `{ id: recordId }` so the
3513
+ // page can still render — same convention as the edit page).
3514
+ if (R.model)
3515
+ return null;
3516
+ record = { id: recordId };
3517
+ }
3518
+ // Three gates: parent resource access + view, then the sub-page's own
3519
+ // canAccess. The route would have run R.canAccess upstream, but
3520
+ // re-running here makes resourceRecordPageData safe to call from
3521
+ // dispatchPageData (where the SPA path skips the route prelude).
3522
+ if (!await safeBool(() => R.canAccess(user)))
3523
+ return { ok: false, status: 403 };
3524
+ if (!await safeBool(() => R.canView(user, record)))
3525
+ return { ok: false, status: 403 };
3526
+ if (!await safeBool(() => PageClass.canAccess(user, record)))
3527
+ return { ok: false, status: 403 };
3528
+ const ctx = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg);
3529
+ const elements = await callPageSchema(PageClass, ctx);
3530
+ // Insert the relation-tabs strip with the sub-page slug active so the
3531
+ // matching tab highlights. `buildRelationTabs` evaluates per-tab
3532
+ // gating against `user + record` — record sub-page tabs are gated
3533
+ // alongside __view/__edit/managers.
3534
+ const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, subPageSlug, user, record);
3535
+ if (relationTabsEl)
3536
+ elements.unshift(relationTabsEl);
3537
+ const recordTitle = record !== undefined && record !== null
3538
+ ? deriveParentTitle(R, record)
3539
+ : recordId;
3540
+ const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle);
3541
+ if (breadcrumbs)
3542
+ elements.unshift(breadcrumbs);
3543
+ const recordPageRoute = { resource: R, page: PageClass, recordId };
3544
+ const schemaData = await applyRoleHooks(pilotiq, user, 'view', await resolveSchema(elements, record !== undefined ? { ...ctx, record } : ctx), recordPageRoute);
3545
+ return {
3546
+ pageType: 'record-page',
3547
+ panel: await panelInfo(pilotiq, req, recordPageRoute),
3548
+ page: PageClass.toMeta(),
3549
+ resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
3550
+ mode: 'record',
3551
+ recordId,
3552
+ subPage: { slug: subPageSlug, label: PageClass.getLabel() },
3553
+ basePath: cfg.path,
3554
+ layout: cfg.layout,
3555
+ schemaData,
3556
+ notifications: consumeFlashedNotifications(req),
3557
+ };
3558
+ }
3356
3559
  export async function globalEditData(pilotiq, slug, prefill, req) {
3357
3560
  const cfg = pilotiq.getConfig();
3358
3561
  const G = cfg.globals.find(g => g.getSlug() === slug);
@@ -3662,9 +3865,14 @@ export async function dispatchPageData(pageContext) {
3662
3865
  });
3663
3866
  // Tagged failure shapes (`{ ok: false, status: 403 }`) leak straight
3664
3867
  // through to the +Page renderer, which can branch on the shape.
3665
- // For Plan #11 we let null short-circuit the SPA render the same
3666
- // way the resource builders do.
3667
- return out === null ? null : out;
3868
+ // null = no manager named `relationship` on R; fall through to the
3869
+ // record sub-page lookup so URLs like `/admin/users/u1/activity`
3870
+ // (where `activity` is registered under `pages().record`) route
3871
+ // through `resourceRecordPageData` rather than 404ing.
3872
+ if (out !== null)
3873
+ return out;
3874
+ const recordOut = await resourceRecordPageData(panel, slug, id, relationship);
3875
+ return recordOut === null ? null : recordOut;
3668
3876
  }
3669
3877
  case '/pages/(pilotiq)/relation-create': {
3670
3878
  const slug = routeParams['slug'];