@pilotiq/pilotiq 0.8.1 → 0.9.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 (74) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +209 -0
  3. package/dist/Resource.d.ts +39 -0
  4. package/dist/Resource.d.ts.map +1 -1
  5. package/dist/Resource.js +30 -0
  6. package/dist/Resource.js.map +1 -1
  7. package/dist/pageData/navigation.d.ts +17 -1
  8. package/dist/pageData/navigation.d.ts.map +1 -1
  9. package/dist/pageData/navigation.js +14 -0
  10. package/dist/pageData/navigation.js.map +1 -1
  11. package/dist/react/AppShell.d.ts +5 -0
  12. package/dist/react/AppShell.d.ts.map +1 -1
  13. package/dist/react/AppShell.js +1 -1
  14. package/dist/react/AppShell.js.map +1 -1
  15. package/dist/react/FieldFocusReporterRegistry.d.ts +29 -0
  16. package/dist/react/FieldFocusReporterRegistry.d.ts.map +1 -0
  17. package/dist/react/FieldFocusReporterRegistry.js +14 -0
  18. package/dist/react/FieldFocusReporterRegistry.js.map +1 -0
  19. package/dist/react/FieldPresenceRegistry.d.ts +38 -0
  20. package/dist/react/FieldPresenceRegistry.d.ts.map +1 -0
  21. package/dist/react/FieldPresenceRegistry.js +14 -0
  22. package/dist/react/FieldPresenceRegistry.js.map +1 -0
  23. package/dist/react/FormCollabBindingRegistry.d.ts +71 -1
  24. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  25. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  26. package/dist/react/FormStateContext.d.ts +17 -0
  27. package/dist/react/FormStateContext.d.ts.map +1 -1
  28. package/dist/react/FormStateContext.js +44 -3
  29. package/dist/react/FormStateContext.js.map +1 -1
  30. package/dist/react/RecordWrapperGate.d.ts +19 -6
  31. package/dist/react/RecordWrapperGate.d.ts.map +1 -1
  32. package/dist/react/RecordWrapperGate.js +18 -8
  33. package/dist/react/RecordWrapperGate.js.map +1 -1
  34. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  35. package/dist/react/fields/FieldShell.js +27 -3
  36. package/dist/react/fields/FieldShell.js.map +1 -1
  37. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  38. package/dist/react/fields/MarkdownInput.js +105 -3
  39. package/dist/react/fields/MarkdownInput.js.map +1 -1
  40. package/dist/react/fields/TextLikeInput.d.ts +10 -0
  41. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  42. package/dist/react/fields/TextLikeInput.js +179 -0
  43. package/dist/react/fields/TextLikeInput.js.map +1 -1
  44. package/dist/react/fields/textDelta.d.ts +44 -0
  45. package/dist/react/fields/textDelta.d.ts.map +1 -0
  46. package/dist/react/fields/textDelta.js +80 -0
  47. package/dist/react/fields/textDelta.js.map +1 -0
  48. package/dist/react/index.d.ts +4 -2
  49. package/dist/react/index.d.ts.map +1 -1
  50. package/dist/react/index.js +3 -1
  51. package/dist/react/index.js.map +1 -1
  52. package/dist/react/parseRecordEditUrl.d.ts +33 -9
  53. package/dist/react/parseRecordEditUrl.d.ts.map +1 -1
  54. package/dist/react/parseRecordEditUrl.js +40 -2
  55. package/dist/react/parseRecordEditUrl.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/Resource.test.ts +44 -0
  58. package/src/Resource.ts +58 -0
  59. package/src/pageData/navigation.ts +32 -1
  60. package/src/pageData.test.ts +36 -0
  61. package/src/react/AppShell.tsx +6 -1
  62. package/src/react/FieldFocusReporterRegistry.ts +37 -0
  63. package/src/react/FieldPresenceRegistry.ts +46 -0
  64. package/src/react/FormCollabBindingRegistry.ts +63 -1
  65. package/src/react/FormStateContext.tsx +62 -3
  66. package/src/react/RecordWrapperGate.tsx +26 -8
  67. package/src/react/fields/FieldShell.tsx +39 -2
  68. package/src/react/fields/MarkdownInput.tsx +100 -3
  69. package/src/react/fields/TextLikeInput.tsx +203 -1
  70. package/src/react/fields/textDelta.test.ts +141 -0
  71. package/src/react/fields/textDelta.ts +86 -0
  72. package/src/react/index.ts +20 -1
  73. package/src/react/parseRecordEditUrl.test.ts +48 -1
  74. package/src/react/parseRecordEditUrl.ts +52 -13
