@voltro/cli 0.53.0 → 0.54.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 (181) hide show
  1. package/CHANGELOG.md +195 -0
  2. package/dist/{agentsMd-0l980yhL.js → agentsMd-BaLC10Na.js} +110 -82
  3. package/dist/agentsMd-DCY1RSs8.js +2 -0
  4. package/dist/apiBuild-CeUN55uk.js +2 -0
  5. package/dist/{apiBuild-CaPfoWku.js → apiBuild-DTWp0S_q.js} +2 -2
  6. package/dist/bin.js +1 -1
  7. package/dist/{build-D-OnvNMf.js → build-D4ygSbnV.js} +114 -114
  8. package/dist/{checkCommand-C5elt0tW.js → checkCommand-Dg1G7Gwd.js} +6 -6
  9. package/dist/{checkCommand-D2ZduVlh.js → checkCommand-L7DTlpIF.js} +1 -1
  10. package/dist/{cloudCmd-QUXh-b5w.js → cloudCmd-DzKcSYuy.js} +1 -1
  11. package/dist/{codegen-FEk8AZHb.js → codegen-DSLM8Su9.js} +2 -2
  12. package/dist/codegen-DjgxEOnD.js +2 -0
  13. package/dist/codegenCommand-CG_Vx4lc.js +41 -0
  14. package/dist/{codemodRunner-BjtB2lq6.js → codemodRunner-Cd4xkC6u.js} +9 -9
  15. package/dist/{commands-DyxAmhP0.js → commands-6Kzi92Np.js} +96 -73
  16. package/dist/{dashboardCommand-BdKTyT13.js → dashboardCommand-Cq1PWvI1.js} +3 -3
  17. package/dist/{dataCommand-Bab9X7s8.js → dataCommand-DYzW8vkv.js} +3 -3
  18. package/dist/{dbCommand-06O2finM.js → dbCommand-B4NWZtGL.js} +3 -3
  19. package/dist/dbCommand-CSFWs9ev.js +2 -0
  20. package/dist/{dev-C6LGF4iY.js → dev-CmuvUKRq.js} +2236 -2219
  21. package/dist/{dev-GjJWAYo2.js → dev-cKUiZZsB.js} +1 -1
  22. package/dist/{doctorCommand-etMkflRc.js → doctorCommand-DCiFVMtZ.js} +21 -21
  23. package/dist/doctorCommand-J3qu4E0Y.js +2 -0
  24. package/dist/{dormancyCommand-UwZ1AZzB.js → dormancyCommand-w1TrmgYP.js} +1 -1
  25. package/dist/{embeddingsCommand-C70zWHwo.js → embeddingsCommand-CMgPyRTr.js} +1 -1
  26. package/dist/{envCommand-dSyKvRkM.js → envCommand-Cyynmcfa.js} +15 -15
  27. package/dist/{evolveCommand-CG0_ebO5.js → evolveCommand-BwvQ8dVH.js} +2 -2
  28. package/dist/fileConventions-l-RIXbx8.js +36 -0
  29. package/dist/{fileTaxonomy-B7uxipWS.js → fileTaxonomy-CbyMQYx_.js} +37 -37
  30. package/dist/{frameworkTableAssembly-DKx3ba3S.js → frameworkTableAssembly-D7LJuALW.js} +5 -5
  31. package/dist/frameworkTableAssembly-IPD1pUnZ.js +2 -0
  32. package/dist/index.js +2 -2
  33. package/dist/{infoCommand-_53iOc_j.js → infoCommand-DlYlUPqs.js} +1 -1
  34. package/dist/{metaCommands-Cn2oboG4.js → metaCommands-x7RCi2AF.js} +2 -2
  35. package/dist/{migrate-Cko9rswM.js → migrate-BK_Bbx-_.js} +2 -2
  36. package/dist/mobileCommand-D9O6iq3D.js +428 -0
  37. package/dist/mobileCommand-DAum7tsG.js +2 -0
  38. package/dist/{pageConvention-C938S8oC.js → pageConvention-CMpfDN6r.js} +1 -1
  39. package/dist/{privacyCommand-DWTQMC6R.js → privacyCommand-BCa2OoZG.js} +2 -2
  40. package/dist/{probeCommand-DkGGLknv.js → probeCommand-_C0YU207.js} +1 -1
  41. package/dist/{projectScaffold-EzlErR4E.js → projectScaffold-CJfP-xbT.js} +1 -1
  42. package/dist/{projectScaffold-B4dmTlwT.js → projectScaffold-CSN0OzBV.js} +2 -2
  43. package/dist/renderModeScan-43yQ2opo.js +147 -0
  44. package/dist/{renderProfile-CskIgAfn.js → renderProfile-DvrhVJHa.js} +2 -2
  45. package/dist/{runtimeTrace-c0APJz7E.js → runtimeTrace-CGWx1Q6l.js} +1 -1
  46. package/dist/{sdkgen-BiQCgIEr.js → sdkgen-CDGHQUFj.js} +1 -1
  47. package/dist/serveCommand-BiPe8BJm.js +2 -0
  48. package/dist/{serveCommand-CueKQgzl.js → serveCommand-Bje09q1v.js} +708 -708
  49. package/dist/serveEntry.js +1 -1
  50. package/dist/start-B0bnJgxI.js +3 -0
  51. package/dist/{start-ekPan8BT.js → start-Clz-1BHB.js} +511 -504
  52. package/dist/startEntry.js +1 -1
  53. package/dist/{staticCommand-xlSL-IWk.js → staticCommand-ey0kYmOT.js} +1 -1
  54. package/dist/{subcommandNames-DpYs3DXr.js → subcommandNames-CDzfEtKV.js} +3 -3
  55. package/dist/{templates-BR-fb4SP.js → templates-BTWZkJJT.js} +41 -9
  56. package/dist/{test-BWPQcRoB.js → test-D_kW4KMj.js} +1 -1
  57. package/dist/updateCommand-CIoVDKnj.js +2 -0
  58. package/dist/{updateCommand-C_8I8Rzo.js → updateCommand-CRJlAOaM.js} +1 -1
  59. package/dist/{webDev-oczpugbx.js → webDev-DSI9SOhs.js} +1127 -1090
  60. package/dist/{webDev-C7jWJ5dX.js → webDev-DlvZO30c.js} +1 -1
  61. package/dist/{webhooksCommand-4SVPDjKg.js → webhooksCommand-BvzXNHji.js} +1 -1
  62. package/dist/workspaceDeps-RKEkX92S.js +45 -0
  63. package/package.json +19 -19
  64. package/templates/AGENTS.core.md +2 -0
  65. package/templates/AGENTS.md +4 -2
  66. package/templates/agent-docs/_index.md +2 -2
  67. package/templates/agent-docs/_manifest.json +1 -1
  68. package/templates/agent-docs/ai.md +4 -4
  69. package/templates/agent-docs/authentication.md +72 -0
  70. package/templates/agent-docs/cli.md +4 -1
  71. package/templates/agent-docs/data.md +61 -12
  72. package/templates/agent-docs/database/advancedqueries.md +1 -1
  73. package/templates/agent-docs/database/migrations.md +1 -1
  74. package/templates/agent-docs/database/seedsdialects.md +64 -2
  75. package/templates/agent-docs/internationalization.md +2 -0
  76. package/templates/agent-docs/introduction.md +25 -0
  77. package/templates/agent-docs/local-first-mobile.md +139 -41
  78. package/templates/agent-docs/observability.md +4 -2
  79. package/templates/agent-docs/plugins/atlassian.md +2 -2
  80. package/templates/agent-docs/plugins/audit.md +2 -2
  81. package/templates/agent-docs/plugins/billing.md +1 -1
  82. package/templates/agent-docs/plugins/cdc-out.md +8 -3
  83. package/templates/agent-docs/plugins/comments.md +22 -0
  84. package/templates/agent-docs/plugins/presence.md +32 -3
  85. package/templates/agent-docs/plugins/prometheus.md +2 -0
  86. package/templates/agent-docs/plugins/queue.md +47 -4
  87. package/templates/agent-docs/plugins.md +6 -6
  88. package/templates/agent-docs/reference.md +20 -1
  89. package/templates/agent-docs/routing.md +63 -9
  90. package/templates/agent-docs/scheduling.md +1 -1
  91. package/templates/agent-docs/schema-driven-ui.md +12 -1
  92. package/templates/agent-docs/templates/appshells.md +36 -4
  93. package/templates/agent-docs/whats-new.md +109 -135
  94. package/templates/apps/api-ai/package.json +6 -6
  95. package/templates/apps/api-auth/package.json +8 -8
  96. package/templates/apps/api-backend/package.json +7 -7
  97. package/templates/apps/api-backend-deactivation/package.json +7 -7
  98. package/templates/apps/api-backend-mail/package.json +8 -8
  99. package/templates/apps/api-backend-mariadb/package.json +9 -9
  100. package/templates/apps/api-backend-sqlite/package.json +8 -8
  101. package/templates/apps/api-backend-storage/package.json +8 -8
  102. package/templates/apps/api-cms/package.json +9 -9
  103. package/templates/apps/api-collab/README.md +3 -3
  104. package/templates/apps/api-collab/app.config.ts +1 -1
  105. package/templates/apps/api-collab/database/schema.ts +12 -8
  106. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +1 -1
  107. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +1 -1
  108. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +1 -1
  109. package/templates/apps/api-collab/package.json +8 -8
  110. package/templates/apps/api-collab/template.json +1 -1
  111. package/templates/apps/api-collab/tests/documents.setBody.test.ts +10 -2
  112. package/templates/apps/api-data-advanced/package.json +8 -8
  113. package/templates/apps/api-durable/package.json +8 -8
  114. package/templates/apps/api-feature-flags/package.json +9 -9
  115. package/templates/apps/api-governance/package.json +8 -8
  116. package/templates/apps/api-kv/package.json +8 -8
  117. package/templates/apps/api-moderation/package.json +8 -8
  118. package/templates/apps/api-observability/package.json +8 -8
  119. package/templates/apps/api-ratelimit/package.json +8 -8
  120. package/templates/apps/api-rbac/package.json +8 -8
  121. package/templates/apps/api-rest/package.json +7 -7
  122. package/templates/apps/api-row-history/package.json +8 -8
  123. package/templates/apps/api-saas/package.json +11 -10
  124. package/templates/apps/api-saas-starter/package.json +10 -10
  125. package/templates/apps/api-search/package.json +8 -8
  126. package/templates/apps/api-status/package.json +8 -8
  127. package/templates/apps/api-webhooks/package.json +9 -9
  128. package/templates/apps/changelog/package.json +7 -6
  129. package/templates/apps/edge-functions/package.json +2 -2
  130. package/templates/apps/frontend-admin/package.json +8 -8
  131. package/templates/apps/frontend-app/package.json +9 -9
  132. package/templates/apps/frontend-auth/package.json +8 -8
  133. package/templates/apps/frontend-blank/package.json +7 -7
  134. package/templates/apps/frontend-cms/package.json +9 -9
  135. package/templates/apps/frontend-collab/README.md +43 -24
  136. package/templates/apps/frontend-collab/app.config.ts +3 -3
  137. package/templates/apps/frontend-collab/package.json +14 -10
  138. package/templates/apps/frontend-collab/src/locales/de.ts +1 -2
  139. package/templates/apps/frontend-collab/src/locales/en.ts +1 -2
  140. package/templates/apps/frontend-collab/src/pages/page.test.tsx +72 -76
  141. package/templates/apps/frontend-collab/src/pages/page.tsx +45 -22
  142. package/templates/apps/frontend-collab/template.json +2 -2
  143. package/templates/apps/frontend-contact/package.json +7 -7
  144. package/templates/apps/frontend-dashboard/package.json +7 -7
  145. package/templates/apps/frontend-docs/package.json +8 -7
  146. package/templates/apps/frontend-i18n/package.json +6 -6
  147. package/templates/apps/frontend-landing/README.md +48 -0
  148. package/templates/apps/frontend-landing/app.config.ts +28 -0
  149. package/templates/apps/frontend-landing/package.json +7 -6
  150. package/templates/apps/frontend-landing/src/assets/hero.jpg +0 -0
  151. package/templates/apps/frontend-landing/src/fonts/Geist-Variable.woff2 +0 -0
  152. package/templates/apps/frontend-landing/src/fonts/LICENSE-Geist.txt +92 -0
  153. package/templates/apps/frontend-landing/src/globals.css +15 -0
  154. package/templates/apps/frontend-landing/src/globals.d.ts +17 -0
  155. package/templates/apps/frontend-landing/src/locales/de.ts +3 -2
  156. package/templates/apps/frontend-landing/src/locales/en.ts +5 -2
  157. package/templates/apps/frontend-landing/src/pages/page.test.tsx +79 -0
  158. package/templates/apps/frontend-landing/src/pages/page.tsx +26 -3
  159. package/templates/apps/frontend-landing/template.json +2 -2
  160. package/templates/apps/frontend-portal/package.json +8 -8
  161. package/templates/apps/frontend-saas/package.json +8 -8
  162. package/templates/apps/frontend-spa/package.json +7 -7
  163. package/templates/apps/frontend-ssr/package.json +7 -7
  164. package/templates/apps/frontend-ssr-api/package.json +8 -8
  165. package/templates/apps/frontend-static-blog/package.json +8 -7
  166. package/templates/apps/frontend-status/package.json +8 -8
  167. package/templates/apps/mobile-app/package.json +12 -11
  168. package/templates/apps/mobile-app/src/lib/deeplinks.ts +29 -17
  169. package/templates/apps/mobile-app/tests/deeplinks.test.ts +16 -0
  170. package/dist/agentsMd-SDDSkyl4.js +0 -2
  171. package/dist/apiBuild-DHtLXYx9.js +0 -2
  172. package/dist/codegen-BWpt3VgF.js +0 -2
  173. package/dist/codegenCommand-BOiWQ5hz.js +0 -137
  174. package/dist/dbCommand-B1EXBC6f.js +0 -2
  175. package/dist/doctorCommand-B0hX0tdz.js +0 -2
  176. package/dist/fileConventions-DASGEmj-.js +0 -35
  177. package/dist/frameworkTableAssembly-C_7Z-rMs.js +0 -2
  178. package/dist/renderModeScan-CUbOeOAg.js +0 -122
  179. package/dist/serveCommand-DsnrVN3U.js +0 -2
  180. package/dist/start-BJzZLbt8.js +0 -3
  181. package/dist/updateCommand-Bqql_rsQ.js +0 -2
