@cat-factory/app 0.247.0 → 0.249.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 (57) hide show
  1. package/app/components/board/AddTaskModal.vue +1 -0
  2. package/app/components/board/nodes/BlockNode.vue +24 -0
  3. package/app/components/context/ContextAttachmentFields.vue +102 -0
  4. package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
  5. package/app/components/context/pastedLinkOffer.logic.ts +50 -0
  6. package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
  7. package/app/components/documents/SpawnPreviewModal.vue +92 -16
  8. package/app/components/documents/StartFromDesignModal.vue +237 -0
  9. package/app/components/environments/EnvironmentSetupWizard.vue +1 -1
  10. package/app/components/environments/steps/JourneyStepNav.vue +1 -1
  11. package/app/components/layout/AccountDeploymentSettings.vue +122 -0
  12. package/app/components/layout/BoardToolbar.vue +1 -1
  13. package/app/components/layout/CommandBar.vue +1 -1
  14. package/app/components/layout/SideBar.vue +2 -2
  15. package/app/components/merge/MergeEffortChips.vue +1 -1
  16. package/app/components/panels/InspectorPanel.vue +1 -1
  17. package/app/components/panels/ResultWindowShell.vue +2 -2
  18. package/app/components/panels/StepResultViewHost.vue +1 -1
  19. package/app/components/panels/inspector/TaskRunSettings.vue +1 -1
  20. package/app/components/settings/MergeClassRulesEditor.vue +1 -1
  21. package/app/composables/api/documents.ts +17 -6
  22. package/app/composables/useDocumentSourceConnect.ts +88 -0
  23. package/app/modular/agent-kinds.ts +1 -1
  24. package/app/modular/external-tools.spec.ts +1 -0
  25. package/app/modular/journeys/environmentSetup.logic.ts +1 -1
  26. package/app/modular/journeys/environmentSetup.ts +1 -1
  27. package/app/modular/journeys/persistence.ts +1 -1
  28. package/app/modular/nav-contributions.spec.ts +2 -0
  29. package/app/modular/nav-contributions.ts +11 -1
  30. package/app/modular/nav-gates.ts +4 -0
  31. package/app/modular/panels/inspector.logic.ts +1 -1
  32. package/app/modular/registry.spec.ts +1 -0
  33. package/app/modular/registry.ts +1 -1
  34. package/app/modular/result-views.ts +1 -1
  35. package/app/modular/slots.ts +1 -1
  36. package/app/modular/tutorial-tours.spec.ts +5 -0
  37. package/app/modular/tutorial-tours.ts +77 -0
  38. package/app/pages/index.vue +4 -0
  39. package/app/plugins/modular.client.ts +1 -1
  40. package/app/stores/agents.ts +1 -1
  41. package/app/stores/documents.spec.ts +60 -0
  42. package/app/stores/documents.ts +52 -24
  43. package/app/stores/environmentWizard.ts +1 -1
  44. package/app/stores/publicApiKeys.spec.ts +1 -0
  45. package/app/stores/ui/modals.ts +15 -0
  46. package/app/utils/modular.ts +1 -1
  47. package/i18n/locales/de.json +82 -6
  48. package/i18n/locales/en.json +82 -6
  49. package/i18n/locales/es.json +82 -6
  50. package/i18n/locales/fr.json +82 -6
  51. package/i18n/locales/he.json +82 -6
  52. package/i18n/locales/it.json +82 -6
  53. package/i18n/locales/ja.json +82 -6
  54. package/i18n/locales/pl.json +82 -6
  55. package/i18n/locales/tr.json +82 -6
  56. package/i18n/locales/uk.json +82 -6
  57. package/package.json +2 -2
