@cat-factory/app 0.248.0 → 0.250.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 (45) hide show
  1. package/app/components/auth/LoginScreen.vue +8 -2
  2. package/app/components/board/AddTaskModal.vue +1 -0
  3. package/app/components/board/nodes/BlockNode.vue +24 -0
  4. package/app/components/board/nodes/TaskCard.vue +1 -1
  5. package/app/components/bootstrap/BootstrapModal.vue +5 -3
  6. package/app/components/context/ContextAttachmentFields.vue +102 -0
  7. package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
  8. package/app/components/context/pastedLinkOffer.logic.ts +50 -0
  9. package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
  10. package/app/components/documents/SpawnPreviewModal.vue +92 -16
  11. package/app/components/documents/StartFromDesignModal.vue +237 -0
  12. package/app/components/github/GitHubPanel.vue +49 -10
  13. package/app/components/layout/AccountDeploymentSettings.vue +122 -0
  14. package/app/components/panels/InspectorPanel.vue +14 -9
  15. package/app/components/vcs/GitLabConnect.vue +8 -2
  16. package/app/composables/api/documents.ts +17 -6
  17. package/app/composables/useDocumentSourceConnect.ts +88 -0
  18. package/app/composables/usePipelineErrorToast.ts +2 -0
  19. package/app/modular/external-tools.spec.ts +1 -0
  20. package/app/modular/nav-contributions.spec.ts +2 -0
  21. package/app/modular/nav-contributions.ts +10 -0
  22. package/app/modular/nav-gates.ts +4 -0
  23. package/app/modular/registry.spec.ts +1 -0
  24. package/app/modular/tutorial-tours.spec.ts +5 -0
  25. package/app/modular/tutorial-tours.ts +77 -0
  26. package/app/pages/index.vue +4 -0
  27. package/app/stores/documents.spec.ts +60 -0
  28. package/app/stores/documents.ts +52 -24
  29. package/app/stores/github/vcsConnect.ts +19 -0
  30. package/app/stores/github.spec.ts +116 -6
  31. package/app/stores/github.ts +23 -6
  32. package/app/stores/ui/modals.ts +15 -0
  33. package/app/utils/vcs.spec.ts +105 -14
  34. package/app/utils/vcs.ts +113 -29
  35. package/i18n/locales/de.json +118 -25
  36. package/i18n/locales/en.json +118 -25
  37. package/i18n/locales/es.json +118 -25
  38. package/i18n/locales/fr.json +118 -25
  39. package/i18n/locales/he.json +118 -25
  40. package/i18n/locales/it.json +118 -25
  41. package/i18n/locales/ja.json +118 -25
  42. package/i18n/locales/pl.json +118 -25
  43. package/i18n/locales/tr.json +118 -25
  44. package/i18n/locales/uk.json +118 -25
  45. package/package.json +2 -2
