@cat-factory/app 0.248.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 (33) 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/layout/AccountDeploymentSettings.vue +122 -0
  10. package/app/composables/api/documents.ts +17 -6
  11. package/app/composables/useDocumentSourceConnect.ts +88 -0
  12. package/app/modular/external-tools.spec.ts +1 -0
  13. package/app/modular/nav-contributions.spec.ts +2 -0
  14. package/app/modular/nav-contributions.ts +10 -0
  15. package/app/modular/nav-gates.ts +4 -0
  16. package/app/modular/registry.spec.ts +1 -0
  17. package/app/modular/tutorial-tours.spec.ts +5 -0
  18. package/app/modular/tutorial-tours.ts +77 -0
  19. package/app/pages/index.vue +4 -0
  20. package/app/stores/documents.spec.ts +60 -0
  21. package/app/stores/documents.ts +52 -24
  22. package/app/stores/ui/modals.ts +15 -0
  23. package/i18n/locales/de.json +82 -6
  24. package/i18n/locales/en.json +82 -6
  25. package/i18n/locales/es.json +82 -6
  26. package/i18n/locales/fr.json +82 -6
  27. package/i18n/locales/he.json +82 -6
  28. package/i18n/locales/it.json +82 -6
  29. package/i18n/locales/ja.json +82 -6
  30. package/i18n/locales/pl.json +82 -6
  31. package/i18n/locales/tr.json +82 -6
  32. package/i18n/locales/uk.json +82 -6
  33. package/package.json +2 -2