@@ -3,31 +3,37 @@
3
3
  // `/` — the collaborative editor, driven through `makeVoltroTestClient`
4
4
  // (`@voltro/testing/client`) rather than a hand-rolled `vi.mock('@voltro/client')`.
5
5
  //
6
- // WHY THE HARNESS HERE and not a mock. This page is stateful: `useCrdtText`
7
- // owns a sync client per document cell, seeded from the server body and
8
- // folding every INCOMING body back in. A `vi.mock` can return one snapshot per
9
- // mount, so the fold — the whole point of the page — was untestable, and the
10
- // suite that replaced this one never pushed a second value into a mounted
11
- // tree. `setSubscription` is a real server delta, so the last case below
12
- // asserts convergence.
6
+ // WHY THE HARNESS HERE and not a mock. This page is stateful: `useCrdtDoc` owns
7
+ // a sync client AND a live CRDT document per cell, seeded from the server body
8
+ // and folding every INCOMING body back in. A `vi.mock` can return one snapshot
9
+ // per mount, so the fold — the whole point of the page — would be untestable.
10
+ // `setSubscription` is a real server delta, so the convergence case below is
11
+ // the real thing.
13
12
  //
14
- // Two other things fall out of it: the real `@voltro/client` hooks run (so a
15
- // change in their loading/error semantics is caught here instead of being
16
- // re-implemented by the mock), and the page can be imported statically —
17
- // `await import('./page')` only existed to sequence around `vi.mock`'s hoist.
13
+ // `@voltro/local-first` is NOT faked either: the real `useCrdtDoc` +
14
+ // `useCrdtEditor` run, so what mounts is a real Tiptap editor over a real Y.Doc.
18
15
  //