@@ -8,10 +8,12 @@ export { PendingSuggestionsContext, usePendingSuggestions, usePendingSuggestions
8
8
  export { registerPendingSuggestionApplier, getPendingSuggestionApplier, type PendingSuggestionApplier, } from './PendingSuggestionApplierRegistry.js';
9
9
  export { CollabRoomContext, useCollabRoom, type CollabRoom, } from './CollabRoomContext.js';
10
10
  export { registerCollabExtensions, getCollabExtensions, type CollabExtensionFactory, type CollabExtensionFactoryArgs, } from './CollabExtensionFactoryRegistry.js';
11
- export { registerFormCollabBinding, getFormCollabBinding, type FormCollabBinding, type FormCollabBindingFactory, type FormCollabBindingFactoryArgs, } from './FormCollabBindingRegistry.js';
11
+ export { registerFormCollabBinding, getFormCollabBinding, type FormCollabBinding, type FormCollabBindingFactory, type FormCollabBindingFactoryArgs, type TextBinding, type TextDelta, } from './FormCollabBindingRegistry.js';
12
+ export { registerFieldPresenceComponent, getFieldPresenceComponent, type FieldPresenceProps, } from './FieldPresenceRegistry.js';
13
+ export { registerFieldFocusReporter, getFieldFocusReporter, type FieldFocusReporter, type FieldFocusEvent, } from './FieldFocusReporterRegistry.js';
12
14
  export { registerRecordWrapper, getRecordWrapper, type RecordWrapperProps, } from './RecordWrapperRegistry.js';
13
15
  export { RecordWrapperGate, type RecordWrapperGateProps, } from './RecordWrapperGate.js';
14
- export { parseRecordEditUrl, type RecordEditIdentity } from './parseRecordEditUrl.js';
16
+ export { parseRecordPageUrl, parseRecordEditUrl, type RecordPageIdentity, type RecordPageRole, type RecordEditIdentity, } from './parseRecordEditUrl.js';
15
17
  export { registerWidgetRenderer, getWidgetRenderer, type WidgetRendererProps, } from './widgetRegistry.js';
16
18
  export { FormStateProvider, useFieldState, useFormState, type FormStateApi, type FormStateProviderProps, type UseFieldStateResult, } from './FormStateContext.js';
17
19
  export { parseFormDataToNested } from './formStateHelpers.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"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"}
@@ -9,9 +9,11 @@ export { registerPendingSuggestionApplier, getPendingSuggestionApplier, } from '
9
9
  export { CollabRoomContext, useCollabRoom, } from './CollabRoomContext.js';
10
10
  export { registerCollabExtensions, getCollabExtensions, } from './CollabExtensionFactoryRegistry.js';
11
11
  export { registerFormCollabBinding, getFormCollabBinding, } from './FormCollabBindingRegistry.js';
12
+ export { registerFieldPresenceComponent, getFieldPresenceComponent, } from './FieldPresenceRegistry.js';
13
+ export { registerFieldFocusReporter, getFieldFocusReporter, } from './FieldFocusReporterRegistry.js';
12
14
  export { registerRecordWrapper, getRecordWrapper, } from './RecordWrapperRegistry.js';
13
15
  export { RecordWrapperGate, } from './RecordWrapperGate.js';
14
- export { parseRecordEditUrl } from './parseRecordEditUrl.js';
16
+ export { parseRecordPageUrl, parseRecordEditUrl, } from './parseRecordEditUrl.js';
15
17
  export { registerWidgetRenderer, getWidgetRenderer, } from './widgetRegistry.js';
16
18
  export { FormStateProvider, useFieldState, useFormState, } from './FormStateContext.js';
17
19
  export { parseFormDataToNested } from './formStateHelpers.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAsB,MAAM,eAAe,CAAA;AAC5D,OAAO,EACL,yBAAyB,EACzB,oBAAoB,EACpB,UAAU,GAEX,MAAM,mBAAmB,CAAA;AAE1B,OAAO,EACL,cAAc,EAEd,UAAU,GAEX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,qBAAqB,EAAE,gBAAgB,EAA2B,MAAM,eAAe,CAAA;AAChG,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAA4B,MAAM,6BAA6B,CAAA;AACjH,OAAO,EACL,gCAAgC,EAChC,2BAA2B,GAE5B,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EACL,yBAAyB,EACzB,qBAAqB,EACrB,6BAA6B,GAI9B,MAAM,gCAAgC,CAAA;AACvC,OAAO,EACL,gCAAgC,EAChC,2BAA2B,GAE5B,MAAM,uCAAuC,CAAA;AAC9C,OAAO,EACL,iBAAiB,EACjB,aAAa,GAEd,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EACL,wBAAwB,EACxB,mBAAmB,GAGpB,MAAM,qCAAqC,CAAA;AAC5C,OAAO,EACL,yBAAyB,EACzB,oBAAoB,GAIrB,MAAM,gCAAgC,CAAA;AACvC,OAAO,EACL,qBAAqB,EACrB,gBAAgB,GAEjB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EACL,iBAAiB,GAElB,MAAM,wBAAwB,CAAA;AAC/B,OAAO,EAAE,kBAAkB,EAA2B,MAAM,yBAAyB,CAAA;AACrF,OAAO,EACL,sBAAsB,EACtB,iBAAiB,GAElB,MAAM,qBAAqB,CAAA;AAE5B,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,YAAY,GAIb,MAAM,uBAAuB,CAAA;AAE9B,OAAO,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAA;AAE7D,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAmB,MAAM,eAAe,CAAA;AAE9E,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAA;AAExD,OAAO,EACL,kBAAkB,EAClB,oBAAoB,EACpB,aAAa,GAId,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EACL,0BAA0B,EAC1B,qBAAqB,EACrB,sBAAsB,GAEvB,MAAM,2BAA2B,CAAA;AAElC,OAAO,EACL,oBAAoB,EACpB,eAAe,EACf,uBAAuB,GAGxB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,YAAY,GAEb,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,aAAa,EAAE,MAAY,oBAAoB,CAAA;AACxD,OAAO,EACL,iBAAiB,EACjB,eAAe,GAGhB,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,SAAS,EAAE,MAAW,gBAAgB,CAAA;AAE/C,OAAO,EACL,eAAe,GAKhB,MAAM,sBAAsB,CAAA;AAG7B,iHAAiH;AACjH,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAA;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAA;AAElD,mBAAmB;AACnB,OAAO,EAAE,QAAQ,IAAI,UAAU,EAAyC,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/react/index.ts"],"names":[],"mappings":"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"}
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Parses a pilotiq URL into a record-edit identity, or returns `null`
3
- * for any URL that isn't a record-bound edit page.
2
+ * URL record-page identity parser. Used by `RecordWrapperGate` (and any
3
+ * plugin that wants to reason about record-bound URLs) to decide whether
4
+ * the current page is a record-edit or record-view route.
4
5
  *
5
6
  * A URL matches when:
6
7
  * 1. it starts with the panel's `basePath`
7
- * 2. after stripping the prefix it ends with `/edit`
8
+ * 2. after stripping the prefix it ends with `/edit` or `/view`
8
9
  * 3. there are at least three remaining segments (resource slug,
9
- * record id, `edit`)
10
+ * record id, terminal token)
10
11
  *