@@ -0,0 +1,88 @@
1
+ import { computed, type ComputedRef, type Ref } from 'vue'
2
+ import { isDesignSource } from '@cat-factory/contracts'
3
+ import type {
4
+ DocumentConnection,
5
+ DocumentSourceDescriptor,
6
+ DocumentSourceKind,
7
+ } from '~/types/domain'
8
+
9
+ /**
10
+ * How this workspace CONNECTS to a document source, and what it may connect with.
11
+ *
12
+ * Split out of `stores/documents.ts` when the OAuth half landed, because "which credential can
13
+ * this board offer for this source" turned into a question with several parts: a source declares
14
+ * an OAuth half in code, the deployment may or may not have registered an app for it, and the
15
+ * typed-credential form is the fallback either way. The store's other concerns (the imported
16
+ * documents, their freshness, the doc-kind role links) never read any of it.
17
+ *
18
+ * It takes bound accessors rather than the store, so it stays testable on its own and cannot
19
+ * reach for state it has no business in.
20
+ */
21
+ export interface DocumentSourceConnectDeps {
22
+ workspaceId: () => string
23
+ /**
24
+ * The sources this DEPLOYMENT can OAuth, as the backend reports them beside the descriptors.
25
+ *
26
+ * Owned by the caller (the probe writes it) rather than fetched here, because it arrives on the
27
+ * same response as the descriptors and a second read would be a second round trip that could
28
+ * disagree with the first.
29
+ */
30
+ oauthSources: Ref<DocumentSourceKind[]>
31
+ /** The connected subset of the descriptors, in registry order. */
32
+ connectedSources: ComputedRef<DocumentSourceDescriptor[]>
33
+ /** Fold a new/updated connection into the caller's list. */
34
+ onConnected: (connection: DocumentConnection) => void
35
+ /** Drop a source's connection from the caller's list. */
36
+ onDisconnected: (source: DocumentSourceKind) => void
37
+ }
38
+
39
+ export function useDocumentSourceConnect(deps: DocumentSourceConnectDeps) {
40
+ const api = useApi()
41
+
42
+ /**
43
+ * Whether this deployment can run the OAuth connect for a source RIGHT NOW.
44
+ *
45
+ * Deliberately not `descriptor.oauth !== undefined`: that says the source supports the flow,
46
+ * which is true of Figma on every deployment, including the ones that have registered no app.
47
+ * A button rendered off the descriptor alone could only 503.
48
+ */
49
+ function canConnectWithOAuth(source: DocumentSourceKind): boolean {
50
+ return deps.oauthSources.value.includes(source)
51
+ }
52
+
53
+ /**
54
+ * Every CONNECTED design source, in the order the backend registered them.
55
+ *
56
+ * `isDesignSource` comes from contracts rather than a local list, for the reason the backend
57
+ * reads it there: whether a source describes a design is a fact both sides have to agree about,
58
+ * and a second copy here would drift the moment a source is added.
59
+ */
60
+ const connectedDesignSources = computed(() =>
61
+ deps.connectedSources.value.map((s) => s.source).filter(isDesignSource),
62
+ )
63
+
64
+ /** Connect the workspace to a source with its credential bag. */
65
+ async function connect(source: DocumentSourceKind, credentials: Record<string, string>) {
66
+ deps.onConnected(await api.connectDocumentSource(deps.workspaceId(), source, credentials))
67
+ }
68
+
69
+ /** Disconnect the workspace from a source. */
70
+ async function disconnect(source: DocumentSourceKind) {
71
+ await api.disconnectDocumentSource(deps.workspaceId(), source)
72
+ deps.onDisconnected(source)
73
+ }
74
+
75
+ /**
76
+ * Send the browser to a source's vendor consent screen.
77
+ *
78
+ * A full navigation rather than a popup: the vendor lands back on the app's own OAuth callback,
79
+ * which stores the grant and redirects here, so the returning page re-probes and sees the
80
+ * connection. A popup would leave the opener holding stale state with nothing to tell it.
81
+ */
82
+ async function beginOAuthConnect(source: DocumentSourceKind) {
83
+ const { url } = await api.documentSourceOAuthUrl(deps.workspaceId(), source)
84
+ window.location.assign(url)
85
+ }
86
+
87
+ return { canConnectWithOAuth, connectedDesignSources, connect, disconnect, beginOAuthConnect }
88
+ }
@@ -333,6 +333,8 @@ const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
333
333
  binary_generators_unreachable: 'errors.unavailable.description.binary_generators_unreachable',
334
334
  foundational_builtins_unreachable:
335
335
  'errors.unavailable.description.foundational_builtins_unreachable',
336
+ connection_credentials_unreadable:
337
+ 'errors.unavailable.description.connection_credentials_unreadable',
336
338
  }
337
339
 
338
340
  /**
@@ -33,6 +33,7 @@ const GATES: NavGates = {
33
33
  canManageSettings: true,
34
34
  githubAvailable: true,
35
35
  libraryAvailable: true,
36
+ designSourceConnected: true,
36
37
  infrastructureAvailable: true,
37
38
  accountsEnabled: true,
38
39
  isAccountAdmin: true,
@@ -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,
@@ -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).
@@ -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,
@@ -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" />
@@ -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,
@@ -15,6 +15,7 @@ export interface VcsProviderViews {
15
15
  canConnectGitLabPat: ComputedRef<boolean>
16
16
  soleConnectProvider: ComputedRef<VcsProvider | null>
17
17
  surfaceProvider: ComputedRef<VcsProvider | null>
18
+ surfaceWebUrl: ComputedRef<string | null>
18
19
  }
19
20
 
20
21
  /** The derived provider questions above, off the probed connection + capability state. */
@@ -53,12 +54,30 @@ export function createVcsProviderViews(ctx: GitHubStoreContext): VcsProviderView
53
54
  connection.value !== null ? provider.value : soleConnectProvider.value,
54
55
  )
55
56
 
57
+ /**
58
+ * The browser-facing host of the instance {@link surfaceProvider} names: the connected one's,
59
+ * or (with nothing bound) the host the deployment advertises for the provider it could
60
+ * connect. Null when neither is known, which is what a link builder needs to WITHHOLD rather
61
+ * than fall back to the provider's public instance.
62
+ *
63
+ * It has to come off the connect OPTION before a connection exists, because the two surfaces
64
+ * that need a host that early (the PAT box's token link, bootstrap's create-repository button)
65
+ * render while the connect box is still on screen.
66
+ */
67
+ const surfaceWebUrl = computed<string | null>(() => {
68
+ if (connection.value !== null) return connection.value.webUrl
69
+ const sole = soleConnectProvider.value
70
+ if (!sole) return null
71
+ return connectOptions.value.find((o) => o.provider === sole)?.webUrl ?? null
72
+ })
73
+
56
74
  return {
57
75
  provider,
58
76
  canConnectGitHubApp,
59
77
  canConnectGitLabPat,
60
78
  soleConnectProvider,
61
79
  surfaceProvider,
80
+ surfaceWebUrl,
62
81
  }
63
82
  }
64
83