19
- // `@voltro/local-first` is NOT faked either: the real `useCrdtText` hook
20
- // decodes the seeded body and turns keystrokes into encoded updates (via its
21
- // `crdtTextEdit` span diff), which is the behaviour worth asserting.
16
+ // THE BOUNDARY, stated rather than hidden: these cases drive the page from the
17
+ // SERVER side (rows arriving) and from the user's side only as far as clicking.
18
+ // They do not type into ProseMirror — reaching the mounted editor instance from
19
+ // outside the component would mean poking at ProseMirror internals, and a test
20
+ // that does that asserts the editor library, not this page. The local-edit push
21
+ // path and its echo guard are covered where they live, against the hook:
22
+ // `useCrdtDoc.test.tsx` in `@voltro/local-first`. What IS asserted here is the
23
+ // half a page can get wrong on its own — that a streamed body reaches the
24
+ // editor, and that folding one produces no write back.
22
25
 
23
26
  import { afterEach, beforeEach, describe, expect, test } from 'vitest'
24
27
  import { act, createElement, type ReactNode } from 'react'
25
28
  import { createRoot, type Root } from 'react-dom/client'
29
+ import { Editor } from '@tiptap/react'
30
+ import StarterKit from '@tiptap/starter-kit'
31
+ import Collaboration from '@tiptap/extension-collaboration'
26
32
  import { I18nProvider } from '@voltro/i18n'