11
12
  * The `resourceSlug` is the slash-joined chain of every segment up to
12
13
  * the record id — this gives clustered resources (`${base}/blog/articles/123/edit`)
@@ -14,13 +15,36 @@
14
15
  * distinct slugs so two URLs that target different records always
15
16
  * produce different room names downstream.
16
17
  *
17
- * `/admin/articles/123/edit` → { resourceSlug: 'articles', recordId: '123' }
18
- * `/admin/blog/articles/123/edit` → { resourceSlug: 'blog/articles', recordId: '123' }
19
- * `/admin/articles/123/comments/456/edit` → { resourceSlug: 'articles/123/comments', recordId: '456' }
20
- * `/admin/articles/123/comments` null (no trailing /edit)
21
- * `/admin/articles/123/comments/create` → null (no record id)
18
+ * `/admin/articles/123/edit` → { slug: 'articles', id: '123', role: 'edit' }
19
+ * `/admin/articles/123/view` → { slug: 'articles', id: '123', role: 'view' }
20
+ * `/admin/blog/articles/123/edit` → { slug: 'blog/articles', id: '123', role: 'edit' }
21
+ * `/admin/articles/123/comments/456/edit` { slug: 'articles/123/comments', id: '456', role: 'edit' }
22
+ * `/admin/articles/123/comments` → null (no trailing /edit or /view)
23
+ * `/admin/articles/123/comments/create` → null (terminal token isn't edit|view)
22
24
  * `/site/articles/123/edit` → null (basePath mismatch when basePath='/admin')
23
25
  */
26
+ /** Page roles `parseRecordPageUrl` recognizes. `'edit'` and `'view'`
27
+ * are the two record-scoped page roles where collab and other
28
+ * record-bound plugins want to mount their per-record wrapper. */
29
+ export type RecordPageRole = 'edit' | 'view';
30
+ export interface RecordPageIdentity {
31
+ resourceSlug: string;
32
+ recordId: string;
33
+ /** Which terminal URL segment matched — `'edit'` for the writable edit
34
+ * page, `'view'` for the read-only view page. Lets the gate decide per
35
+ * resource whether collab activates on this role (defaults to `'edit'`
36
+ * only; resources opt into `'view'` for presence-only experiences). */
37
+ role: RecordPageRole;
38
+ }
39
+ /**
40
+ * Parse a pilotiq URL into a `{ slug, id, role }` identity. Returns
41
+ * `null` for any URL that isn't a record-edit or record-view page.
42
+ */
43
+ export declare function parseRecordPageUrl(currentPath: string, basePath: string): RecordPageIdentity | null;
44
+ /** Legacy alias: parse a URL into an edit-only identity. Returns `null`
45
+ * for view URLs (and any non-edit URL). Kept for back-compat with the
46
+ * pre-`parseRecordPageUrl` public API; new code should call
47
+ * `parseRecordPageUrl` and branch on `role`. */
24
48
  export interface RecordEditIdentity {
25
49
  resourceSlug: string;
26
50
  recordId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"parseRecordEditUrl.d.ts","sourceRoot":"","sources":["../../src/react/parseRecordEditUrl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAM,MAAM,CAAA;CACrB;AAED,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAK,MAAM,GAClB,kBAAkB,GAAG,IAAI,CAuB3B"}
1
+ {"version":3,"file":"parseRecordEditUrl.d.ts","sourceRoot":"","sources":["../../src/react/parseRecordEditUrl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;kEAEkE;AAClE,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,CAAA;AAE5C,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAM,MAAM,CAAA;IACpB;;;2EAGuE;IACvE,IAAI,EAAU,cAAc,CAAA;CAC7B;AAID;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAK,MAAM,GAClB,kBAAkB,GAAG,IAAI,CAyB3B;AAED;;;gDAGgD;AAChD,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,MAAM,CAAA;IACpB,QAAQ,EAAM,MAAM,CAAA;CACrB;AAED,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAK,MAAM,GAClB,kBAAkB,GAAG,IAAI,CAI3B"}
@@ -1,4 +1,34 @@
1
- export function parseRecordEditUrl(currentPath, basePath) {
1
+ /**
2
+ * URL → record-page identity parser. Used by `RecordWrapperGate` (and any
3
+ * plugin that wants to reason about record-bound URLs) to decide whether
4
+ * the current page is a record-edit or record-view route.
5
+ *
6
+ * A URL matches when:
7
+ * 1. it starts with the panel's `basePath`
8
+ * 2. after stripping the prefix it ends with `/edit` or `/view`
9
+ * 3. there are at least three remaining segments (resource slug,
10
+ * record id, terminal token)
11
+ *
12
+ * The `resourceSlug` is the slash-joined chain of every segment up to
13
+ * the record id — this gives clustered resources (`${base}/blog/articles/123/edit`)
14
+ * and nested-relation edits (`${base}/articles/123/comments/456/edit`)
15
+ * distinct slugs so two URLs that target different records always
16
+ * produce different room names downstream.
17
+ *
18
+ * `/admin/articles/123/edit` → { slug: 'articles', id: '123', role: 'edit' }
19
+ * `/admin/articles/123/view` → { slug: 'articles', id: '123', role: 'view' }
20
+ * `/admin/blog/articles/123/edit` → { slug: 'blog/articles', id: '123', role: 'edit' }
21
+ * `/admin/articles/123/comments/456/edit` → { slug: 'articles/123/comments', id: '456', role: 'edit' }
22
+ * `/admin/articles/123/comments` → null (no trailing /edit or /view)
23
+ * `/admin/articles/123/comments/create` → null (terminal token isn't edit|view)
24
+ * `/site/articles/123/edit` → null (basePath mismatch when basePath='/admin')
25
+ */
26
+ const RECOGNIZED_ROLES = ['edit', 'view'];
27
+ /**
28
+ * Parse a pilotiq URL into a `{ slug, id, role }` identity. Returns
29
+ * `null` for any URL that isn't a record-edit or record-view page.
30
+ */
31
+ export function parseRecordPageUrl(currentPath, basePath) {
2
32
  if (!currentPath)
3
33
  return null;
4
34
  // Normalise — trailing slashes on the URL or trailing slashes on
@@ -11,7 +41,8 @@ export function parseRecordEditUrl(currentPath, basePath) {
11
41
  const parts = tail.split('/').filter(Boolean);
12
42
  if (parts.length < 3)
13
43
  return null;
14
- if (parts[parts.length - 1] !== 'edit')
44
+ const terminal = parts[parts.length - 1];
45
+ if (!RECOGNIZED_ROLES.includes(terminal))
15
46
  return null;
16
47
  const recordId = parts[parts.length - 2];
17
48
  const slugParts = parts.slice(0, parts.length - 2);
@@ -20,6 +51,13 @@ export function parseRecordEditUrl(currentPath, basePath) {
20
51
  return {
21
52
  resourceSlug: slugParts.join('/'),
22
53
  recordId,
54
+ role: terminal,
23
55
  };
24
56
  }
57
+ export function parseRecordEditUrl(currentPath, basePath) {
58
+ const identity = parseRecordPageUrl(currentPath, basePath);
59
+ if (!identity || identity.role !== 'edit')
60
+ return null;
61
+ return { resourceSlug: identity.resourceSlug, recordId: identity.recordId };
62
+ }
25
63
  //# sourceMappingURL=parseRecordEditUrl.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"parseRecordEditUrl.js","sourceRoot":"","sources":["../../src/react/parseRecordEditUrl.ts"],"names":[],"mappings":"AA4BA,MAAM,UAAU,kBAAkB,CAChC,WAAmB,EACnB,QAAmB;IAEnB,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAA;IAC7B,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,WAAW,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IACnD,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IAEhD,IAAI,WAAW,KAAK,EAAE,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,OAAO,IAAI,CAAA;IAE3E,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IACtE,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IAE7C,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACjC,IAAI,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,MAAM;QAAE,OAAO,IAAI,CAAA;IAEnD,MAAM,QAAQ,GAAM,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAA;IAC5C,MAAM,SAAS,GAAK,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IACpD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAEvC,OAAO;QACL,YAAY,EAAE,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC;QACjC,QAAQ;KACT,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"parseRecordEditUrl.js","sourceRoot":"","sources":["../../src/react/parseRecordEditUrl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAiBH,MAAM,gBAAgB,GAAkC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAExE;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAChC,WAAmB,EACnB,QAAmB;IAEnB,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAA;IAC7B,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,WAAW,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IACnD,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IAEhD,IAAI,WAAW,KAAK,EAAE,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,OAAO,IAAI,CAAA;IAE3E,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IACtE,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IAE7C,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACjC,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAA;IACzC,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,QAA0B,CAAC;QAAE,OAAO,IAAI,CAAA;IAEvE,MAAM,QAAQ,GAAM,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAA;IAC5C,MAAM,SAAS,GAAK,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IACpD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAEvC,OAAO;QACL,YAAY,EAAE,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC;QACjC,QAAQ;QACR,IAAI,EAAU,QAA0B;KACzC,CAAA;AACH,CAAC;AAWD,MAAM,UAAU,kBAAkB,CAChC,WAAmB,EACnB,QAAmB;IAEnB,MAAM,QAAQ,GAAG,kBAAkB,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;IAC1D,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,IAAI,KAAK,MAAM;QAAE,OAAO,IAAI,CAAA;IACtD,OAAO,EAAE,YAAY,EAAE,QAAQ,CAAC,YAAY,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAA;AAC7E,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/pilotiq",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "View-based admin panel for RudderJS",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -237,4 +237,48 @@ describe('Resource (static API)', () => {
237
237
  assert.equal(await R.canRestore(null, { ownedBy: 'other' }), false)
238
238
  })
239
239
  })
240
+
241
+ describe('collab opt-in', () => {
242
+ it('omitted static collab → getResolvedCollabConfig returns null', () => {
243
+ class R extends Resource {}
244
+ assert.equal(R.getResolvedCollabConfig(), null)
245
+ })
246
+
247
+ it('static collab = false → null (explicit opt-out)', () => {
248
+ class R extends Resource {
249
+ static override collab = false as const
250
+ }
251
+ assert.equal(R.getResolvedCollabConfig(), null)
252
+ })
253
+
254
+ it('static collab = true → defaults to { pages: [edit], presence: true }', () => {
255
+ class R extends Resource {
256
+ static override collab = true as const
257
+ }
258
+ assert.deepEqual(R.getResolvedCollabConfig(), {
259
+ pages: ['edit'],
260
+ presence: true,
261
+ })
262
+ })
263
+
264
+ it('object form merges with defaults', () => {
265
+ class R extends Resource {
266
+ static override collab = { pages: ['edit', 'view'] as const }
267
+ }
268
+ assert.deepEqual(R.getResolvedCollabConfig(), {
269
+ pages: ['edit', 'view'],
270
+ presence: true, // default preserved
271
+ })
272
+ })
273
+
274
+ it('object form can suppress presence', () => {
275
+ class R extends Resource {
276
+ static override collab = { presence: false }
277
+ }
278
+ assert.deepEqual(R.getResolvedCollabConfig(), {
279
+ pages: ['edit'], // default preserved
280
+ presence: false,
281
+ })
282
+ })
283
+ })
240
284
  })