@@ -0,0 +1,237 @@
1
+ <script setup lang="ts">
2
+ // Start a task from a design link: paste a Figma/Zeplin URL on a service frame, and the resolved
3
+ // reference is staged onto the add-task form as context. One affordance, three steps that used to
4
+ // be four separate surfaces (Integrations hub → import modal → board → add task → attach).
5
+ //
6
+ // Three rules this surface exists to hold, each of which the obvious version gets wrong.
7
+ //
8
+ // - It resolves the reference BEFORE anything is created, through the same
9
+ // `POST /document-sources/:source/resolve-ref` the attach picker uses, so a share link's title
10
+ // segment and tracking params are trimmed where the person who pasted them can still see it,
11
+ // and a link the source cannot read is a correction rather than a toast over a task that
12
+ // already exists without its design.
13
+ // - It only asks the CONNECTED DESIGN sources, and it asks them in claim-confidence order
14
+ // (host-pinned first), because a host-blind prose parser will happily claim a Figma URL whose
15
+ // file key carries a UUID-shaped run. `connectedDesignSources` is already in registry order
16
+ // and every design source is host-pinned, so the order is the store's.
17
+ // - A WIDENED reference is stated separately from a trimmed one. Figma's own Copy link emits a
18
+ // complex instance id for any component instance, which the parser cannot read, so it falls
19
+ // back to the whole file: "I attached this frame" and "I attached the entire design" otherwise
20
+ // render identically. For a designer that widening IS the defect, not a detail.
21
+ import { refRowFor, classifyRefFailure, type RefState } from './ContextDocumentPicker.logic'
22
+ import type { DocumentSourceKind, ResolvedDocumentRef } from '~/types/domain'
23
+
24
+ const { t } = useI18n()
25
+ const ui = useUiStore()
26
+ const documents = useDocumentsStore()
27
+
28
+ const open = computed({
29
+ get: () => ui.startFromDesign !== null,
30
+ set: (v: boolean) => {
31
+ if (!v) ui.closeStartFromDesign()
32
+ },
33
+ })
34
+
35
+ const pasted = ref('')
36
+ const state = ref<RefState>({ status: 'none' })
37
+ /** The source that claimed the paste, held beside the verdict so the staged item names it. */
38
+ const claimant = ref<DocumentSourceKind | null>(null)
39
+ /**
40
+ * The exact text {@link state} is the verdict FOR, so the same text is never re-resolved.
41
+ *
42
+ * This is what keeps Continue clickable. The input resolves on blur, and clicking Continue blurs
43
+ * it, so a person who resolved with Enter and then reached for the button re-entered `resolve()`
44
+ * on mousedown: the state fell back to `checking`, `row` went null, and Vue disabled the button
45
+ * before mouseup, so the click was never dispatched. The button came back enabled a moment later
46
+ * having done nothing, which reads as a dead button rather than as a race. (The `start-from-design`
47
+ * tour hit the same edge, its `advanceOn: 'target-click'` step waiting on a click that never fired.)
48
+ *
49
+ * Guarding on the TEXT rather than suppressing the blur is what makes it a fix instead of a
50
+ * workaround: re-resolving input that already has a verdict spends one HTTP call per connected
51
+ * design source to arrive back where it started, whoever caused it.
52
+ */
53
+ const resolvedFor = ref<string | null>(null)
54
+
55
+ watch(open, (isOpen) => {
56
+ if (!isOpen) return
57
+ pasted.value = ''
58
+ state.value = { status: 'none' }
59
+ claimant.value = null
60
+ resolvedFor.value = null
61
+ })
62
+
63
+ const row = computed(() => refRowFor(state.value, pasted.value))
64
+ const sources = computed(() => documents.connectedDesignSources)
65
+
66
+ /**
67
+ * The source the staged reference will be attached to.
68
+ *
69
+ * An UNCHECKED paste (the pre-flight itself failed: offline, a 502, a proxy's error page) is not
70
+ * a refusal, so it stays stageable with the import as the backstop it always was — but only when
71
+ * ONE design source is connected, because that is the only case where nothing has to be guessed.
72
+ * With two, the source is exactly what the resolve was going to tell us, and picking the
73
+ * first-registered one would attach a Zeplin screen to Figma's key space. Then the honest answer
74
+ * is to say the check could not be made and let the person retry.
75
+ */
76
+ const target = computed<DocumentSourceKind | null>(() => {
77
+ if (claimant.value) return claimant.value
78
+ if (state.value.status !== 'unchecked') return null
79
+ return sources.value.length === 1 ? (sources.value[0] ?? null) : null
80
+ })
81
+
82
+ /**
83
+ * Ask each connected design source in turn and keep the FIRST that claims the paste.
84
+ *
85
+ * Sequential rather than parallel: the sources are ordered by how much a claim over a URL is
86
+ * worth, and running them together would make the winner whichever answered first. A refusal from
87
+ * one source is not a refusal of the paste, so only the LAST one is surfaced when nobody claims
88
+ * it — that is the state where the person genuinely has to change something.
89
+ */
90
+ async function resolve() {
91
+ const text = pasted.value.trim()
92
+ // Already judged, so there is nothing to learn and a verdict to lose (see `resolvedFor`).
93
+ if (text === resolvedFor.value) return
94
+ claimant.value = null
95
+ if (!text) {
96
+ state.value = { status: 'none' }
97
+ resolvedFor.value = null
98
+ return
99
+ }
100
+ state.value = { status: 'checking' }
101
+ let last: RefState = { status: 'rejected', reason: 'document_ref_unrecognized' }
102
+ for (const source of sources.value) {
103
+ try {
104
+ const ref: ResolvedDocumentRef = await documents.resolveRef(source, text)
105
+ claimant.value = source
106
+ state.value = { status: 'ok', ref }
107
+ resolvedFor.value = text
108
+ return
109
+ } catch (e) {
110
+ last = classifyRefFailure(e)
111
+ // An outage leaves the paste UNJUDGED rather than refused, and asking the next source
112
+ // would turn one source being down into a different source's verdict.
113
+ if (last.status === 'unchecked') break
114
+ }
115
+ }
116
+ state.value = last
117
+ // A refusal and an outage are verdicts too: re-running on the same text would reach the same
118
+ // one, and an `unchecked` paste is stageable, so it must survive the blur Continue causes.
119
+ resolvedFor.value = text
120
+ }
121
+
122
+ /**
123
+ * Hand the resolved reference to the add-task form as a staged attachment.
124
+ *
125
+ * `needsImport` is true because nothing has been fetched yet: the form's own pre-create resolve
126
+ * (`useContextLinking().resolvePending`) performs the import, which is where an unreachable page
127
+ * becomes a correction the author can still make. Importing here as well would spend the fetch
128
+ * twice and move the failure back before the form exists.
129
+ */
130
+ function stage() {
131
+ const frame = ui.startFromDesign
132
+ const resolved = row.value
133
+ const source = target.value
134
+ if (!frame || !resolved || !source) return
135
+ const descriptor = documents.descriptorFor(source)
136
+ ui.closeStartFromDesign()
137
+ ui.openAddTask(frame.frameId, {
138
+ context: [
139
+ {
140
+ kind: 'document',
141
+ source,
142
+ externalId: resolved.externalId,
143
+ title: resolved.label,
144
+ subtitle: descriptor?.label,
145
+ icon: descriptor?.icon,
146
+ needsImport: true,
147
+ },
148
+ ],
149
+ })
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <UModal v-model:open="open" :title="t('documents.startFromDesign.title')">
155
+ <template #body>
156
+ <div class="space-y-4">
157
+ <p class="text-sm text-slate-400">{{ t('documents.startFromDesign.intro') }}</p>
158
+
159
+ <!-- No connected design source: the flow cannot run, and saying which step is missing
160
+ beats an input that refuses every paste. The connect route is withheld from a member
161
+ for the same reason the picker's add tier is: connecting stores a credential. -->
162
+ <p v-if="sources.length === 0" class="text-sm text-amber-300">
163
+ {{ t('documents.startFromDesign.noSource') }}
164
+ </p>
165
+
166
+ <template v-else>
167
+ <UFormField :label="t('documents.startFromDesign.linkLabel')">
168
+ <UInput
169
+ v-model="pasted"
170
+ :placeholder="t('documents.startFromDesign.linkPlaceholder')"
171
+ class="w-full"
172
+ data-testid="start-from-design-link"
173
+ @blur="resolve"
174
+ @keyup.enter="resolve"
175
+ />
176
+ </UFormField>
177
+
178
+ <div
179
+ v-if="state.status === 'checking'"
180
+ class="flex items-center gap-2 text-sm text-slate-400"
181
+ >
182
+ <UIcon name="i-lucide-loader" class="h-4 w-4 animate-spin" />
183
+ {{ t('documents.startFromDesign.checking') }}
184
+ </div>
185
+
186
+ <div
187
+ v-else-if="row"
188
+ class="space-y-1 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
189
+ data-testid="start-from-design-resolved"
190
+ >
191
+ <div class="flex items-center gap-2 text-sm text-white">
192
+ <UIcon name="i-lucide-frame" class="h-4 w-4 text-indigo-400" />
193
+ <span class="truncate">{{ row.label }}</span>
194
+ </div>
195
+ <p v-if="row.trimmed" class="text-[11px] text-slate-400">
196
+ {{ t('documents.startFromDesign.trimmed') }}
197
+ </p>
198
+ <!-- Its own line, in amber: a trim resolves the same page, a drop widens ONE frame to
199
+ the whole design file, and the second is what a designer needs to see. -->
200
+ <p v-if="row.droppedScope" class="text-[11px] text-amber-300">
201
+ {{ t('documents.startFromDesign.widened', { scope: row.droppedScope }) }}
202
+ </p>
203
+ <p v-if="row.unchecked && target" class="text-[11px] text-slate-400">
204
+ {{ t('documents.startFromDesign.unchecked') }}
205
+ </p>
206
+ <p v-else-if="row.unchecked" class="text-[11px] text-amber-300">
207
+ {{ t('documents.startFromDesign.uncheckedAmbiguous') }}
208
+ </p>
209
+ </div>
210
+
211
+ <p
212
+ v-else-if="state.status === 'rejected'"
213
+ class="text-sm text-amber-300"
214
+ data-testid="start-from-design-rejected"
215
+ >
216
+ {{ t('documents.startFromDesign.rejected') }}
217
+ </p>
218
+ </template>
219
+
220
+ <div class="flex justify-end gap-2 pt-1">
221
+ <UButton color="neutral" variant="ghost" @click="ui.closeStartFromDesign()">
222
+ {{ t('common.cancel') }}
223
+ </UButton>
224
+ <UButton
225
+ color="primary"
226
+ icon="i-lucide-arrow-right"
227
+ :disabled="!row || !target"
228
+ data-testid="start-from-design-continue"
229
+ @click="stage"
230
+ >
231
+ {{ t('documents.startFromDesign.continue') }}
232
+ </UButton>
233
+ </div>
234
+ </div>
235
+ </template>
236
+ </UModal>
237
+ </template>
@@ -39,9 +39,14 @@ watch(
39
39
 
40
40
  const slack = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
41
41
  const linear = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
42
+ // The deployment's registered Figma app, which is what turns "Connect with Figma" on for every
43
+ // board in the account. Without it the Figma document source still connects, by personal access
44
+ // token — which is the step this exists to spare a designer.
45
+ const figma = reactive({ clientId: '', clientSecret: '', redirectUrl: '' })
42
46
  const web = reactive({ braveApiKey: '', searxngUrl: '', searxngApiKey: '' })
43
47
  const savingSlack = ref(false)
44
48
  const savingLinear = ref(false)
49
+ const savingFigma = ref(false)
45
50
  const savingWeb = ref(false)
46
51
 
47
52
  const summary = computed(() => store.view?.summary ?? null)
@@ -289,6 +294,62 @@ async function clearLinear() {
289
294
  }
290
295
  }
291
296
 
297
+ async function saveFigma() {
298
+ if (!figma.clientId.trim() || !figma.clientSecret.trim() || !figma.redirectUrl.trim()) {
299
+ toast.add({ title: t('layout.accountDeployment.figma.validation'), color: 'error' })
300
+ return
301
+ }
302
+ savingFigma.value = true
303
+ try {
304
+ await store.save(props.accountId, {
305
+ secrets: {
306
+ figmaOAuth: {
307
+ clientId: figma.clientId.trim(),
308
+ clientSecret: figma.clientSecret.trim(),
309
+ redirectUrl: figma.redirectUrl.trim(),
310
+ },
311
+ },
312
+ })
313
+ figma.clientId = ''
314
+ figma.clientSecret = ''
315
+ figma.redirectUrl = ''
316
+ toast.add({
317
+ title: t('layout.accountDeployment.figma.saved'),
318
+ icon: 'i-lucide-check',
319
+ color: 'success',
320
+ })
321
+ } catch (e) {
322
+ toast.add({
323
+ title: t('layout.accountDeployment.figma.saveFailed'),
324
+ description: e instanceof Error ? e.message : String(e),
325
+ color: 'error',
326
+ })
327
+ } finally {
328
+ savingFigma.value = false
329
+ }
330
+ }
331
+
332
+ async function clearFigma() {
333
+ if (!(await confirmAction('clear', 'Figma'))) return
334
+ savingFigma.value = true
335
+ try {
336
+ await store.save(props.accountId, { secrets: { figmaOAuth: null } })
337
+ toast.add({
338
+ title: t('layout.accountDeployment.figma.cleared'),
339
+ icon: 'i-lucide-check',
340
+ color: 'success',
341
+ })
342
+ } catch (e) {
343
+ toast.add({
344
+ title: t('layout.accountDeployment.figma.clearFailed'),
345
+ description: e instanceof Error ? e.message : String(e),
346
+ color: 'error',
347
+ })
348
+ } finally {
349
+ savingFigma.value = false
350
+ }
351
+ }
352
+
292
353
  async function saveWeb() {
293
354
  const brave = web.braveApiKey.trim()
294
355
  const searxng = web.searxngUrl.trim()
@@ -479,6 +540,67 @@ async function clearWeb() {
479
540
  </div>
480
541
  </section>
481
542
 
543
+ <!-- Figma app OAuth (the document source's designer-doable connect) -->
544
+ <section class="space-y-2 border-t border-slate-800 pt-6">
545
+ <div class="flex items-center gap-2">
546
+ <h4 class="text-sm font-semibold text-slate-200">
547
+ {{ t('layout.accountDeployment.figma.title') }}
548
+ </h4>
549
+ <UBadge
550
+ :color="summary?.figmaOAuthConfigured ? 'success' : 'neutral'"
551
+ variant="subtle"
552
+ size="xs"
553
+ >
554
+ {{
555
+ summary?.figmaOAuthConfigured
556
+ ? t('layout.accountDeployment.configured')
557
+ : t('layout.accountDeployment.notSet')
558
+ }}
559
+ </UBadge>
560
+ </div>
561
+ <p class="text-[11px] text-slate-400">
562
+ {{ t('layout.accountDeployment.figma.description') }}
563
+ </p>
564
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
565
+ <UInput
566
+ v-model="figma.clientId"
567
+ :placeholder="t('layout.accountDeployment.figma.clientId')"
568
+ size="sm"
569
+ />
570
+ <SecretInput
571
+ v-model="figma.clientSecret"
572
+ :placeholder="t('layout.accountDeployment.figma.clientSecret')"
573
+ size="sm"
574
+ />
575
+ <UInput
576
+ v-model="figma.redirectUrl"
577
+ :placeholder="t('layout.accountDeployment.figma.redirectUrl')"
578
+ size="sm"
579
+ />
580
+ </div>
581
+ <div class="flex gap-2">
582
+ <UButton
583
+ color="primary"
584
+ size="xs"
585
+ icon="i-lucide-save"
586
+ :loading="savingFigma"
587
+ @click="saveFigma"
588
+ >
589
+ {{ t('common.save') }}
590
+ </UButton>
591
+ <UButton
592
+ v-if="summary?.figmaOAuthConfigured"
593
+ color="neutral"
594
+ variant="ghost"
595
+ size="xs"
596
+ :loading="savingFigma"
597
+ @click="clearFigma"
598
+ >
599
+ {{ t('layout.accountDeployment.clear') }}
600
+ </UButton>
601
+ </div>
602
+ </section>
603
+
482
604
  <!-- Web search keys -->
483
605
  <section class="space-y-2 border-t border-slate-800 pt-6">
484
606
  <div class="flex items-center gap-2">
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  connectDocumentSourceContract,
3
3
  disconnectDocumentSourceContract,
4
+ documentSourceOAuthUrlContract,
4
5
  importDocumentContract,
5
6
  linkDocumentContract,
6
7
  linkDocumentForKindContract,
@@ -41,6 +42,14 @@ export function documentsApi({ send, ws }: ApiContext) {
41
42
  body: { credentials },
42
43
  }),
43
44
 
45
+ // The vendor authorization URL for a source's OAuth connect. Admin-tier: what it hands back
46
+ // is the first half of a credential write, completed by the public callback.
47
+ documentSourceOAuthUrl: (workspaceId: string, source: DocumentSourceKind) =>
48
+ send(documentSourceOAuthUrlContract, {
49
+ pathPrefix: ws(workspaceId),
50
+ pathParams: { source },
51
+ }),
52
+
44
53
  disconnectDocumentSource: (workspaceId: string, source: DocumentSourceKind) =>
45
54
  send(disconnectDocumentSourceContract, {
46
55
  pathPrefix: ws(workspaceId),
@@ -77,12 +86,14 @@ export function documentsApi({ send, ws }: ApiContext) {
77
86
  body: { query },
78
87
  }),
79
88
 
80
- planDocument: (workspaceId: string, source: DocumentSourceKind, externalId: string) =>
81
- send(planDocumentContract, {
82
- pathPrefix: ws(workspaceId),
83
- pathParams: { source },
84
- body: { externalId },
85
- }),
89
+ // `frameId` makes the plan TARGET-AWARE: modules and tasks for a service that already exists
90
+ // rather than an architecture. The same frame is sent to the spawn, so the preview and the
91
+ // write agree about the target.
92
+ planDocument: (
93
+ workspaceId: string,
94
+ source: DocumentSourceKind,
95
+ body: { externalId: string; frameId?: string },
96
+ ) => send(planDocumentContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
86
97
 
87
98
  spawnDocument: (
88
99
  workspaceId: string,
@@ -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
+ }
@@ -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',