27
- import { crdtText, type CrdtState } from '@voltro/local-first'
33
+ import { defaultCrdtBackend, type CrdtState } from '@voltro/local-first'
28
34
  import { makeVoltroTestClient, type VoltroTestClient } from '@voltro/testing/client'
29
35
  import enCatalog from '../locales/en'
30
- import IndexPage from './page'
36
+ import IndexPage, { renderMode } from './page'
31
37
 
32
38
  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
33
39
 
@@ -37,13 +43,29 @@ let container: HTMLDivElement
37
43
  let root: Root
38
44
  let client: VoltroTestClient
39
45
 
40
- /** A CRDT state holding `text`, from a fresh document. */
41
- const bodyWith = (text: string): CrdtState => crdtText().insert(0, text).encode()
42
-
43
- /** A REMOTE edit derived from `base`, so merging it back converges instead of
44
- * interleaving two independently-created runs of the same characters. */
45
- const remoteEditOf = (base: CrdtState, at: number, insert: string): CrdtState =>
46
- crdtText().merge(base).insert(at, insert).encode()
46
+ /**
47
+ * An authoritative body holding `html`, authored the way the app authors one:
48
+ * through a Tiptap editor over a CRDT document. It has to be — the editor reads
49
+ * the document's XML fragment, so a state written by a plain text handle would
50
+ * decode fine and render as an EMPTY editor.
51
+ *
52
+ * `base` continues an existing document, which is what makes a second call
53
+ * converge with the first instead of interleaving two independent runs of the
54
+ * same characters.
55
+ */
56
+ const bodyWith = (html: string, base?: CrdtState): CrdtState => {
57
+ const handle = defaultCrdtBackend.createDoc(base)
58
+ const editor = new Editor({
59
+ extensions: [
60
+ StarterKit.configure({ undoRedo: false }),
61
+ Collaboration.configure({ document: handle.raw as never }),
62
+ ],
63
+ })
64
+ editor.commands.setContent(html)
65
+ const state = handle.encodeState()
66
+ editor.destroy()
67
+ return state
68
+ }
47
69
 
48
70
  const docRow = (over: Partial<{ id: string; title: string; body: CrdtState | null }> = {}) => ({
49
71
  id: over.id ?? 'doc_1',
@@ -69,15 +91,8 @@ const render = (): void => {
69
91
  })
70
92
  }
71
93
 
72
- const setTextarea = (el: HTMLTextAreaElement, value: string): void => {
73
- const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!
74
- act(() => {
75
- setter.call(el, value)
76
- el.dispatchEvent(new Event('input', { bubbles: true }))
77
- })
78
- }
79
-
80
- const textarea = (): HTMLTextAreaElement => container.querySelector('textarea') as HTMLTextAreaElement
94
+ /** The editor's contenteditable surface, once `useCrdtDoc` has built its doc. */
95
+ const surface = (): HTMLElement => container.querySelector('.ProseMirror') as HTMLElement
81
96
 
82
97
  /** The harness with both writes wired. Handlers return void; the assertions
83
98
  * read `client.calls`, which records tag + input in order. */
@@ -98,13 +113,22 @@ afterEach(() => {
98
113
  document.body.innerHTML = ''
99
114
  })
100
115
 