package/src/Resource.ts CHANGED
@@ -50,6 +50,33 @@ export type NavigationBadgeColor =
50
50
  export type NavigationBadgeHandler =
51
51
  () => string | number | undefined | Promise<string | number | undefined>
52
52
 
53
+ /**
54
+ * Per-resource collab configuration. Set via `static collab = true` (the
55
+ * 90% case — opts the edit page in with presence) or via the object form
56
+ * for finer control. Omitting `static collab` entirely keeps the resource
57
+ * collab-free even when the `@pilotiq-pro/collab` plugin is registered.
58
+ *
59
+ * pages — page roles where collab activates. `'edit'` syncs values +
60
+ * presence; `'view'` is presence-only (the page is read-only,
61
+ * value-sync would be moot). Defaults to `['edit']`.
62
+ * presence — when false, suppress the awareness layer (focus chips,
63
+ * cursor positions) while keeping value-sync. Defaults to true.
64
+ *
65
+ * Field-level `.collab(false)` always wins over this setting — opting the
66
+ * resource in then opting individual fields out is the supported shape.
67
+ */
68
+ export interface ResourceCollabConfig {
69
+ pages: ReadonlyArray<'edit' | 'view'>
70
+ presence: boolean
71
+ }
72
+
73
+ /** Raw shape accepted by `static collab` before normalization. `true` is a
74
+ * shorthand for `{ pages: ['edit'], presence: true }`. */
75
+ export type ResourceCollabInput = boolean | {
76
+ pages?: ReadonlyArray<'edit' | 'view'>
77
+ presence?: boolean
78
+ }
79
+
53
80
  /**
54
81
  * Abstract Resource base class. **All methods are static** — resources are
55
82
  * registered by class, not by instance. Routes look up the class and call
@@ -297,6 +324,37 @@ export abstract class Resource {
297
324
  return undefined
298
325
  }
299
326
 
327
+ // ─── Realtime collab opt-in ────────────────────────────────
328
+ // Opt-in per resource. The `@pilotiq-pro/collab` plugin registers the
329
+ // global singletons (transport, Tiptap extension factory, RecordWrapper
330
+ // factory); resources with `static collab` unset stay collab-free even
331
+ // when the plugin is installed. Field-level `.collab(false)` overrides
332
+ // this setting per field.
333
+
334
+ /** Enable collab on this resource. `true` is the 90% case — shorthand
335
+ * for `{ pages: ['edit'], presence: true }`. Use the object form to
336
+ * narrow which page roles activate or to suppress presence. Default
337
+ * `false` (collab off — the WS room is never opened for this resource). */
338
+ static collab: ResourceCollabInput = false
339
+
340
+ /**
341
+ * Normalize `static collab` into the canonical wire shape, or return
342
+ * `null` when the resource has opted out. Centralizes the `true` →
343
+ * defaults expansion and the `{ pages?: … }` merge.
344
+ *
345
+ * Result lands on `panelInfo().recordCollab[slug]`; the gate reads it
346
+ * to decide whether to mount the record wrapper.
347
+ */
348
+ static getResolvedCollabConfig(): ResourceCollabConfig | null {
349
+ const raw = this.collab
350
+ if (raw === false || raw == null) return null
351
+ if (raw === true) return { pages: ['edit'], presence: true }
352
+ return {
353
+ pages: raw.pages ?? ['edit'],
354
+ presence: raw.presence ?? true,
355
+ }
356
+ }
357
+
300
358
  // ─── Plan #10: authorization predicates ────────────────────
