@cat-factory/app 1.0.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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,196 @@
1
+ /* eslint-disable unicorn/no-thenable -- `then` is the Gherkin clause name on plain
2
+ scenario data objects (a string[]), never a thenable callback; these objects
3
+ are never awaited. */
4
+ import { defineStore } from 'pinia'
5
+ import { ref, computed } from 'vue'
6
+ import { uid } from '~/utils/catalog'
7
+ import type { AcceptanceScenario, Block } from '~/types/domain'
8
+
9
+ /** Context the acceptance agent draws on when drafting scenarios for a feature. */
10
+ export interface ScenarioGenerationContext {
11
+ /** The block's free-text intent. */
12
+ description?: string
13
+ /** Titles/excerpts of linked requirement docs (PRDs), for traceable scenarios. */
14
+ requirements?: string[]
15
+ }
16
+
17
+ /** Normalise a feature/title into a comparable key (case- and space-insensitive). */
18
+ function normalize(value: string): string {
19
+ return value.trim().toLowerCase().replace(/\s+/g, ' ')
20
+ }
21
+
22
+ /**
23
+ * Draft the standard set of acceptance scenarios for a feature: the happy path,
24
+ * an error path and an input-validation path. This mirrors what the `acceptance`
25
+ * agent does from requirements — deterministic here so the prototype has
26
+ * something concrete and editable to show. The feature name and any linked
27
+ * requirements are folded into the Given/When/Then so the output is specific.
28
+ */
29
+ function draftScenarios(
30
+ feature: string,
31
+ context: ScenarioGenerationContext = {},
32
+ ): Omit<AcceptanceScenario, 'id' | 'createdAt'>[] {
33
+ const name = feature.trim()
34
+ const reqGiven = context.requirements?.length
35
+ ? [`the requirements for "${name}" (${context.requirements.join('; ')})`]
36
+ : []
37
+ const base = ['a user on the application', ...reqGiven]
38
+
39
+ return [
40
+ {
41
+ feature: name,
42
+ title: `${name}: happy path`,
43
+ given: base,
44
+ when: [`the user completes the "${name}" flow with valid input`],
45
+ then: [`the action succeeds`, `the expected result for "${name}" is shown`],
46
+ status: 'draft',
47
+ source: 'generated',
48
+ hasPlaywrightTest: false,
49
+ },
50
+ {
51
+ feature: name,
52
+ title: `${name}: invalid input is rejected`,
53
+ given: base,
54
+ when: [`the user attempts the "${name}" flow with invalid input`],
55
+ then: [`the action is rejected`, `a clear error message is shown`],
56
+ status: 'draft',
57
+ source: 'generated',
58
+ hasPlaywrightTest: false,
59
+ },
60
+ {
61
+ feature: name,
62
+ title: `${name}: required fields are validated`,
63
+ given: base,
64
+ when: [`the user submits the "${name}" flow with required fields missing`],
65
+ then: [`submission is blocked`, `each missing field is flagged`],
66
+ status: 'draft',
67
+ source: 'generated',
68
+ hasPlaywrightTest: false,
69
+ },
70
+ ]
71
+ }
72
+
73
+ /**
74
+ * The acceptance-scenario catalog. Feature-scoped Given/When/Then scenarios that
75
+ * the `acceptance` agent drafts from requirements and the `playwright` agent
76
+ * turns into e2e tests. Authored and refined client-side (persisted locally),
77
+ * this is the data the feature's scenario viewer renders.
78
+ */
79
+ export const useScenariosStore = defineStore(
80
+ 'scenarios',
81
+ () => {
82
+ const scenarios = ref<AcceptanceScenario[]>([])
83
+
84
+ /** Scenarios for a single feature, oldest first. */
85
+ function scenariosForFeature(feature: string): AcceptanceScenario[] {
86
+ const key = normalize(feature)
87
+ return scenarios.value
88
+ .filter((s) => normalize(s.feature) === key)
89
+ .sort((a, b) => a.createdAt - b.createdAt)
90
+ }
91
+
92
+ /** Scenarios across all of a block's features (the "current set" for a task). */
93
+ function scenariosForBlock(block: Pick<Block, 'features'>): AcceptanceScenario[] {
94
+ const features = (block.features ?? []).map(normalize)
95
+ if (!features.length) return []
96
+ const set = new Set(features)
97
+ return scenarios.value
98
+ .filter((s) => set.has(normalize(s.feature)))
99
+ .sort((a, b) => a.createdAt - b.createdAt)
100
+ }
101
+
102
+ /** True when a feature already has at least one scenario. */
103
+ function hasScenarios(feature: string): boolean {
104
+ const key = normalize(feature)
105
+ return scenarios.value.some((s) => normalize(s.feature) === key)
106
+ }
107
+
108
+ function addScenario(input: {
109
+ feature: string
110
+ title?: string
111
+ given?: string[]
112
+ when?: string[]
113
+ then?: string[]
114
+ source?: AcceptanceScenario['source']
115
+ }): AcceptanceScenario {
116
+ const scenario: AcceptanceScenario = {
117
+ id: uid('scn'),
118
+ feature: input.feature.trim(),
119
+ title: input.title?.trim() || 'New scenario',
120
+ given: input.given ?? [],
121
+ when: input.when ?? [],
122
+ then: input.then ?? [],
123
+ status: 'draft',
124
+ source: input.source ?? 'manual',
125
+ hasPlaywrightTest: false,
126
+ createdAt: Date.now(),
127
+ }
128
+ scenarios.value.push(scenario)
129
+ return scenario
130
+ }
131
+
132
+ function updateScenario(id: string, patch: Partial<AcceptanceScenario>) {
133
+ const scenario = scenarios.value.find((s) => s.id === id)
134
+ if (!scenario) return
135
+ Object.assign(scenario, patch)
136
+ }
137
+
138
+ function removeScenario(id: string) {
139
+ scenarios.value = scenarios.value.filter((s) => s.id !== id)
140
+ }
141
+
142
+ /**
143
+ * Draft scenarios for a feature from its requirements. Additive: titles that
144
+ * already exist for the feature are skipped, so re-running only fills gaps and
145
+ * never clobbers edits. Returns the scenarios actually created.
146
+ */
147
+ function generateForFeature(
148
+ feature: string,
149
+ context: ScenarioGenerationContext = {},
150
+ ): AcceptanceScenario[] {
151
+ const existing = new Set(scenariosForFeature(feature).map((s) => normalize(s.title)))
152
+ const created: AcceptanceScenario[] = []
153
+ for (const draft of draftScenarios(feature, context)) {
154
+ if (existing.has(normalize(draft.title))) continue
155
+ created.push(addScenario({ ...draft, source: 'generated' }))
156
+ }
157
+ return created
158
+ }
159
+
160
+ /**
161
+ * "Generate Playwright tests" for a feature. Mirrors the `playwright` agent's
162
+ * idempotent contract: only scenarios that don't yet have a test get one, so
163
+ * existing committed tests are never regenerated. Returns the scenarios for
164
+ * which a new test was created.
165
+ */
166
+ function generatePlaywrightTests(feature: string): AcceptanceScenario[] {
167
+ const created: AcceptanceScenario[] = []
168
+ for (const scenario of scenariosForFeature(feature)) {
169
+ if (scenario.hasPlaywrightTest) continue
170
+ scenario.hasPlaywrightTest = true
171
+ created.push(scenario)
172
+ }
173
+ return created
174
+ }
175
+
176
+ /** Count of scenarios still missing a Playwright test, for a feature. */
177
+ const untested = computed(
178
+ () => (feature: string) =>
179
+ scenariosForFeature(feature).filter((s) => !s.hasPlaywrightTest).length,
180
+ )
181
+
182
+ return {
183
+ scenarios,
184
+ scenariosForFeature,
185
+ scenariosForBlock,
186
+ hasScenarios,
187
+ untested,
188
+ addScenario,
189
+ updateScenario,
190
+ removeScenario,
191
+ generateForFeature,
192
+ generatePlaywrightTests,
193
+ }
194
+ },
195
+ { persist: true },
196
+ )
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import type { SourceTask, TaskConnection, TaskSourceDescriptor } from '~/types/domain'
3
+ import { useTasksStore } from '~/stores/tasks'
4
+
5
+ /** Minimal SourceTask factory — only the fields the read getters care about. */
6
+ function sourceTask(externalId: string, over: Partial<SourceTask> = {}): SourceTask {
7
+ return {
8
+ source: 'jira',
9
+ externalId,
10
+ title: externalId,
11
+ url: `https://acme.atlassian.net/browse/${externalId}`,
12
+ status: 'To Do',
13
+ type: 'Task',
14
+ assignee: null,
15
+ priority: null,
16
+ labels: [],
17
+ description: '',
18
+ comments: [],
19
+ excerpt: '',
20
+ linkedBlockId: null,
21
+ syncedAt: 0,
22
+ ...over,
23
+ }
24
+ }
25
+
26
+ const jiraDescriptor: TaskSourceDescriptor = {
27
+ source: 'jira',
28
+ label: 'Jira',
29
+ icon: 'i-lucide-square-check',
30
+ credentialFields: [],
31
+ refLabel: 'Issue key or URL',
32
+ refPlaceholder: 'PROJ-123',
33
+ }
34
+
35
+ const jiraConnection: TaskConnection = { source: 'jira', label: 'acme', connectedAt: 0 }
36
+
37
+ describe('tasks store read getters', () => {
38
+ let store: ReturnType<typeof useTasksStore>
39
+ beforeEach(() => {
40
+ store = useTasksStore()
41
+ })
42
+
43
+ it('tasksForBlock returns only issues linked to the block', () => {
44
+ store.tasks = [
45
+ sourceTask('PROJ-1', { linkedBlockId: 'b1' }),
46
+ sourceTask('PROJ-2', { linkedBlockId: 'b2' }),
47
+ sourceTask('PROJ-3', { linkedBlockId: null }),
48
+ ]
49
+ expect(store.tasksForBlock('b1').map((t) => t.externalId)).toEqual(['PROJ-1'])
50
+ expect(store.tasksForBlock('bX')).toEqual([])
51
+ })
52
+
53
+ it('isConnected / connectedSources / anyConnected reflect connections', () => {
54
+ store.sources = [jiraDescriptor]
55
+ expect(store.anyConnected).toBe(false)
56
+ expect(store.isConnected('jira')).toBe(false)
57
+ expect(store.connectedSources).toEqual([])
58
+
59
+ store.connections = [jiraConnection]
60
+ expect(store.anyConnected).toBe(true)
61
+ expect(store.isConnected('jira')).toBe(true)
62
+ expect(store.connectedSources.map((s) => s.source)).toEqual(['jira'])
63
+ })
64
+
65
+ it('descriptorFor / connectionFor resolve by source', () => {
66
+ store.sources = [jiraDescriptor]
67
+ store.connections = [jiraConnection]
68
+ expect(store.descriptorFor('jira')?.label).toBe('Jira')
69
+ expect(store.connectionFor('jira')?.label).toBe('acme')
70
+ })
71
+ })
@@ -0,0 +1,149 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type {
4
+ SourceTask,
5
+ TaskConnection,
6
+ TaskSourceDescriptor,
7
+ TaskSourceKind,
8
+ } from '~/types/domain'
9
+ import { useWorkspaceStore } from '~/stores/workspace'
10
+
11
+ /**
12
+ * Task-source integration state: the trackers the backend offers (and their
13
+ * connect metadata), the workspace's per-source connections, and the issues it
14
+ * has imported — plus the actions that connect/import/link against the backend.
15
+ * `available` mirrors the backend's opt-in gate: a 503 from the source probe
16
+ * means the integration is off, and the UI hides its entry points (just as the
17
+ * documents store does). The abstraction is source-agnostic; every action is
18
+ * keyed by a `TaskSourceKind`. Per-workspace; nothing is persisted client-side.
19
+ *
20
+ * Unlike documents there is no plan/spawn — an issue is linked to a block for
21
+ * agent context, never expanded into board structure.
22
+ */
23
+ export const useTasksStore = defineStore('tasks', () => {
24
+ const api = useApi()
25
+ const workspace = useWorkspaceStore()
26
+
27
+ /** null = unknown (not probed yet), true/false = integration on/off. */
28
+ const available = ref<boolean | null>(null)
29
+ /** The configured sources and their connect/import descriptors. */
30
+ const sources = ref<TaskSourceDescriptor[]>([])
31
+ /** Live connections, one per connected source. */
32
+ const connections = ref<TaskConnection[]>([])
33
+ const tasks = ref<SourceTask[]>([])
34
+ const loading = ref(false)
35
+
36
+ /** Sources the workspace currently has a live connection to. */
37
+ const connectedSources = computed(() =>
38
+ sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
39
+ )
40
+ const anyConnected = computed(() => connections.value.length > 0)
41
+
42
+ function descriptorFor(source: TaskSourceKind): TaskSourceDescriptor | undefined {
43
+ return sources.value.find((s) => s.source === source)
44
+ }
45
+
46
+ function connectionFor(source: TaskSourceKind): TaskConnection | undefined {
47
+ return connections.value.find((c) => c.source === source)
48
+ }
49
+
50
+ function isConnected(source: TaskSourceKind): boolean {
51
+ return connectionFor(source) !== undefined
52
+ }
53
+
54
+ /** Imported issues currently attached to a given block. */
55
+ function tasksForBlock(blockId: string): SourceTask[] {
56
+ return tasks.value.filter((t) => t.linkedBlockId === blockId)
57
+ }
58
+
59
+ /** Merge an issue returned by the backend into the local cache. */
60
+ function upsertTask(task: SourceTask) {
61
+ const i = tasks.value.findIndex(
62
+ (t) => t.source === task.source && t.externalId === task.externalId,
63
+ )
64
+ if (i >= 0) tasks.value[i] = task
65
+ else tasks.value.unshift(task)
66
+ }
67
+
68
+ function upsertConnection(conn: TaskConnection) {
69
+ const i = connections.value.findIndex((c) => c.source === conn.source)
70
+ if (i >= 0) connections.value[i] = conn
71
+ else connections.value.push(conn)
72
+ }
73
+
74
+ /** Probe the integration: resolves `available`, the sources and connections. */
75
+ async function probe() {
76
+ if (!workspace.workspaceId) return
77
+ try {
78
+ const [{ sources: srcs }, { connections: conns }] = await Promise.all([
79
+ api.listTaskSources(workspace.requireId()),
80
+ api.listTaskConnections(workspace.requireId()),
81
+ ])
82
+ available.value = true
83
+ sources.value = srcs
84
+ connections.value = conns
85
+ } catch {
86
+ // 503 (integration disabled) or any error → hide the UI entry points.
87
+ available.value = false
88
+ sources.value = []
89
+ connections.value = []
90
+ }
91
+ }
92
+
93
+ /** Connect the workspace to a source with its credential bag. */
94
+ async function connect(source: TaskSourceKind, credentials: Record<string, string>) {
95
+ const conn = await api.connectTaskSource(workspace.requireId(), source, credentials)
96
+ upsertConnection(conn)
97
+ available.value = true
98
+ }
99
+
100
+ /** Disconnect the workspace from a source. */
101
+ async function disconnect(source: TaskSourceKind) {
102
+ await api.disconnectTaskSource(workspace.requireId(), source)
103
+ connections.value = connections.value.filter((c) => c.source !== source)
104
+ }
105
+
106
+ /** Load the imported issues for the workspace (across sources). */
107
+ async function loadTasks() {
108
+ tasks.value = await api.listTasks(workspace.requireId())
109
+ }
110
+
111
+ /** Import (fetch + persist) an issue by key or URL from a source. */
112
+ async function importTask(source: TaskSourceKind, ref: string): Promise<SourceTask> {
113
+ loading.value = true
114
+ try {
115
+ const task = await api.importTask(workspace.requireId(), source, { ref })
116
+ upsertTask(task)
117
+ return task
118
+ } finally {
119
+ loading.value = false
120
+ }
121
+ }
122
+
123
+ /** Attach an imported issue to a block as agent context. */
124
+ async function linkToBlock(blockId: string, source: TaskSourceKind, externalId: string) {
125
+ const task = await api.linkTask(workspace.requireId(), { source, externalId, blockId })
126
+ upsertTask(task)
127
+ return task
128
+ }
129
+
130
+ return {
131
+ available,
132
+ sources,
133
+ connections,
134
+ tasks,
135
+ loading,
136
+ connectedSources,
137
+ anyConnected,
138
+ descriptorFor,
139
+ connectionFor,
140
+ isConnected,
141
+ tasksForBlock,
142
+ probe,
143
+ connect,
144
+ disconnect,
145
+ loadTasks,
146
+ importTask,
147
+ linkToBlock,
148
+ }
149
+ })
@@ -0,0 +1,204 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref, computed } from 'vue'
3
+ import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
4
+
5
+ /** Transient UI state: selection, panels, zoom level. */
6
+ export const useUiStore = defineStore('ui', () => {
7
+ const selectedBlockId = ref<string | null>(null)
8
+ const focusBlockId = ref<string | null>(null)
9
+ const builderOpen = ref(false)
10
+ const decisionContext = ref<{ instanceId: string; decisionId: string } | null>(null)
11
+
12
+ // Document-source integration modals, keyed by source. `documentImport` and
13
+ // `spawnPreview` carry an optional target frame, so structure spawned from a
14
+ // frame's inspector lands inside that frame rather than creating new top-level
15
+ // frames. `documentConnect` carries the source whose connect form to show;
16
+ // `documentImport`'s source may be null to let the modal pick a connected one.
17
+ const documentConnect = ref<{ source: DocumentSourceKind } | null>(null)
18
+ const documentImport = ref<{
19
+ source: DocumentSourceKind | null
20
+ targetFrameId: string | null
21
+ } | null>(null)
22
+ const spawnPreview = ref<{
23
+ source: DocumentSourceKind
24
+ externalId: string
25
+ targetFrameId: string | null
26
+ } | null>(null)
27
+
28
+ // Task-source integration modals, keyed by source. `taskConnect` carries the
29
+ // source whose connect form to show; `taskImport`'s source may be null to let
30
+ // the modal pick a connected one (there is no spawn target — issues are linked
31
+ // to a block for context, not expanded into structure).
32
+ const taskConnect = ref<{ source: TaskSourceKind } | null>(null)
33
+ const taskImport = ref<{ source: TaskSourceKind | null } | null>(null)
34
+
35
+ // Repo-bootstrap modal (manage reference architectures + launch a bootstrap).
36
+ const bootstrapOpen = ref(false)
37
+
38
+ // GitHub integration panel (connection management + repo/PR/issue browsing).
39
+ const githubOpen = ref(false)
40
+
41
+ // Prompt-fragment library panel (manage the board's best-practice catalog +
42
+ // linked guideline repos; ADR 0006).
43
+ const fragmentLibraryOpen = ref(false)
44
+
45
+ // Requirements-review panel: the block whose requirements review (questions /
46
+ // gaps / clarifications) to show, or null when closed.
47
+ const requirementReviewBlockId = ref<string | null>(null)
48
+
49
+ /** Current canvas zoom (driven by Vue Flow viewport). */
50
+ const zoom = ref(1)
51
+
52
+ const lod = computed<LodLevel>(() => {
53
+ if (zoom.value < 0.6) return 'far'
54
+ if (zoom.value < 1.2) return 'mid'
55
+ return 'close'
56
+ })
57
+
58
+ /** Frames the user has manually expanded to reveal their tasks. */
59
+ const expandedFrames = ref<Set<string>>(new Set())
60
+
61
+ function toggleFrame(id: string) {
62
+ const next = new Set(expandedFrames.value)
63
+ if (next.has(id)) next.delete(id)
64
+ else next.add(id)
65
+ expandedFrames.value = next
66
+ }
67
+
68
+ function expandFrame(id: string) {
69
+ if (expandedFrames.value.has(id)) return
70
+ expandedFrames.value = new Set(expandedFrames.value).add(id)
71
+ }
72
+
73
+ /** A frame shows its tasks when manually expanded OR when zoomed in close. */
74
+ function isFrameExpanded(id: string) {
75
+ return expandedFrames.value.has(id) || lod.value === 'close'
76
+ }
77
+
78
+ function select(id: string | null) {
79
+ selectedBlockId.value = id
80
+ }
81
+
82
+ function focus(id: string | null) {
83
+ focusBlockId.value = id
84
+ }
85
+
86
+ function openBuilder() {
87
+ builderOpen.value = true
88
+ }
89
+
90
+ function openDecision(instanceId: string, decisionId: string) {
91
+ decisionContext.value = { instanceId, decisionId }
92
+ }
93
+
94
+ function closeDecision() {
95
+ decisionContext.value = null
96
+ }
97
+
98
+ function openDocumentConnect(source: DocumentSourceKind) {
99
+ documentConnect.value = { source }
100
+ }
101
+ function closeDocumentConnect() {
102
+ documentConnect.value = null
103
+ }
104
+ function openDocumentImport(
105
+ targetFrameId: string | null = null,
106
+ source: DocumentSourceKind | null = null,
107
+ ) {
108
+ documentImport.value = { source, targetFrameId }
109
+ }
110
+ function closeDocumentImport() {
111
+ documentImport.value = null
112
+ }
113
+ function openSpawnPreview(
114
+ source: DocumentSourceKind,
115
+ externalId: string,
116
+ targetFrameId: string | null = null,
117
+ ) {
118
+ spawnPreview.value = { source, externalId, targetFrameId }
119
+ }
120
+ function closeSpawnPreview() {
121
+ spawnPreview.value = null
122
+ }
123
+ function openTaskConnect(source: TaskSourceKind) {
124
+ taskConnect.value = { source }
125
+ }
126
+ function closeTaskConnect() {
127
+ taskConnect.value = null
128
+ }
129
+ function openTaskImport(source: TaskSourceKind | null = null) {
130
+ taskImport.value = { source }
131
+ }
132
+ function closeTaskImport() {
133
+ taskImport.value = null
134
+ }
135
+ function openBootstrap() {
136
+ bootstrapOpen.value = true
137
+ }
138
+ function closeBootstrap() {
139
+ bootstrapOpen.value = false
140
+ }
141
+ function openGitHub() {
142
+ githubOpen.value = true
143
+ }
144
+ function closeGitHub() {
145
+ githubOpen.value = false
146
+ }
147
+ function openFragmentLibrary() {
148
+ fragmentLibraryOpen.value = true
149
+ }
150
+ function closeFragmentLibrary() {
151
+ fragmentLibraryOpen.value = false
152
+ }
153
+ function openRequirementReview(blockId: string) {
154
+ requirementReviewBlockId.value = blockId
155
+ }
156
+ function closeRequirementReview() {
157
+ requirementReviewBlockId.value = null
158
+ }
159
+
160
+ return {
161
+ selectedBlockId,
162
+ focusBlockId,
163
+ builderOpen,
164
+ decisionContext,
165
+ documentConnect,
166
+ documentImport,
167
+ spawnPreview,
168
+ taskConnect,
169
+ taskImport,
170
+ bootstrapOpen,
171
+ githubOpen,
172
+ fragmentLibraryOpen,
173
+ requirementReviewBlockId,
174
+ zoom,
175
+ lod,
176
+ expandedFrames,
177
+ toggleFrame,
178
+ expandFrame,
179
+ isFrameExpanded,
180
+ select,
181
+ focus,
182
+ openBuilder,
183
+ openDecision,
184
+ closeDecision,
185
+ openDocumentConnect,
186
+ closeDocumentConnect,
187
+ openDocumentImport,
188
+ closeDocumentImport,
189
+ openSpawnPreview,
190
+ closeSpawnPreview,
191
+ openTaskConnect,
192
+ closeTaskConnect,
193
+ openTaskImport,
194
+ closeTaskImport,
195
+ openBootstrap,
196
+ closeBootstrap,
197
+ openGitHub,
198
+ closeGitHub,
199
+ openFragmentLibrary,
200
+ closeFragmentLibrary,
201
+ openRequirementReview,
202
+ closeRequirementReview,
203
+ }
204
+ })