116
+ describe('collab editor — the page contract', () => {
117
+ test('renders in the browser only — a rich-text editor cannot be prerendered', () => {
118
+ // Pinned because the failure is a BUILD failure with an unhelpful message:
119
+ // `renderMode` defaults to 'static', and Tiptap's `new Editor()` has no
120
+ // `window` during prerender.
121
+ expect(renderMode).toBe('client')
122
+ })
123
+ })
124
+
101
125
  describe('collab editor — subscription states', () => {
102
126
  test('shows the loading hint before the first snapshot', () => {
103
127
  // A tag with NO fixture is the harness's cold state — the real thing a
104
128
  // component sees before the first delta arrives.
105
129
  render()
106
130
  expect(container.textContent).toContain('loading…')
107
- expect(container.querySelector('textarea')).toBeNull()
131
+ expect(surface()).toBeNull()
108
132
  })
109
133
 
110
134
  test('shows the empty state + create button once a snapshot arrives with no documents', () => {
@@ -112,7 +136,7 @@ describe('collab editor — subscription states', () => {
112
136
  render()
113
137
  expect(container.textContent).toContain('No document yet')
114
138
  expect(container.querySelector('button')).not.toBeNull()
115
- expect(container.querySelector('textarea')).toBeNull()
139
+ expect(surface()).toBeNull()
116
140
  })
117
141
 
118
142
  test('renders the error banner when the subscription errors', () => {
@@ -138,58 +162,30 @@ describe('collab editor — create', () => {
138
162
  })
139
163
 
140
164
  describe('collab editor — editing a document', () => {
141
- test('binds the textarea to the decoded CRDT body', () => {
142
- client = harness({ [LIST]: [docRow({ body: bodyWith('Hello') })] })
165
+ test('mounts an editable surface bound to the streamed CRDT body', () => {
166
+ client = harness({ [LIST]: [docRow({ body: bodyWith('<p>Hello</p>') })] })
143
167
  render()
144
- expect(textarea()).not.toBeNull()
145
- expect(textarea().value).toBe('Hello')
168
+ expect(surface()).not.toBeNull()
169
+ expect(surface().getAttribute('contenteditable')).toBe('true')
170
+ expect(surface().textContent).toContain('Hello')
146
171
  expect(container.textContent).toContain('Shared document')
147
172
  })
148
173
 
149
- test('typing sends an encoded CRDT update via documents.setBody', () => {
150
- client = harness({ [LIST]: [docRow({ body: bodyWith('Hello') })] })
151
- render()
152
- setTextarea(textarea(), 'Hello!')
153
-
154
- expect(client.calls).toHaveLength(1)
155
- const call = client.calls[0]!
156
- expect(call.tag).toBe('documents.setBody')
157
- const input = call.input as { id: string; update: unknown }
158
- expect(input.id).toBe('doc_1')
159
- expect(input.update).toBeInstanceOf(Uint8Array)
160
- })
161
-
162
- // The case the hand-rolled mock could not express, and the page's whole
163
- // reason to exist: a body edited in ANOTHER tab arrives as a delta, and the
164
- // local handle folds it in without remounting the editor.
165
- test('a remote edit pushed by the server converges into the open editor', () => {
166
- const base = bodyWith('Hello')
174
+ // The page's whole reason to exist: a body edited in ANOTHER tab arrives as a
175
+ // delta and folds into the OPEN editor without remounting it.
176
+ test('a remote edit pushed by the server converges into the open editor, and is not echoed back', () => {
177
+ const base = bodyWith('<p>Hello</p>')
167
178
  client = harness({ [LIST]: [docRow({ body: base })] })
168
179
  render()
169
- expect(textarea().value).toBe('Hello')
180
+ expect(surface().textContent).toContain('Hello')
170
181
 
171
182
  act(() => {
172
- client.setSubscription(LIST, [docRow({ body: remoteEditOf(base, 5, ' world') })])
183
+ client.setSubscription(LIST, [docRow({ body: bodyWith('<p>Hello world</p>', base) })])
173
184
  })
174
185
 
175
- expect(textarea().value).toBe('Hello world')
176
- // A fold is not a write nothing is echoed back to the server.
186
+ expect(surface().textContent).toContain('Hello world')
187
+ // A fold is NOT a write. Without the hook's echo guard this is one
188
+ // `documents.setBody` per fold, per open tab.
177
189
  expect(client.calls).toEqual([])
178
190
  })
179
-
180
- test('a local edit survives a concurrent remote edit — both are in the merged text', () => {
181
- const base = bodyWith('Hello')
182
- client = harness({ [LIST]: [docRow({ body: base })] })
183
- render()
184
- setTextarea(textarea(), 'Hello!')
185
- expect(textarea().value).toBe('Hello!')
186
-
187
- // The other tab branched from the SAME base and appended elsewhere.
188
- act(() => {
189
- client.setSubscription(LIST, [docRow({ body: remoteEditOf(base, 0, 'Oh, ') })])
190
- })
191
-
192
- expect(textarea().value).toContain('Oh, ')
193
- expect(textarea().value).toContain('!')
194
- })
195
191
  })
@@ -2,14 +2,15 @@
2
2
  // by the framework's file-convention page discovery.
3
3
  //
4
4
  // THIS is local-first collaboration over the reactive loop. The api's
5
- // `documents` table has a `crdtText()` `body`; this page binds ONE
6
- // `useCrdtText` cell (`@voltro/local-first/react`) to it:
5
+ // `documents` table has a `crdtDoc()` `body`; this page binds ONE `useCrdtDoc`
6
+ // cell (`@voltro/local-first/react`) to it and hands the resulting document to
7
+ // `useCrdtEditor` (`@voltro/local-first/editor`), the Tiptap binding:
7
8
  // 1. `useSubscription('app','documents.list')` — streams the shared document
8
9
  // (with its MERGED body) live over WebSocket; the streamed body is the
9
- // hook's `remote`, folded into the local CRDT on every change,
10
- // 2. `useMutation('app','documents.setBody')` is the hook's `push` — every
11
- // keystroke goes through `setText`, which diffs to a minimal single-span
12
- // edit (the framework's `crdtTextEdit`) and queues the encoded update.
10
+ // hook's `remote`, folded into the local CRDT document on every change,
11
+ // 2. `useMutation('app','documents.setBody')` is the hook's `push` — the
12
+ // editor's own edits surface as `onUpdate` callbacks and are queued as
13
+ // INCREMENTAL updates (a keystroke is bytes, not the whole document).
13
14
  //
14
15
  // The convergence is authoritative and SERVER-SIDE: the api merges each update
15
16
  // into the stored state before broadcasting, so two tabs typing at once both
@@ -17,17 +18,28 @@
17
18
  //
18
19
  // This is the app-level binding the local-first docs describe — the two names
19
20
  // nothing can derive: `push` = the setBody mutation, `remote` = the documents
20
- // subscription. Everything else (sync client lifecycle, offline queue, edit
21
- // encoding) lives in the hook.
21
+ // subscription. Everything else (sync-client lifecycle, offline queue, retry,
22
+ // and the ECHO GUARD that keeps a folded remote update from being pushed back)
23
+ // lives in the hook.
22
24
 
23
25
  import { useMutation, useSubscription } from '@voltro/client'
24
26
  import { useMemo, type ReactNode } from 'react'
25
27
  import type { PageMeta } from '@voltro/web'
26
- import { T, useTFn } from '@voltro/i18n'
27
- import type { CrdtState } from '@voltro/local-first'
28
- import { useCrdtText } from '@voltro/local-first/react'
28
+ import { EditorContent } from '@tiptap/react'
29
+ import { T } from '@voltro/i18n'
30
+ import type { CrdtDocHandle, CrdtState } from '@voltro/local-first'
31
+ import { useCrdtDoc } from '@voltro/local-first/react'
32
+ import { useCrdtEditor } from '@voltro/local-first/editor'
29
33
  import { getCatalog } from '../locales'
30
34
 
35
+ // A rich-text editor needs a BROWSER. `renderMode` defaults to `'static'`,
36
+ // which pre-renders this page at build time — and Tiptap's `new Editor()` has
37
+ // no `window` there, so the build fails outright. `'client'` renders the page
38
+ // in the browser only, which is what a collaborative editor wants anyway: there
39
+ // is nothing meaningful to pre-render for a document that arrives over a live
40
+ // subscription.
41
+ export const renderMode = 'client' as const
42
+
31
43
  export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
32
44
  title: getCatalog(locale)['meta.editor.title'],
33
45
  })
@@ -51,6 +63,20 @@ interface DocRow {
51
63
  const base64ToBytes = (b64: string): Uint8Array =>
52
64
  Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0))
53
65
 
66
+ // The Tiptap surface, in its OWN component. `useCrdtDoc` builds its document in
67
+ // an effect (never during render — that is what crashed a prerender and leaked
68
+ // an editor under StrictMode), so `doc` is `null` on the first pass. Splitting
69
+ // the editor out keeps `useCrdtEditor` an unconditional hook call instead of one
70
+ // guarded by `doc !== null`.
71
+ const RichTextSurface = ({ doc }: { readonly doc: CrdtDocHandle }): ReactNode => {
72
+ const editor = useCrdtEditor({ doc })
73
+ return (
74
+ <div style={{ border: '1px solid #ddd', borderRadius: 6, padding: '10px 12px', minHeight: 220, fontSize: 14 }}>
75
+ <EditorContent editor={editor} />
76
+ </div>
77
+ )
78
+ }
79
+
54
80
  // One editor bound to ONE document. Remounted (via `key={doc.id}`) when the
55
81
  // bound document changes, so the hook's sync-client lifecycle is scoped to it.
56
82
  const DocumentEditor = ({
@@ -62,9 +88,8 @@ const DocumentEditor = ({
62
88
  readonly saving: boolean
63
89
  readonly onUpdate: (update: CrdtState) => Promise<unknown>
64
90
  }): ReactNode => {
65
- const t = useTFn()
66
91
  // The authoritative state as the subscription streams it. `null` while the
67
- // body is absent — useCrdtText folds nothing then, NOT an empty document.
92
+ // body is absent — useCrdtDoc folds nothing then, NOT an empty document.
68
93
  const remote = useMemo(
69
94
  () =>
70
95
  doc.body instanceof Uint8Array
@@ -74,7 +99,7 @@ const DocumentEditor = ({
74
99
  : null,
75
100
  [doc.body],
76
101
  )
77
- const body = useCrdtText({
102
+ const shared = useCrdtDoc({
78
103
  cell: { table: 'documents', id: doc.id, column: 'body' },
79
104
  remote,
80
105
  // A rejected push re-drives the queued write; log it and keep the rejection.
@@ -89,15 +114,13 @@ const DocumentEditor = ({
89
114
  <section>
90
115
  <p style={{ color: '#666', fontSize: 13, margin: '0 0 8px' }}>
91
116
  <T id="editor.docLabel" values={{ title: doc.title, strong: (c: ReactNode) => <strong>{c}</strong> }} />
92
- {saving ? <T id="editor.status.saving" /> : null}
117
+ {saving || !shared.synced ? <T id="editor.status.saving" /> : null}
93
118
  </p>
94
- <textarea
95
- value={body.text}
96
- onChange={(event) => body.setText(event.target.value)}
97
- placeholder={t('editor.placeholder')}
98
- rows={12}
99
- style={{ width: '100%', padding: '10px 12px', fontSize: 14, fontFamily: 'ui-monospace, monospace', resize: 'vertical', boxSizing: 'border-box' }}
100
- />
119
+ {shared.doc !== null ? (
120
+ <RichTextSurface doc={shared.doc} />
121
+ ) : (
122
+ <p style={{ color: '#999', fontSize: 13 }}><T id="editor.status.loading" /></p>
123
+ )}
101
124
  <p style={{ color: '#999', fontSize: 12, marginTop: 8 }}><T id="editor.hint" /></p>
102
125
  </section>
103
126
  )
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "frontend-collab",
3
3
  "kind": "web",
4
- "summary": "A collaborative document editor — a textarea bound to a `crdtText()` body over the reactive engine. Type in two browser tabs at once and the edits CONVERGE (server-authoritative CRDT merge), no last-write-wins loser. Pairs with api-collab: scaffold both with `create-project --api=api-collab --web=frontend-collab`.",
5
- "tags": ["web", "frontend", "fullstack", "local-first", "crdt", "collaborative", "realtime", "reactive", "api-collab"]
4
+ "summary": "A collaborative rich-text editor — Tiptap (`useCrdtEditor`) bound to a `crdtDoc()` body through `useCrdtDoc` over the reactive engine. Type in two browser tabs at once and the edits CONVERGE (server-authoritative CRDT merge), no last-write-wins loser. Pairs with api-collab: scaffold both with `create-project --api=api-collab --web=frontend-collab`.",
5
+ "tags": ["web", "frontend", "fullstack", "local-first", "crdt", "collaborative", "realtime", "reactive", "rich-text", "tiptap", "api-collab"]
6
6
  }
@@ -15,18 +15,18 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@effect/platform": "^0.97.0",
18
- "@voltro/cli": "0.53.0",
19
- "@voltro/client": "0.53.0",
20
- "@voltro/env": "0.53.0",
21
- "@voltro/i18n": "0.53.0",
22
- "@voltro/serverless": "0.53.0",
23
- "@voltro/web": "0.53.0",
18
+ "@voltro/cli": "0.54.0",
19
+ "@voltro/client": "0.54.0",
20
+ "@voltro/env": "0.54.0",
21
+ "@voltro/i18n": "0.54.0",
22
+ "@voltro/serverless": "0.54.0",
23
+ "@voltro/web": "0.54.0",
24
24
  "effect": "^3.22.0",
25
25
  "react": "^19.0.0",
26
26
  "react-dom": "^19.0.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@voltro/devtools": "0.53.0",
29
+ "@voltro/devtools": "0.54.0",
30
30
  "@types/react": "^19.0.0",
31
31
  "@types/react-dom": "^19.0.0",
32
32
  "jsdom": "^30.0.0",
@@ -12,17 +12,17 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.53.0",
16
- "@voltro/client": "0.53.0",
17
- "@voltro/env": "0.53.0",
18
- "@voltro/i18n": "0.53.0",
19
- "@voltro/ui-shadcn": "0.53.0",
20
- "@voltro/web": "0.53.0",
15
+ "@voltro/cli": "0.54.0",
16
+ "@voltro/client": "0.54.0",
17
+ "@voltro/env": "0.54.0",
18
+ "@voltro/i18n": "0.54.0",
19
+ "@voltro/ui-shadcn": "0.54.0",
20
+ "@voltro/web": "0.54.0",
21
21
  "react": "^19.0.0",
22
22
  "react-dom": "^19.0.0"
23
23
  },
24
24
  "devDependencies": {
25
- "@voltro/devtools": "0.53.0",
25
+ "@voltro/devtools": "0.54.0",
26
26
  "@types/react": "^19.0.0",
27
27
  "@types/react-dom": "^19.0.0",
28
28
  "jsdom": "^30.0.0",
@@ -12,18 +12,19 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/content": "0.53.0",
15
+ "@voltro/cli": "0.54.0",
16
+ "@voltro/client": "0.54.0",
17
+ "@voltro/content": "0.54.0",
18
+ "@voltro/env": "0.54.0",
19
+ "@voltro/i18n": "0.54.0",
20
+ "@voltro/ui-shadcn": "0.54.0",
21
+ "@voltro/web": "0.54.0",
16
22
  "effect": "^3.22.0",
17
- "@voltro/cli": "0.53.0",
18
- "@voltro/client": "0.53.0",
19
- "@voltro/env": "0.53.0",
20
- "@voltro/i18n": "0.53.0",
21
- "@voltro/web": "0.53.0",
22
23
  "react": "^19.0.0",
23
24
  "react-dom": "^19.0.0"
24
25
  },
25
26
  "devDependencies": {
26
- "@voltro/devtools": "0.53.0",
27
+ "@voltro/devtools": "0.54.0",
27
28
  "@types/react": "^19.0.0",
28
29
  "@types/react-dom": "^19.0.0",
29
30
  "jsdom": "^30.0.0",
@@ -12,16 +12,16 @@
12
12
  "test": "voltro test"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.53.0",
16
- "@voltro/client": "0.53.0",
17
- "@voltro/env": "0.53.0",
18
- "@voltro/i18n": "0.53.0",
19
- "@voltro/web": "0.53.0",
15
+ "@voltro/cli": "0.54.0",
16
+ "@voltro/client": "0.54.0",
17
+ "@voltro/env": "0.54.0",
18
+ "@voltro/i18n": "0.54.0",
19
+ "@voltro/web": "0.54.0",
20
20
  "react": "^19.0.0",
21
21
  "react-dom": "^19.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@voltro/devtools": "0.53.0",
24
+ "@voltro/devtools": "0.54.0",
25
25
  "@types/react": "^19.0.0",
26
26
  "@types/react-dom": "^19.0.0",
27
27
  "jsdom": "^30.0.0",
@@ -15,3 +15,51 @@ pnpm --filter @{{projectName}}/{{appName}} dev
15
15
  When you need a contact form / sign-up flow, switch
16
16
  `interactive: 'islands'` on the page and mark the interactive
17
17
  component with the `*.island.tsx` suffix so only that bundle ships.
18
+
19
+ ## The hero image — `?image` + `<Image>`
20
+
21
+ `src/assets/hero.jpg` is imported with the **`?image` suffix**, which is the
22
+ explicit opt-in to the build-time pipeline: the import resolves to an optimized
23
+ **asset object** instead of vite's plain hashed URL, and `<Image src={hero}>`
24
+ renders it as a `<picture>` with one `<source>` per modern format.
25
+
26
+ - **`voltro build`** encodes AVIF + WebP + a jpeg fallback at every ladder width
27
+ up to the intrinsic 1200px into `dist/assets/` under content hashes, through a
28
+ persistent cache (`.framework/image-cache/`) — a rebuild re-encodes nothing.
29
+ - **`voltro dev`** transforms on demand from `/_voltro/image/<assetId>`.
30
+ - **`voltro start`** serves build artifacts only; there is deliberately no
31
+ production transform endpoint.
32
+
33
+ `width`, `height` and the blur placeholder are **not props** — they are read off
34
+ the asset, which is what reserves the box (CLS ≈ 0) without hand-written
35
+ numbers. `priority` marks it the LCP image (eager + high fetch priority).
36
+
37
+ Encoding runs on [sharp](https://sharp.pixelplumbing.com), an optional
38
+ dependency of `@voltro/cli` — nothing to install. If your installer omits
39
+ optional dependencies the pipeline serves the original with one loud warning
40
+ naming the fix; `voltro doctor` tells "not installed" from "binary missing".
41
+
42
+ A **dynamic** `src` (a URL from a loader or CMS frontmatter) cannot be seen at
43
+ build time — use the loader seam instead: `<Image src={url} loader={cdn} />`.
44
+
45
+ ## The font — self-hosted, no CDN
46
+
47
+ `app.config.ts` declares one family under `fonts:`, pointing at the woff2
48
+ committed in `src/fonts/`. The build content-hashes it and serves it from **your
49
+ origin**, emits `@font-face`, computes a **size-adjusted fallback face** from the
50
+ file's real metrics so the swap moves no text, and puts a `<link rel="preload">`
51
+ in the one shell every render mode serves. `subsets: ['latin']` ships only the
52
+ range the page's characters need.
53
+
54
+ Nothing talks to Google Fonts (or any font host) at runtime — the visitor's IP
55
+ never reaches a font CDN, because no request leaves your domain.
56
+
57
+ Reference the family from CSS via the `--font-geist` variable the shell defines
58
+ (`src/globals.css` points the kit's `--font-sans` at it), or from TSX via
59
+ `localFont('Geist')` — a pure name mapper, so the slug rule stays the
60
+ pipeline's.
61
+
62
+ **Swapping in your own face:** drop the `woff2` **and its licence file** into
63
+ `src/fonts/`, then change `family` + `path` in `app.config.ts`. The framework
64
+ ships no font downloader on purpose — licence terms differ per family. The
65
+ bundled Geist is SIL OFL 1.1 (see `src/fonts/LICENSE-Geist.txt`).
@@ -21,5 +21,33 @@ export default {
21
21
  // drives the [locale] mirror routing + the language switcher.
22
22
  locales: ['en', 'de'] as const,
23
23
  defaultLocale: 'en' as const,
24
+ // Self-hosted local font. The file is committed next to this config
25
+ // (src/fonts/) WITH its licence — Geist is SIL OFL 1.1 (Vercel /
26
+ // basement.studio), see src/fonts/LICENSE-Geist.txt. The framework ships no
27
+ // font DOWNLOADER on purpose (licence terms differ per family), so swapping
28
+ // in your own brand face is: drop the woff2 + its licence here, change
29
+ // `family` + `path`.
30
+ //
31
+ // What `voltro build` / `voltro dev` do with this: content-hash the file and
32
+ // serve it from YOUR origin (no font-CDN request ever leaves the browser —
33
+ // the GDPR answer), emit `@font-face` with `font-display`, compute a
34
+ // SIZE-ADJUSTED `'Geist Fallback'` face from the file's real metrics so the
35
+ // swap moves no text (CLS ≈ 0), and put a `<link rel="preload">` in the one
36
+ // generated shell every render mode serves.
37
+ //
38
+ // ONE variable face covers the whole 100–900 range, so the page makes a
39
+ // single font request. `subsets: ['latin']` (U+0000–00FF) covers both
40
+ // shipped locales — German umlauts and ß live inside that range.
41
+ //
42
+ // src/globals.css points the kit's `--font-sans` at the `--font-geist`
43
+ // variable the shell defines; src/pages/page.tsx reads the same variable
44
+ // from TSX via `localFont('Geist')`.
45
+ fonts: [{
46
+ family: 'Geist',
47
+ src: [{ path: 'src/fonts/Geist-Variable.woff2', weight: '100 900' }],
48
+ display: 'swap' as const,
49
+ subsets: ['latin'] as ReadonlyArray<'latin' | 'latin-ext'>,
50
+ fallback: 'Arial' as const,
51
+ }],
24
52
  env,
25
53
  }
@@ -12,16 +12,17 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@voltro/cli": "0.53.0",
16
- "@voltro/client": "0.53.0",
17
- "@voltro/env": "0.53.0",
18
- "@voltro/i18n": "0.53.0",
19
- "@voltro/web": "0.53.0",
15
+ "@voltro/cli": "0.54.0",
16
+ "@voltro/client": "0.54.0",
17
+ "@voltro/env": "0.54.0",
18
+ "@voltro/i18n": "0.54.0",
19
+ "@voltro/ui-shadcn": "0.54.0",
20
+ "@voltro/web": "0.54.0",
20
21
  "react": "^19.0.0",
21
22
  "react-dom": "^19.0.0"
22
23
  },
23
24
  "devDependencies": {
24
- "@voltro/devtools": "0.53.0",
25
+ "@voltro/devtools": "0.54.0",
25
26
  "@types/react": "^19.0.0",
26
27
  "@types/react-dom": "^19.0.0",
27
28
  "jsdom": "^30.0.0",