@@ -18,7 +18,7 @@ import {
18
18
 
19
19
  /**
20
20
  * The environment-setup journey — the slice-3 pilot of the modular-vue adoption
21
- * (docs/initiatives/modular-vue-adoption.md). It replaces the wizard's hand-rolled
21
+ * (backend/docs/adr/0049-modular-vue-adoption.md). It replaces the wizard's hand-rolled
22
22
  * `STEP_ORDER` + `step` ref + `goToStep` navigation (in `stores/environmentWizard.ts`)
23
23
  * with a typed, back/rewind-capable, resumable journey; the per-step data + async
24
24
  * actions stay in that Pinia store, driven by the step components below.
@@ -5,7 +5,7 @@ import { createPiniaJourneyPersistence } from '@modular-vue/journeys'
5
5
 
6
6
  /**
7
7
  * Pinia-backed journey persistence (slice 3 of the modular-vue adoption —
8
- * docs/initiatives/modular-vue-adoption.md).
8
+ * backend/docs/adr/0049-modular-vue-adoption.md).
9
9
  *
10
10
  * A journey's `persistence` adapter is what makes `runtime.start()` mean
11
11
  * RESUME: it probes `keyFor(input)` for an in-flight serialized instance and
@@ -21,6 +21,7 @@ const NO_GATES: NavGates = {
21
21
  canManageSettings: false,
22
22
  githubAvailable: false,
23
23
  libraryAvailable: false,
24
+ designSourceConnected: false,
24
25
  infrastructureAvailable: false,
25
26
  accountsEnabled: false,
26
27
  isAccountAdmin: false,
@@ -42,6 +43,7 @@ const ALL_GATES: NavGates = {
42
43
  canManageSettings: true,
43
44
  githubAvailable: true,
44
45
  libraryAvailable: true,
46
+ designSourceConnected: true,
45
47
  infrastructureAvailable: true,
46
48
  accountsEnabled: true,
47
49
  isAccountAdmin: true,
@@ -10,7 +10,7 @@ export type { AppSlots } from './slots'
10
10
 
11
11
  /**
12
12
  * The single nav/command catalog for the layer (slice 1 of the modular-vue
13
- * adoption — docs/initiatives/modular-vue-adoption.md).
13
+ * adoption — backend/docs/adr/0049-modular-vue-adoption.md).
14
14
  *
15
15
  * Every destination is declared ONCE here as data and rendered three ways —
16
16
  * `SideBar`, `CommandBar`, `BoardToolbar` — instead of each shell hand-rolling
@@ -89,6 +89,16 @@ export interface NavGates {
89
89
  githubAvailable: boolean
90
90
  /** The prompt-fragment library integration is enabled. */
91
91
  libraryAvailable: boolean
92
+ /**
93
+ * The board has a CONNECTED design source (Figma, Zeplin).
94
+ *
95
+ * Availability, not permission, like `githubAvailable`: the start-from-design affordance and
96
+ * the tour that walks it both point at a frame-header button that only exists once a design
97
+ * source is connected, so a board without one is offered a walkthrough that hunts for a
98
+ * control nobody can see. Connecting is `integrations.manage`, but STARTING from a design is
99
+ * member-tier, so the gate is the connection rather than the permission to make one.
100
+ */
101
+ designSourceConnected: boolean
92
102
  /** An execution/test-env backend is reported (runner pool / environment / local). */
93
103
  infrastructureAvailable: boolean
94
104
  /** Accounts (auth) are enabled on the deployment. */
@@ -22,6 +22,7 @@ export function createNavGates(): NavGates {
22
22
  const access = useWorkspaceAccess()
23
23
  const github = useGitHubStore()
24
24
  const library = useFragmentLibraryStore()
25
+ const documents = useDocumentsStore()
25
26
  const accounts = useAccountsStore()
26
27
  const auth = useAuthStore()
27
28
  const providerConnections = useProviderConnectionsStore()
@@ -90,6 +91,9 @@ export function createNavGates(): NavGates {
90
91
  get libraryAvailable() {
91
92
  return library.available === true
92
93
  },
94
+ get designSourceConnected() {
95
+ return documents.connectedDesignSources.length > 0
96
+ },
93
97
  get infrastructureAvailable() {
94
98
  // `integrations.manage` is required to provision/manage infrastructure, so
95
99
  // gate the whole section on it too (a member/viewer would only 403 inside).
@@ -3,7 +3,7 @@ import type { Block } from '~/types/domain'
3
3
 
4
4
  /**
5
5
  * Pure definition of the block-inspector panel group (slice 4 of the modular-vue
6
- * adoption — docs/initiatives/modular-vue-adoption.md).
6
+ * adoption — backend/docs/adr/0049-modular-vue-adoption.md).
7
7
  *
8
8
  * The inspector used to be a 631-line `v-if` fan in `InspectorPanel.vue` that
9
9
  * switched its body on the selected block's `level` (frame/module/task/epic/
@@ -9,6 +9,7 @@ const NO_GATES: NavGates = {
9
9
  canManageSettings: false,
10
10
  githubAvailable: false,
11
11
  libraryAvailable: false,
12
+ designSourceConnected: false,
12
13
  infrastructureAvailable: false,
13
14
  accountsEnabled: false,
14
15
  isAccountAdmin: false,
@@ -8,7 +8,7 @@ import type { AppSlots } from '~/modular/slots'
8
8
 
9
9
  /**
10
10
  * modular-vue registry for the `@cat-factory/app` layer (slice 0 of the
11
- * modular-vue adoption — docs/initiatives/modular-vue-adoption.md).
11
+ * modular-vue adoption — backend/docs/adr/0049-modular-vue-adoption.md).
12
12
  *
13
13
  * This is the frontend analogue of the backend's public registries
14
14
  * (`registerAgentKind`, `registerGate`): a single registry into which the layer
@@ -25,7 +25,7 @@ import type { ResultViewContribution } from './slots'
25
25
 
26
26
  /**
27
27
  * The first-party result-view registry (slice 2 of the modular-vue adoption —
28
- * docs/initiatives/modular-vue-adoption.md).
28
+ * backend/docs/adr/0049-modular-vue-adoption.md).
29
29
  *
30
30
  * Every built-in dedicated result window is contributed as a `ComponentEntry`
31
31
  * to the `resultViews` slot instead of living in a hardcoded `Record` in
@@ -10,7 +10,7 @@ import type { WorkspaceMetadataFieldDefinition } from './workspace-metadata'
10
10
  * The layer's aggregated slot map — the single home for every slot key the
11
11
  * first-party modules (and consumer deployments) contribute to. Grows one key
12
12
  * per converted seam as the modular-vue adoption proceeds
13
- * (docs/initiatives/modular-vue-adoption.md):
13
+ * (backend/docs/adr/0049-modular-vue-adoption.md):
14
14
  *
15
15
  * - `nav` (slice 1) — the nav/command catalog, rendered by the three shells.
16
16
  * - `resultViews` (slice 2) — the id → dedicated result-window registry
@@ -19,6 +19,7 @@ const ALL_GATES: NavGates = {
19
19
  canManageSettings: true,
20
20
  githubAvailable: true,
21
21
  libraryAvailable: true,
22
+ designSourceConnected: true,
22
23
  infrastructureAvailable: true,
23
24
  accountsEnabled: true,
24
25
  isAccountAdmin: true,
@@ -392,6 +393,10 @@ describe('tour availability across the catalog', () => {
392
393
  // this is the tour the contextual offer raises when one does.
393
394
  'diagnose-failure',
394
395
  'review-merge',
396
+ // The same loop as a DESIGNER enters it. Offered rather than catalogue-only because on a
397
+ // board with a design source connected it IS the everyday loop, and its own requirement
398
+ // keeps it off every board without one.
399
+ 'start-from-design',
395
400
  ]
396
401
  const CATALOGUE_ONLY = [
397
402
  'wire-models',
@@ -152,6 +152,15 @@ export const TUTORIAL_REQUIREMENTS = {
152
152
  labelKey: 'tutorial.requirements.library',
153
153
  met: (gates) => gates.libraryAvailable,
154
154
  },
155
+ // The CONNECTION rather than the permission to make one: starting from a design is
156
+ // member-tier, and the frame-header button the tour clicks exists only once some admin has
157
+ // connected a design source. A tour gated on `integrations.manage` would be withheld from
158
+ // exactly the persona it is written for.
159
+ designSource: {
160
+ id: 'design-source',
161
+ labelKey: 'tutorial.requirements.designSource',
162
+ met: (gates) => gates.designSourceConnected,
163
+ },
155
164
  } as const satisfies Record<string, TutorialRequirement>
156
165
 
157
166
  export const TUTORIAL_TOURS: readonly TutorialTour[] = [
@@ -575,6 +584,74 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
575
584
  },
576
585
  ],
577
586
  },
587
+ {
588
+ id: 'start-from-design',
589
+ order: 55,
590
+ icon: 'i-lucide-frame',
591
+ titleKey: 'tutorial.tours.startFromDesign.title',
592
+ descriptionKey: 'tutorial.tours.startFromDesign.description',
593
+ // The delivery loop as a DESIGNER enters it, which is a different first step from
594
+ // `first-task`: the work starts from a frame in Figma, not from a title someone types. It
595
+ // rides the loop's order (after the run tours, before the platform half) rather than
596
+ // replacing `first-task`, because it ends in the same add-task form and everything after
597
+ // that point is the arc those tours already cover.
598
+ //
599
+ // Offered at launch, unlike the platform tours, and gated on the design source being
600
+ // CONNECTED: on a board with one, this is the everyday loop rather than reference material.
601
+ requires: [
602
+ TUTORIAL_REQUIREMENTS.boardWrite,
603
+ TUTORIAL_REQUIREMENTS.service,
604
+ TUTORIAL_REQUIREMENTS.designSource,
605
+ ],
606
+ steps: [
607
+ {
608
+ id: 'intro',
609
+ titleKey: 'tutorial.tours.startFromDesign.steps.intro.title',
610
+ bodyKey: 'tutorial.tours.startFromDesign.steps.intro.body',
611
+ },
612
+ {
613
+ id: 'open',
614
+ target: 'frame-start-from-design',
615
+ advanceOn: 'target-click',
616
+ placement: 'bottom',
617
+ titleKey: 'tutorial.tours.startFromDesign.steps.open.title',
618
+ bodyKey: 'tutorial.tours.startFromDesign.steps.open.body',
619
+ },
620
+ {
621
+ id: 'paste',
622
+ target: 'start-from-design-link',
623
+ // Inside the modal the previous click opens.
624
+ waitForTargetMs: 8000,
625
+ placement: 'bottom',
626
+ titleKey: 'tutorial.tours.startFromDesign.steps.paste.title',
627
+ bodyKey: 'tutorial.tours.startFromDesign.steps.paste.body',
628
+ },
629
+ {
630
+ // Left to the anchor-skip rather than given a `when`: the resolved card appears only
631
+ // once a link has actually been pasted, and someone walking the tour without one in
632
+ // hand should reach the finish card rather than stall on an input they cannot fill.
633
+ id: 'resolved',
634
+ target: 'start-from-design-resolved',
635
+ waitForTargetMs: 8000,
636
+ placement: 'bottom',
637
+ titleKey: 'tutorial.tours.startFromDesign.steps.resolved.title',
638
+ bodyKey: 'tutorial.tours.startFromDesign.steps.resolved.body',
639
+ },
640
+ {
641
+ id: 'continue',
642
+ target: 'start-from-design-continue',
643
+ advanceOn: 'target-click',
644
+ placement: 'top',
645
+ titleKey: 'tutorial.tours.startFromDesign.steps.continue.title',
646
+ bodyKey: 'tutorial.tours.startFromDesign.steps.continue.body',
647
+ },
648
+ {
649
+ id: 'finish',
650
+ titleKey: 'tutorial.tours.startFromDesign.steps.finish.title',
651
+ bodyKey: 'tutorial.tours.startFromDesign.steps.finish.body',
652
+ },
653
+ ],
654
+ },
578
655
  // ---------------------------------------------------------------------------------------
579
656
  // The platform half. Ordered after the whole delivery loop so the catalogue reads in the
580
657
  // order someone meets these things: learn the loop, then the machinery under it.
@@ -55,6 +55,9 @@ const DocumentTemplatesModal = defineAsyncComponent(
55
55
  const SpawnPreviewModal = defineAsyncComponent(
56
56
  () => import('~/components/documents/SpawnPreviewModal.vue'),
57
57
  )
58
+ const StartFromDesignModal = defineAsyncComponent(
59
+ () => import('~/components/documents/StartFromDesignModal.vue'),
60
+ )
58
61
  const BootstrapModal = defineAsyncComponent(
59
62
  () => import('~/components/bootstrap/BootstrapModal.vue'),
60
63
  )
@@ -472,6 +475,7 @@ watch(
472
475
  <DocumentImportModal v-if="ui.documentImport" />
473
476
  <DocumentTemplatesModal v-if="ui.documentTemplates" />
474
477
  <SpawnPreviewModal v-if="ui.spawnPreview" />
478
+ <StartFromDesignModal v-if="ui.startFromDesign" />
475
479
  <BootstrapModal v-if="ui.bootstrapOpen" />
476
480
  <AddServiceFromRepoModal v-if="ui.addServiceOpen" />
477
481
  <GitHubPanel v-if="ui.githubOpen" />
@@ -20,7 +20,7 @@ import type { Block, CustomAgentKind, CustomTaskType } from '~/types/domain'
20
20
 
21
21
  /**
22
22
  * Wire the modular-vue registry into the Nuxt app (slice 0 of the modular-vue
23
- * adoption — docs/initiatives/modular-vue-adoption.md).
23
+ * adoption — backend/docs/adr/0049-modular-vue-adoption.md).
24
24
  *
25
25
  * `enforce: 'post'` is load-bearing for the consumer-contribution seam. Nuxt
26
26
  * loads layer plugins before the consuming app's plugins within the same enforce
@@ -16,7 +16,7 @@ import type { AgentArchetype, AgentKind, AgentKindVariant, CustomAgentKind } fro
16
16
 
17
17
  /**
18
18
  * The agent palette catalog (slice 2 of the modular-vue adoption —
19
- * docs/initiatives/modular-vue-adoption.md).
19
+ * backend/docs/adr/0049-modular-vue-adoption.md).
20
20
  *
21
21
  * Reactive union of three sources, none of which mutates the frozen built-in
22
22
  * {@link AGENT_BY_KIND} const any more:
@@ -154,3 +154,63 @@ describe('documents store: manual refresh', () => {
154
154
  expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
155
155
  })
156
156
  })
157
+
158
+ describe('documents store: the OAuth and design halves of the source list', () => {
159
+ /** The source listing the probe reads: descriptors PLUS what this deployment can OAuth. */
160
+ function stubSources(oauthSources: string[]) {
161
+ stubApi({
162
+ listDocumentSources: () =>
163
+ Promise.resolve({
164
+ sources: [
165
+ {
166
+ source: 'figma',
167
+ label: 'Figma',
168
+ icon: 'i',
169
+ credentialFields: [],
170
+ refLabel: '',
171
+ refPlaceholder: '',
172
+ oauth: { scopes: ['file_content:read'] },
173
+ },
174
+ {
175
+ source: 'notion',
176
+ label: 'Notion',
177
+ icon: 'i',
178
+ credentialFields: [],
179
+ refLabel: '',
180
+ refPlaceholder: '',
181
+ },
182
+ ],
183
+ oauthSources,
184
+ }),
185
+ listDocumentConnections: () =>
186
+ Promise.resolve({ connections: [{ source: 'figma', label: 'Figma', connectedAt: 1 }] }),
187
+ })
188
+ }
189
+
190
+ it('offers OAuth where the deployment has a registered app', async () => {
191
+ stubSources(['figma'])
192
+ const store = useDocumentsStore()
193
+ await store.probe()
194
+ expect(store.canConnectWithOAuth('figma')).toBe(true)
195
+ })
196
+
197
+ it('withholds it where the SOURCE declares an OAuth half but the deployment registered nothing', async () => {
198
+ // Figma declares the half in both cases; only the registered app decides whether the button
199
+ // is offered. Folded onto the descriptor, this case would render a "Connect with Figma" that
200
+ // can only 503.
201
+ stubSources([])
202
+ const store = useDocumentsStore()
203
+ await store.probe()
204
+ expect(store.descriptorFor('figma')?.oauth).toBeDefined()
205
+ expect(store.canConnectWithOAuth('figma')).toBe(false)
206
+ })
207
+
208
+ it('lists only CONNECTED design sources, so no affordance opens onto a dead end', async () => {
209
+ stubSources(['figma'])
210
+ const store = useDocumentsStore()
211
+ await store.probe()
212
+ // Notion is connectable and not a design source; Figma is both connected and a design one.
213
+ // The classification comes from contracts, so adding a source cannot leave it unclassified.
214
+ expect(store.connectedDesignSources).toEqual(['figma'])
215
+ })
216
+ })
@@ -14,6 +14,7 @@ import type {
14
14
  } from '~/types/domain'
15
15
  import { isConnectableSource } from '@cat-factory/contracts'
16
16
  import { useDocumentFreshness } from '~/composables/useDocumentFreshness'
17
+ import { useDocumentSourceConnect } from '~/composables/useDocumentSourceConnect'
17
18
  import { useSourceIntegration } from '~/composables/useSourceIntegration'
18
19
  import { useUpsertList } from '~/composables/useUpsertList'
19
20
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -32,6 +33,10 @@ export const useDocumentsStore = defineStore('documents', () => {
32
33
  const api = useApi()
33
34
  const workspace = useWorkspaceStore()
34
35
 
36
+ // What this DEPLOYMENT can OAuth, reported beside the descriptors and read through
37
+ // `useDocumentSourceConnect`, which owns why that is a separate fact from the descriptor's own.
38
+ const oauthSources = ref<DocumentSourceKind[]>([])
39
+
35
40
  // Shared opt-in / probe / connections lifecycle (see `useSourceIntegration`).
36
41
  const integration = useSourceIntegration<
37
42
  DocumentSourceKind,
@@ -41,16 +46,32 @@ export const useDocumentsStore = defineStore('documents', () => {
41
46
  enabled: () => !!workspace.workspaceId,
42
47
  workspaceId: () => workspace.workspaceId,
43
48
  fetch: async () => {
44
- const [{ sources }, { connections }] = await Promise.all([
49
+ const [{ sources, oauthSources: oauth }, { connections }] = await Promise.all([
45
50
  api.listDocumentSources(workspace.requireId()),
46
51
  api.listDocumentConnections(workspace.requireId()),
47
52
  ])
53
+ oauthSources.value = oauth
48
54
  return { sources, connections }
49
55
  },
50
56
  })
51
57
  const { available, sources, connections, connectedSources, anyConnected } = integration
52
58
  const { descriptorFor, connectionFor, isConnected, probe, ensureProbed } = integration
53
59
 
60
+ // Connecting a source, and what this board may connect it WITH, in its own collaborator: the
61
+ // question grew several parts when OAuth landed, and none of the store's other concerns (the
62
+ // imported documents, their freshness, the doc-kind role links) read any of them.
63
+ const { canConnectWithOAuth, connectedDesignSources, connect, disconnect, beginOAuthConnect } =
64
+ useDocumentSourceConnect({
65
+ workspaceId: () => workspace.requireId(),
66
+ oauthSources,
67
+ connectedSources,
68
+ onConnected: (conn) => {
69
+ integration.upsertConnection(conn)
70
+ available.value = true
71
+ },
72
+ onDisconnected: (source) => integration.removeConnection(source),
73
+ })
74
+
54
75
  const { items: documents, upsert: upsertDoc } = useUpsertList<SourceDocument>({
55
76
  key: (d) => `${d.source}:${d.externalId}`,
56
77
  prepend: true,
@@ -76,19 +97,6 @@ export const useDocumentsStore = defineStore('documents', () => {
76
97
  return documents.value.filter((d) => d.linkedBlockId === blockId)
77
98
  }
78
99
 
79
- /** Connect the workspace to a source with its credential bag. */
80
- async function connect(source: DocumentSourceKind, credentials: Record<string, string>) {
81
- const conn = await api.connectDocumentSource(workspace.requireId(), source, credentials)
82
- integration.upsertConnection(conn)
83
- available.value = true
84
- }
85
-
86
- /** Disconnect the workspace from a source. */
87
- async function disconnect(source: DocumentSourceKind) {
88
- await api.disconnectDocumentSource(workspace.requireId(), source)
89
- integration.removeConnection(source)
90
- }
91
-
92
100
  /** Load the imported documents for the workspace (across sources). */
93
101
  async function loadDocuments() {
94
102
  documents.value = await api.listDocuments(workspace.requireId())
@@ -125,20 +133,36 @@ export const useDocumentsStore = defineStore('documents', () => {
125
133
  return results
126
134
  }
127
135
 
128
- /** Preview the board structure a page would expand into (no writes). */
129
- function plan(source: DocumentSourceKind, externalId: string): Promise<DocumentBoardPlan> {
130
- return api.planDocument(workspace.requireId(), source, externalId)
136
+ /**
137
+ * Preview the board structure a page would expand into (no writes).
138
+ *
139
+ * With `frameId` the preview is TARGET-AWARE: the planner is told which service the work goes
140
+ * inside and proposes its modules and tasks instead of an architecture.
141
+ */
142
+ function plan(
143
+ source: DocumentSourceKind,
144
+ externalId: string,
145
+ frameId?: string,
146
+ ): Promise<DocumentBoardPlan> {
147
+ return api.planDocument(workspace.requireId(), source, {
148
+ externalId,
149
+ ...(frameId ? { frameId } : {}),
150
+ })
131
151
  }
132
152
 
133
153
  /**
134
- * Apply a page's structure to the board as new top-level frames, then refresh the
135
- * board snapshot. The endpoint also accepts a `frameId` that flattens the planned
136
- * frames into an existing service; the SPA deliberately never sends one, because the
137
- * planner is target-blind and that path discards the frame titles/types the preview
138
- * shows. Scoping a spawn to a service needs a target-aware plan first.
154
+ * Apply a page's structure to the board, then refresh the board snapshot.
155
+ *
156
+ * `frameId` must be the SAME frame the preview was planned for. The endpoint re-plans against
157
+ * it, so a targeted preview and its write agree; sending a frame the preview did not use would
158
+ * flatten a board-wide plan into it and discard the frame titles and types the user approved,
159
+ * which is why this was board-level only until target-aware planning existed.
139
160
  */
140
- async function spawn(source: DocumentSourceKind, externalId: string) {
141
- const { result } = await api.spawnDocument(workspace.requireId(), source, { externalId })
161
+ async function spawn(source: DocumentSourceKind, externalId: string, frameId?: string) {
162
+ const { result } = await api.spawnDocument(workspace.requireId(), source, {
163
+ externalId,
164
+ ...(frameId ? { frameId } : {}),
165
+ })
142
166
  await workspace.refresh()
143
167
  return result
144
168
  }
@@ -218,6 +242,10 @@ export const useDocumentsStore = defineStore('documents', () => {
218
242
  return {
219
243
  available,
220
244
  sources,
245
+ oauthSources,
246
+ canConnectWithOAuth,
247
+ connectedDesignSources,
248
+ beginOAuthConnect,
221
249
  connections,
222
250
  documents,
223
251
  loading,
@@ -33,7 +33,7 @@ import { createSaveActions } from '~/stores/environmentWizard/save'
33
33
  // compose provider keys purely on the saved recipe (the build-flag rule). Mirrors the other infra
34
34
  // stores' idiom; the flow state is a singleton so the wizard's step children share it.
35
35
  //
36
- // Since slice 3 of the modular-vue adoption (docs/initiatives/modular-vue-adoption.md) the wizard's
36
+ // Since slice 3 of the modular-vue adoption (backend/docs/adr/0049-modular-vue-adoption.md) the wizard's
37
37
  // step NAVIGATION lives in a modular-vue journey (`app/modular/journeys/environmentSetup.ts`), NOT
38
38
  // here — this store no longer holds a `step` / `STEP_ORDER` / `goToStep`. It is purely the per-frame
39
39
  // data+action layer the journey's step components drive; `beginForFrame` seeds it when a step first
@@ -13,6 +13,7 @@ function key(over: Partial<PublicApiKey> = {}): PublicApiKey {
13
13
  scope: 'write',
14
14
  createdByUserId: null,
15
15
  createdByKeyId: null,
16
+ externalIdentity: null,
16
17
  createdAt: 1,
17
18
  lastUsedAt: null,
18
19
  revokedAt: null,
@@ -192,6 +192,11 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
192
192
  // opened standalone, and the modal offers every container on the board.
193
193
  const bugHunt = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
194
194
 
195
+ // Start-from-design: paste a design link, and the resolved reference is staged onto a new task
196
+ // in `frameId`. `frameId` is always present — the affordance lives on a frame header, and the
197
+ // flow ends in the add-task form, which needs a container to create in.
198
+ const startFromDesign = ref<{ frameId: string } | null>(null)
199
+
195
200
  // Add-task modal: the container (service frame or module) a new task is being
196
201
  // added to, or null when closed. The user types the title + description; nothing
197
202
  // is launched until they explicitly start the created task.
@@ -266,6 +271,13 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
266
271
  function closeBugHunt() {
267
272
  bugHunt.value = null
268
273
  }
274
+ function openStartFromDesign(frameId: string) {
275
+ resetHubReturn()
276
+ startFromDesign.value = { frameId }
277
+ }
278
+ function closeStartFromDesign() {
279
+ startFromDesign.value = null
280
+ }
269
281
  function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
270
282
  addTaskPrefill.value = prefill
271
283
  addTaskContainerId.value = containerId
@@ -301,6 +313,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
301
313
  taskConnect,
302
314
  taskImport,
303
315
  bugHunt,
316
+ startFromDesign,
304
317
  addTaskContainerId,
305
318
  addTaskPrefill,
306
319
  reviewFrictionContext,
@@ -320,6 +333,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
320
333
  closeTaskImport,
321
334
  openBugHunt,
322
335
  closeBugHunt,
336
+ openStartFromDesign,
337
+ closeStartFromDesign,
323
338
  openAddTask,
324
339
  closeAddTask,
325
340
  openReviewFriction,
@@ -6,6 +6,6 @@
6
6
  * ergonomics as the layer's auto-imported stores and composables.
7
7
  *
8
8
  * See `app/modular/registry.ts` for the seam itself and
9
- * docs/initiatives/modular-vue-adoption.md for the adoption plan.
9
+ * backend/docs/adr/0049-modular-vue-adoption.md for the adoption plan.
10
10
  */
11
11
  export { registerAppModule } from '~/modular/registry'