301
359
  // All async, all default `true`. Routes call them with the resolved
302
360
  // user (from `Pilotiq.user(fn)`); the renderer threads the same user
@@ -1,6 +1,6 @@
1
1
  import type { Pilotiq, PilotiqConfig } from '../Pilotiq.js'
2
2
  import type { Page } from '../Page.js'
3
- import type { ResourceClass, NavigationBadgeColor } from '../Resource.js'
3
+ import type { ResourceClass, NavigationBadgeColor, ResourceCollabConfig } from '../Resource.js'
4
4
  import type { GlobalClass } from '../Global.js'
5
5
  import type { ClusterClass } from '../Cluster.js'
6
6
  import { resourceBasePath, globalBasePath, pageBasePath } from '../clusterPaths.js'
@@ -194,6 +194,35 @@ export interface PanelInfoRoute {
194
194
  url?: string
195
195
  }
196
196
 
197
+ /**
198
+ * Per-resource collab opt-in map, keyed by the URL slug
199
+ * `parseRecordPageUrl` produces. Non-clustered resource → `getSlug()`;
200
+ * clustered resource → `${cluster.getSlug()}/${R.getSlug()}`.
201
+ *
202
+ * `RecordWrapperGate` reads this map to decide whether the page tree
203
+ * needs the plugin-registered RecordWrapper (collab room, audit, …)
204
+ * mounted around the record view/edit content area.
205
+ *
206
+ * Nested-relation edit URLs (`/articles/123/comments/456/edit`) have a
207
+ * dynamic-id segment in the gate's URL slug and don't match here in v1.
208
+ * Collab on nested-relation edits is a follow-up — top-level resource
209
+ * edits are the common case and ship now.
210
+ */
211
+ export type RecordCollabMap = Record<string, ResourceCollabConfig>
212
+
213
+ function resourceSlugForGate(R: ResourceClass): string {
214
+ return R.cluster ? `${R.cluster.getSlug()}/${R.getSlug()}` : R.getSlug()
215
+ }
216
+
217
+ function buildRecordCollabMap(cfg: Readonly<PilotiqConfig>): RecordCollabMap | undefined {
218
+ const map: RecordCollabMap = {}
219
+ for (const R of cfg.resources) {
220
+ const collab = R.getResolvedCollabConfig()
221
+ if (collab) map[resourceSlugForGate(R)] = collab
222
+ }
223
+ return Object.keys(map).length > 0 ? map : undefined
224
+ }
225
+
197
226
  export async function panelInfo(
198
227
  pilotiq: Pilotiq,
199
228
  req?: unknown,
@@ -210,6 +239,7 @@ export async function panelInfo(
210
239
  buildRightSidebarMeta(cfg, user),
211
240
  ])
212
241
  const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
242
+ const recordCollab = buildRecordCollabMap(cfg)
213
243
  // AI suggestion mode — sparse: omit when 'auto' (the default) so the
214
244
  // wire shape stays minimal for panels that don't opt into review mode.
215
245
  // Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
@@ -225,6 +255,7 @@ export async function panelInfo(
225
255
  ...(userMenu ? { userMenu } : {}),
226
256
  ...(databaseNotifications ? { databaseNotifications } : {}),
227
257
  ...(rightSidebar ? { rightSidebar } : {}),
258
+ ...(recordCollab ? { recordCollab } : {}),
228
259
  ...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
229
260
  ...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
230
261
  }
@@ -1202,3 +1202,39 @@ describe('tagRichTextMentionUrls — nested Repeater + Builder rows', () => {
1202
1202
  assert.equal(inner.stamped, '/admin/_form/art/mentions')
1203
1203
  })
1204
1204
  })
1205
+
1206
+ describe('panelInfo — recordCollab map (resource collab opt-in)', () => {
1207
+ it('absent when no resource opts in', async () => {
1208
+ class Posts extends Resource { static override label = 'Posts' }
1209
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
1210
+ assert.equal((info as { recordCollab?: unknown }).recordCollab, undefined)
1211
+ })
1212
+
1213
+ it('emits an entry for each opted-in resource keyed by URL slug', async () => {
1214
+ class Posts extends Resource {
1215
+ static override label = 'Posts'
1216
+ static override collab = true as const
1217
+ }
1218
+ class Users extends Resource {
1219
+ static override label = 'Users'
1220
+ // No collab — should NOT appear in the map.
1221
+ }
1222
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts, Users]))
1223
+ const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
1224
+ assert.deepEqual(map, {
1225
+ posts: { pages: ['edit'], presence: true },
1226
+ })
1227
+ })
1228
+
1229
+ it('honors object form of static collab (pages + presence override defaults)', async () => {
1230
+ class Posts extends Resource {
1231
+ static override label = 'Posts'
1232
+ static override collab = { pages: ['edit', 'view'] as const, presence: false }
1233
+ }
1234
+ const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
1235
+ const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
1236
+ assert.deepEqual(map, {
1237
+ posts: { pages: ['edit', 'view'], presence: false },
1238
+ })
1239
+ })
1240
+ })
@@ -10,7 +10,7 @@ import type { RightPanelRegistry } from './right-panel-registry.js'
10
10
  import { RightPanelRegistryProvider } from './right-panel-registry.js'
11
11
  import { RightSidebarProvider, useRightSidebarOptional } from './RightSidebarContext.js'
12
12
  import { RightSidebar } from './RightSidebar.js'
13
- import { RecordWrapperGate } from './RecordWrapperGate.js'
13
+ import { RecordWrapperGate, type RecordCollabMap } from './RecordWrapperGate.js'
14
14
  import { useIsMobile } from './hooks/use-mobile.js'
15
15
  import type { NavItem, UserMenuMeta, DatabaseNotificationsMeta, RightSidebarMeta } from '../pageData.js'
16
16
  import type { RenderHookMap } from '../RenderHook.js'
@@ -34,6 +34,10 @@ export interface AppShellProps {
34
34
  * `panelInfo()` only ships this when at least one contribution
35
35
  * was registered AND passed the auth gate AND is non-hidden. */
36
36
  rightSidebar?: RightSidebarMeta
37
+ /** Per-resource collab opt-in map — read by `RecordWrapperGate` to
38
+ * decide whether to mount the plugin-registered RecordWrapper on
39
+ * a record edit/view URL. Absent when no resource opted in. */
40
+ recordCollab?: RecordCollabMap
37
41
  /** Pre-resolved render-hook slots for the panel chrome (body /
38
42
  * topbar / sidebar / user-menu / footer / head). Sparse map —
39
43
  * slots with no registered entries are absent. Built by
@@ -130,6 +134,7 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
130
134
  <RecordWrapperGate
131
135
  basePath={props.basePath}
132
136
  {...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
137
+ {...(props.panel.recordCollab !== undefined ? { recordCollab: props.panel.recordCollab } : {})}
133
138
  >
134
139
  {props.children}
135
140
  </RecordWrapperGate>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Module-level slot for focus / blur callbacks dispatched by pilotiq's
3
+ * `FieldShell`. A collab plugin (e.g. `@pilotiq-pro/collab`) registers
4
+ * a reporter at boot; on every controlled-field focus / blur event,
5
+ * `FieldShell` calls the matching method with `{ fieldName, formId }`
6
+ * so the plugin can mirror the local user's focus state into Yjs
7
+ * awareness.
8
+ *
9
+ * `FieldShell` skips dispatching for fields opted out via
10
+ * `Field.collab(false)` AND for dotted-path names (Repeater rows stay
11
+ * out of F4 presence in v1) — so the reporter never sees those events.
12
+ */
13
+ export interface FieldFocusEvent {
14
+ fieldName: string
15
+ formId: string
16
+ }
17
+
18
+ export interface FieldFocusReporter {
19
+ onFocus(event: FieldFocusEvent): void
20
+ onBlur(event: FieldFocusEvent): void
21
+ }
22
+
23
+ let _reporter: FieldFocusReporter | null = null
24
+
25
+ /**
26
+ * Register the reporter. Called once at boot by the collab plugin.
27
+ * No-op when no plugin registers — `FieldShell` doesn't wire focus
28
+ * listeners.
29
+ */
30
+ export function registerFieldFocusReporter(reporter: FieldFocusReporter): void {
31
+ _reporter = reporter
32
+ }
33
+
34
+ /** Returns the registered reporter, or `null`. */
35
+ export function getFieldFocusReporter(): FieldFocusReporter | null {
36
+ return _reporter
37
+ }
@@ -0,0 +1,46 @@
1
+ import type { ComponentType } from 'react'
2
+
3
+ /**
4
+ * Props the per-field presence chip receives from `FieldShell` when a
5
+ * `@pilotiq-pro/collab`-style plugin has registered a component.
6
+ *
7
+ * The chip subscribes to the active collab room's awareness state and
8
+ * renders colored avatars / dots for every remote user currently
9
+ * focused on this field. Pilotiq core renders it alongside the field's
10
+ * label; the component owns the awareness lookup so pilotiq stays
11
+ * Yjs-free.
12
+ *
13
+ * Mounted only when:
14
+ *
15
+ * - A collab plugin has registered a component via
16
+ * `registerFieldPresenceComponent(...)`,
17
+ * - The field hasn't opted out via `Field.collab(false)`, and
18
+ * - The field has a stable `name` (dotted-path Repeater rows skip
19
+ * presence in v1 — Phase F.5 work).
20
+ */
21
+ export interface FieldPresenceProps {
22
+ /** Top-level field name. Dotted-path names are skipped by `FieldShell`. */
23
+ fieldName: string
24
+ /**
25
+ * Stable form identifier — same value the form's `FormStateProvider`
26
+ * uses. Lets the chip scope presence by form when multiple forms
27
+ * render on the same record (action modals, etc.).
28
+ */
29
+ formId: string
30
+ }
31
+
32
+ let _component: ComponentType<FieldPresenceProps> | null = null
33
+
34
+ /**
35
+ * Register a component that renders inside `FieldShell`'s label area
36
+ * for every controlled field. Called once at boot by a collab plugin.
37
+ * No-op when no plugin registers — `FieldShell` skips the slot.
38
+ */
39
+ export function registerFieldPresenceComponent(C: ComponentType<FieldPresenceProps>): void {
40
+ _component = C
41
+ }
42
+
43
+ /** Returns the registered presence component, or `null`. */
44
+ export function getFieldPresenceComponent(): ComponentType<FieldPresenceProps> | null {
45
+ return _component
46
+ }
@@ -1,5 +1,44 @@
1
+ import type { ElementMeta } from '../schema/Element.js'
1
2
  import type { CollabRoom } from './CollabRoomContext.js'
2
3
 
4
+ /**
5
+ * Phase F.6 — character-level edit op emitted by `TextLikeInput` and
6
+ * applied through `TextBinding.applyDelta`. `replace` covers IME / paste
7
+ * / multi-char selections; `insert` and `delete` cover the single-key
8
+ * common path. Pilotiq core stays Yjs-free — the binding impl in
9
+ * `@pilotiq-pro/collab` translates these into `Y.Text.insert / delete`
10
+ * inside a transaction.
11
+ */
12
+ export type TextDelta =
13
+ | { kind: 'insert', index: number, text: string }
14
+ | { kind: 'delete', index: number, length: number }
15
+ | { kind: 'replace', from: number, to: number, text: string }
16
+
17
+ /**
18
+ * Phase F.6 — per-field character-level CRDT handle. Issued by
19
+ * `FormCollabBinding.getTextBinding(name)` for text-shaped fields
20
+ * (`TextField / TextareaField / EmailField / SlugField / MarkdownField`);
21
+ * returns `null` for non-text fields or text fields opted out via
22
+ * `.collab(false)`. The surface stays intentionally narrow so pilotiq
23
+ * core never touches Yjs directly — same posture as `FormCollabBinding`.
24
+ *
25
+ * - `read()` returns the current full string. `TextLikeInput` calls
26
+ * this once on mount to seed its controlled value.
27
+ * - `applyDelta(delta)` is called from `onInput` events with a single
28
+ * `insert / delete / replace` op derived from the input's selection.
29
+ * - `observe(fn)` registers a remote-change listener; `fn(next)`
30
+ * receives the post-change string. Returns an unsubscribe function.
31
+ * - `destroy()` cleans up everything the handle holds. The owning
32
+ * `FormCollabBinding.destroy()` is expected to cascade — consumers
33
+ * don't need to call this directly.
34
+ */
35
+ export interface TextBinding {
36
+ read(): string
37
+ applyDelta(delta: TextDelta): void
38
+ observe(fn: (next: string) => void): () => void
39
+ destroy(): void
40
+ }
41
+
3
42
  /**
4
43
  * Binding contract that a collab plugin returns from
5
44
  * `registerFormCollabBinding` — wraps a single form's value map in a
@@ -16,8 +55,14 @@ import type { CollabRoom } from './CollabRoomContext.js'
16
55
  * - `subscribe(fn)` registers a listener that fires when REMOTE
17
56
  * changes land; `fn(snapshot)` receives the full updated map.
18
57
  * The provider re-applies this snapshot onto its React state.
58
+ * - `getTextBinding(name)` (Phase F.6) returns a `Y.Text`-backed
59
+ * handle for text-shaped fields, or `null` for non-text fields and
60
+ * text fields opted out via `.collab(false)`. The text/non-text
61
+ * allowlist lives in the binding impl — `FormStateProvider` asks
62
+ * for every field and routes per-field on the answer.
19
63
  * - `destroy()` is called on unmount — gives the plugin a chance to
20
- * remove its CRDT observer.
64
+ * remove its CRDT observer. Implementations are expected to cascade
65
+ * into every `TextBinding` they issued.
21
66
  *
22
67
  * `unknown` payloads keep pilotiq core Yjs-free; the binding owns its
23
68
  * own type knowledge. Same posture as `CollabExtensionFactory`.
@@ -29,6 +74,12 @@ export interface FormCollabBinding {
29
74
  set(name: string, value: unknown): void
30
75
  /** Subscribe to remote changes. Returns an unsubscribe function. */
31
76
  subscribe(fn: (snapshot: Record<string, unknown>) => void): () => void
77
+ /** Phase F.6 — per-field text-CRDT handle. Returns `null` for non-text
78
+ * fields or text fields opted out via `.collab(false)`. Optional so
79
+ * existing F1-era plugins keep type-checking without a no-op stub;
80
+ * when absent, every text field stays on today's whole-string LWW
81
+ * path (i.e. F.6 character-level CRDT is opt-in by impl). */
82
+ getTextBinding?(name: string): TextBinding | null
32
83
  /** Cleanup hook called when the form unmounts. */
33
84
  destroy(): void
34
85
  }
@@ -51,6 +102,17 @@ export interface FormCollabBindingFactoryArgs {
51
102
  * map already populated and skip.
52
103
  */
53
104
  initial: Record<string, unknown>
105
+ /**
106
+ * Phase F.6 — initial form meta from the server. The binding walks
107
+ * this once at construction to decide which fields are text-shaped
108
+ * (`fieldType ∈ { text, textarea, email, slug, markdown }`) and
109
+ * which have opted out via `.collab(false)`. Text fields get a
110
+ * dedicated `Y.Text` and route through `getTextBinding`; non-text
111
+ * fields stay on the `Y.Map`. The meta is captured at mount; later
112
+ * structural changes from `live()` re-resolves aren't re-walked
113
+ * (rare in practice — dynamic field add/remove is an F-followup).
114
+ */
115
+ formMeta: ElementMeta
54
116
  }
55
117
 
56
118
  export type FormCollabBindingFactory = (args: FormCollabBindingFactoryArgs) => FormCollabBinding