@topogram/cli 0.3.34

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 (257) hide show
  1. package/ARCHITECTURE.md +67 -0
  2. package/CHANGELOG.md +240 -0
  3. package/README.md +223 -0
  4. package/package.json +51 -0
  5. package/src/adoption/index.js +3 -0
  6. package/src/adoption/plan.js +702 -0
  7. package/src/adoption/reporting.js +464 -0
  8. package/src/adoption/review-groups.js +313 -0
  9. package/src/agent-ops/query-builders.js +5012 -0
  10. package/src/archive/archive.js +141 -0
  11. package/src/archive/compact.js +26 -0
  12. package/src/archive/jsonl.js +70 -0
  13. package/src/archive/resolver-bridge.js +82 -0
  14. package/src/archive/schema.js +87 -0
  15. package/src/archive/unarchive.js +108 -0
  16. package/src/catalog.js +752 -0
  17. package/src/cli/catalog-alias.js +166 -0
  18. package/src/cli.js +9738 -0
  19. package/src/component-behavior.js +173 -0
  20. package/src/example-implementation.js +91 -0
  21. package/src/format.js +19 -0
  22. package/src/generator/adapters.d.ts +4 -0
  23. package/src/generator/adapters.js +325 -0
  24. package/src/generator/api.d.ts +1 -0
  25. package/src/generator/api.js +1196 -0
  26. package/src/generator/check.js +355 -0
  27. package/src/generator/component-conformance.js +767 -0
  28. package/src/generator/components.js +39 -0
  29. package/src/generator/context/bundle.js +291 -0
  30. package/src/generator/context/diff.js +256 -0
  31. package/src/generator/context/digest.js +182 -0
  32. package/src/generator/context/domain-coverage.js +94 -0
  33. package/src/generator/context/domain-page.js +137 -0
  34. package/src/generator/context/index.js +42 -0
  35. package/src/generator/context/report.js +121 -0
  36. package/src/generator/context/shared.js +1397 -0
  37. package/src/generator/context/slice.js +703 -0
  38. package/src/generator/context/task-mode.js +466 -0
  39. package/src/generator/docs.js +327 -0
  40. package/src/generator/index.js +161 -0
  41. package/src/generator/native/parity-bundle.js +311 -0
  42. package/src/generator/output.js +300 -0
  43. package/src/generator/registry.js +482 -0
  44. package/src/generator/runtime/app-bundle.js +456 -0
  45. package/src/generator/runtime/bundle-shared.js +166 -0
  46. package/src/generator/runtime/compile-check.js +163 -0
  47. package/src/generator/runtime/deployment.js +287 -0
  48. package/src/generator/runtime/environment.js +635 -0
  49. package/src/generator/runtime/index.js +32 -0
  50. package/src/generator/runtime/runtime-check.js +554 -0
  51. package/src/generator/runtime/shared.js +515 -0
  52. package/src/generator/runtime/smoke.js +219 -0
  53. package/src/generator/schema.js +204 -0
  54. package/src/generator/sdlc/board.js +66 -0
  55. package/src/generator/sdlc/doc-page.js +53 -0
  56. package/src/generator/sdlc/index.js +23 -0
  57. package/src/generator/sdlc/release-notes.js +62 -0
  58. package/src/generator/sdlc/traceability-matrix.js +65 -0
  59. package/src/generator/shared.js +29 -0
  60. package/src/generator/surfaces/contracts.js +146 -0
  61. package/src/generator/surfaces/databases/contract.js +40 -0
  62. package/src/generator/surfaces/databases/index.js +84 -0
  63. package/src/generator/surfaces/databases/lifecycle-shared.d.ts +1 -0
  64. package/src/generator/surfaces/databases/lifecycle-shared.js +612 -0
  65. package/src/generator/surfaces/databases/migration-plan.js +281 -0
  66. package/src/generator/surfaces/databases/postgres/capabilities.js +14 -0
  67. package/src/generator/surfaces/databases/postgres/drizzle.js +99 -0
  68. package/src/generator/surfaces/databases/postgres/index.js +9 -0
  69. package/src/generator/surfaces/databases/postgres/lifecycle.js +16 -0
  70. package/src/generator/surfaces/databases/postgres/prisma.js +159 -0
  71. package/src/generator/surfaces/databases/postgres/sql-migration.js +102 -0
  72. package/src/generator/surfaces/databases/postgres/sql-schema.js +34 -0
  73. package/src/generator/surfaces/databases/shared.d.ts +1 -0
  74. package/src/generator/surfaces/databases/shared.js +350 -0
  75. package/src/generator/surfaces/databases/snapshot.js +96 -0
  76. package/src/generator/surfaces/databases/sqlite/capabilities.js +14 -0
  77. package/src/generator/surfaces/databases/sqlite/index.js +8 -0
  78. package/src/generator/surfaces/databases/sqlite/lifecycle.js +16 -0
  79. package/src/generator/surfaces/databases/sqlite/prisma.js +143 -0
  80. package/src/generator/surfaces/databases/sqlite/sql-migration.js +65 -0
  81. package/src/generator/surfaces/databases/sqlite/sql-schema.js +27 -0
  82. package/src/generator/surfaces/index.js +25 -0
  83. package/src/generator/surfaces/native/swiftui-app.js +38 -0
  84. package/src/generator/surfaces/native/swiftui-templates/Package.swift.txt +20 -0
  85. package/src/generator/surfaces/native/swiftui-templates/README.generated.md +26 -0
  86. package/src/generator/surfaces/native/swiftui-templates/runtime/DynamicScreens.swift +682 -0
  87. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoAPIClient.swift +156 -0
  88. package/src/generator/surfaces/native/swiftui-templates/runtime/TodoSwiftUIApp.swift +44 -0
  89. package/src/generator/surfaces/native/swiftui-templates/runtime/Visibility.swift +183 -0
  90. package/src/generator/surfaces/services/express.d.ts +1 -0
  91. package/src/generator/surfaces/services/express.js +766 -0
  92. package/src/generator/surfaces/services/hono.d.ts +1 -0
  93. package/src/generator/surfaces/services/hono.js +204 -0
  94. package/src/generator/surfaces/services/index.js +42 -0
  95. package/src/generator/surfaces/services/persistence-wiring.js +240 -0
  96. package/src/generator/surfaces/services/runtime-helpers.js +631 -0
  97. package/src/generator/surfaces/services/server-contract.js +80 -0
  98. package/src/generator/surfaces/services/stateless.d.ts +1 -0
  99. package/src/generator/surfaces/services/stateless.js +97 -0
  100. package/src/generator/surfaces/shared.js +64 -0
  101. package/src/generator/surfaces/web/api-client.js +1 -0
  102. package/src/generator/surfaces/web/forms.js +1 -0
  103. package/src/generator/surfaces/web/index.d.ts +2 -0
  104. package/src/generator/surfaces/web/index.js +53 -0
  105. package/src/generator/surfaces/web/react-components.js +248 -0
  106. package/src/generator/surfaces/web/react.js +538 -0
  107. package/src/generator/surfaces/web/routes.js +1 -0
  108. package/src/generator/surfaces/web/screens.js +1 -0
  109. package/src/generator/surfaces/web/shared.js +369 -0
  110. package/src/generator/surfaces/web/sveltekit-actions.js +28 -0
  111. package/src/generator/surfaces/web/sveltekit-components.js +234 -0
  112. package/src/generator/surfaces/web/sveltekit.js +426 -0
  113. package/src/generator/surfaces/web/ui-web-contract.js +65 -0
  114. package/src/generator/surfaces/web/vanilla.js +239 -0
  115. package/src/generator/verification.js +84 -0
  116. package/src/generator.js +1 -0
  117. package/src/import/core/context.js +52 -0
  118. package/src/import/core/contracts.js +23 -0
  119. package/src/import/core/registry.js +81 -0
  120. package/src/import/core/runner.js +646 -0
  121. package/src/import/core/shared.js +910 -0
  122. package/src/import/enrichers/auth-session.js +18 -0
  123. package/src/import/enrichers/django-rest.js +226 -0
  124. package/src/import/enrichers/doc-linking.js +20 -0
  125. package/src/import/enrichers/rails-controllers.js +246 -0
  126. package/src/import/enrichers/rails-models.js +130 -0
  127. package/src/import/enrichers/workflow-target-state.js +10 -0
  128. package/src/import/extractors/api/aspnet-core.js +304 -0
  129. package/src/import/extractors/api/django-routes.js +318 -0
  130. package/src/import/extractors/api/express.js +154 -0
  131. package/src/import/extractors/api/fastify.js +371 -0
  132. package/src/import/extractors/api/flutter-dio.js +135 -0
  133. package/src/import/extractors/api/generic-route-fallback.js +90 -0
  134. package/src/import/extractors/api/graphql-code-first.js +565 -0
  135. package/src/import/extractors/api/graphql-sdl.js +309 -0
  136. package/src/import/extractors/api/jaxrs.js +303 -0
  137. package/src/import/extractors/api/micronaut.js +213 -0
  138. package/src/import/extractors/api/next-route.js +50 -0
  139. package/src/import/extractors/api/next-server-action.js +51 -0
  140. package/src/import/extractors/api/nextauth.js +52 -0
  141. package/src/import/extractors/api/openapi-code.js +242 -0
  142. package/src/import/extractors/api/openapi.js +232 -0
  143. package/src/import/extractors/api/rails-routes.js +230 -0
  144. package/src/import/extractors/api/react-native-repository.js +128 -0
  145. package/src/import/extractors/api/retrofit.js +103 -0
  146. package/src/import/extractors/api/spring-web.js +372 -0
  147. package/src/import/extractors/api/swift-webapi.js +116 -0
  148. package/src/import/extractors/api/trpc.js +212 -0
  149. package/src/import/extractors/db/django-models.js +232 -0
  150. package/src/import/extractors/db/dotnet-models.js +93 -0
  151. package/src/import/extractors/db/drizzle.js +242 -0
  152. package/src/import/extractors/db/ef-core.js +221 -0
  153. package/src/import/extractors/db/flutter-entities.js +120 -0
  154. package/src/import/extractors/db/jpa.js +120 -0
  155. package/src/import/extractors/db/liquibase.js +180 -0
  156. package/src/import/extractors/db/mybatis-xml.js +145 -0
  157. package/src/import/extractors/db/prisma.js +185 -0
  158. package/src/import/extractors/db/rails-schema.js +175 -0
  159. package/src/import/extractors/db/react-native-entities.js +95 -0
  160. package/src/import/extractors/db/room.js +193 -0
  161. package/src/import/extractors/db/snapshot.js +112 -0
  162. package/src/import/extractors/db/sql.js +180 -0
  163. package/src/import/extractors/db/swiftdata.js +137 -0
  164. package/src/import/extractors/ui/android-compose.js +230 -0
  165. package/src/import/extractors/ui/backend-only.js +70 -0
  166. package/src/import/extractors/ui/blazor.js +227 -0
  167. package/src/import/extractors/ui/flutter-screens.js +152 -0
  168. package/src/import/extractors/ui/maui-xaml.js +135 -0
  169. package/src/import/extractors/ui/next-app-router.js +83 -0
  170. package/src/import/extractors/ui/next-pages-router.js +141 -0
  171. package/src/import/extractors/ui/razor-pages.js +181 -0
  172. package/src/import/extractors/ui/react-native-screens.js +166 -0
  173. package/src/import/extractors/ui/react-router.js +139 -0
  174. package/src/import/extractors/ui/sveltekit.js +123 -0
  175. package/src/import/extractors/ui/swiftui.js +193 -0
  176. package/src/import/extractors/ui/uikit.js +175 -0
  177. package/src/import/extractors/verification/generic.js +290 -0
  178. package/src/import/extractors/workflows/generic.js +137 -0
  179. package/src/import/index.js +7 -0
  180. package/src/import/provenance.js +158 -0
  181. package/src/new-project.js +2107 -0
  182. package/src/parser.js +439 -0
  183. package/src/policy/review-boundaries.js +165 -0
  184. package/src/project-config.js +535 -0
  185. package/src/proofs/backend-parity.js +19 -0
  186. package/src/proofs/contract-audit.js +220 -0
  187. package/src/proofs/ios-parity.js +7 -0
  188. package/src/proofs/issues-parity.js +10 -0
  189. package/src/proofs/web-parity.js +50 -0
  190. package/src/realization/api/build-api-realization.js +5 -0
  191. package/src/realization/api/index.js +1 -0
  192. package/src/realization/backend/build-backend-runtime-realization.js +82 -0
  193. package/src/realization/backend/index.d.ts +1 -0
  194. package/src/realization/backend/index.js +4 -0
  195. package/src/realization/db/build-db-realization.js +17 -0
  196. package/src/realization/db/index.js +3 -0
  197. package/src/realization/db/migration-plan.js +5 -0
  198. package/src/realization/db/snapshot.js +5 -0
  199. package/src/realization/ui/build-ui-shared-realization.js +305 -0
  200. package/src/realization/ui/build-web-realization.js +189 -0
  201. package/src/realization/ui/index.js +2 -0
  202. package/src/reconcile/docs.js +280 -0
  203. package/src/reconcile/index.js +3 -0
  204. package/src/reconcile/journeys.js +441 -0
  205. package/src/resolver/docs.js +1 -0
  206. package/src/resolver/enrich/acceptance-criterion.js +14 -0
  207. package/src/resolver/enrich/bug.js +12 -0
  208. package/src/resolver/enrich/component.js +2 -0
  209. package/src/resolver/enrich/index.js +1 -0
  210. package/src/resolver/enrich/pitch.js +18 -0
  211. package/src/resolver/enrich/requirement.js +20 -0
  212. package/src/resolver/enrich/task.js +16 -0
  213. package/src/resolver/expressions.js +1 -0
  214. package/src/resolver/index.js +2422 -0
  215. package/src/resolver/normalize.js +1 -0
  216. package/src/resolver.js +1 -0
  217. package/src/sdlc/adopt.js +65 -0
  218. package/src/sdlc/check.js +86 -0
  219. package/src/sdlc/dod/acceptance-criterion.js +22 -0
  220. package/src/sdlc/dod/bug.js +26 -0
  221. package/src/sdlc/dod/document.js +23 -0
  222. package/src/sdlc/dod/index.js +25 -0
  223. package/src/sdlc/dod/pitch.js +23 -0
  224. package/src/sdlc/dod/requirement.js +34 -0
  225. package/src/sdlc/dod/task.js +39 -0
  226. package/src/sdlc/explain.js +116 -0
  227. package/src/sdlc/history.js +80 -0
  228. package/src/sdlc/paths.js +11 -0
  229. package/src/sdlc/release.js +106 -0
  230. package/src/sdlc/scaffold.js +89 -0
  231. package/src/sdlc/status-filter.js +54 -0
  232. package/src/sdlc/transition.js +112 -0
  233. package/src/sdlc/transitions/acceptance-criterion.js +28 -0
  234. package/src/sdlc/transitions/bug.js +31 -0
  235. package/src/sdlc/transitions/document.js +29 -0
  236. package/src/sdlc/transitions/index.js +56 -0
  237. package/src/sdlc/transitions/pitch.js +34 -0
  238. package/src/sdlc/transitions/requirement.js +31 -0
  239. package/src/sdlc/transitions/task.js +34 -0
  240. package/src/template-trust.js +597 -0
  241. package/src/validator/expressions.js +1 -0
  242. package/src/validator/index.js +3424 -0
  243. package/src/validator/kinds.js +346 -0
  244. package/src/validator/per-kind/acceptance-criterion.js +91 -0
  245. package/src/validator/per-kind/bug.js +77 -0
  246. package/src/validator/per-kind/component.js +274 -0
  247. package/src/validator/per-kind/domain.js +205 -0
  248. package/src/validator/per-kind/pitch.js +101 -0
  249. package/src/validator/per-kind/requirement.js +75 -0
  250. package/src/validator/per-kind/task.js +96 -0
  251. package/src/validator/registry.js +1 -0
  252. package/src/validator/utils.js +12 -0
  253. package/src/validator.js +1 -0
  254. package/src/workflows.js +7597 -0
  255. package/src/workspace-docs.js +265 -0
  256. package/template-helpers/react.js +5 -0
  257. package/template-helpers/sveltekit.js +5 -0
@@ -0,0 +1,682 @@
1
+ import SwiftUI
2
+
3
+ /// Holds decoded ui-web-contract.json for runtime-driven navigation (parity with web bundle).
4
+ public final class TodoUiContract: ObservableObject {
5
+ public let raw: [String: Any]
6
+
7
+ public init(data: Data) throws {
8
+ guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
9
+ throw NSError(domain: "TodoUiContract", code: 1)
10
+ }
11
+ self.raw = obj
12
+ }
13
+
14
+ public static func loadBundled() throws -> Data {
15
+ guard let url = Bundle.module.url(forResource: "ui-web-contract", withExtension: "json"),
16
+ let data = try? Data(contentsOf: url) else {
17
+ throw NSError(domain: "TodoUiContract", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing ui-web-contract.json"])
18
+ }
19
+ return data
20
+ }
21
+
22
+ public var screens: [[String: Any]] {
23
+ (raw["screens"] as? [[String: Any]]) ?? []
24
+ }
25
+
26
+ public func screen(id: String) -> [String: Any]? {
27
+ screens.first { ($0["id"] as? String) == id }
28
+ }
29
+
30
+ public var tabItems: [NavigationItem] {
31
+ guard let nav = raw["navigation"] as? [String: Any],
32
+ let items = nav["items"] as? [[String: Any]] else {
33
+ return []
34
+ }
35
+ return items.compactMap(NavigationItem.init(json:))
36
+ .filter { $0.visible && $0.pattern == "bottom_tabs" }
37
+ .sorted {
38
+ let o0 = Int($0.order ?? "") ?? 0
39
+ let o1 = Int($1.order ?? "") ?? 0
40
+ return o0 < o1
41
+ }
42
+ }
43
+ }
44
+
45
+ public struct NavigationItem: Identifiable {
46
+ public var id: String { screenId }
47
+ public let screenId: String
48
+ public let route: String?
49
+ public let label: String
50
+ public let visible: Bool
51
+ public let pattern: String?
52
+ public let order: String?
53
+
54
+ init?(json: [String: Any]) {
55
+ guard let screenId = json["screenId"] as? String else { return nil }
56
+ self.screenId = screenId
57
+ self.route = json["route"] as? String
58
+ self.label = json["label"] as? String ?? screenId
59
+ self.visible = (json["visible"] as? Bool) ?? false
60
+ self.pattern = json["pattern"] as? String
61
+ self.order = json["order"] as? String
62
+ }
63
+ }
64
+
65
+ public struct RootTabView: View {
66
+ @ObservedObject var contract: TodoUiContract
67
+ let client: TodoAPIClient
68
+
69
+ public init(contract: TodoUiContract, client: TodoAPIClient) {
70
+ self.contract = contract
71
+ self.client = client
72
+ }
73
+
74
+ public var body: some View {
75
+ let tabs = contract.tabItems
76
+ TabView {
77
+ ForEach(tabs) { item in
78
+ NavigationStack {
79
+ DynamicScreenView(contract: contract, client: client, rootScreenId: item.screenId)
80
+ }
81
+ .tabItem { Text(item.label) }
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ public struct DynamicScreenView: View {
88
+ @ObservedObject var contract: TodoUiContract
89
+ let client: TodoAPIClient
90
+ let rootScreenId: String
91
+
92
+ public init(contract: TodoUiContract, client: TodoAPIClient, rootScreenId: String) {
93
+ self.contract = contract
94
+ self.client = client
95
+ self.rootScreenId = rootScreenId
96
+ }
97
+
98
+ public var body: some View {
99
+ if let screen = contract.screen(id: rootScreenId) {
100
+ ScreenSwitcher(contract: contract, client: client, screen: screen, baseParams: [:])
101
+ } else {
102
+ Text("Unknown screen: \(rootScreenId)")
103
+ }
104
+ }
105
+ }
106
+
107
+ struct ScreenSwitcher: View {
108
+ @ObservedObject var contract: TodoUiContract
109
+ let client: TodoAPIClient
110
+ let screen: [String: Any]
111
+ let baseParams: [String: String]
112
+
113
+ var body: some View {
114
+ let kind = screen["kind"] as? String ?? "list"
115
+ Group {
116
+ switch kind {
117
+ case "list":
118
+ ListScreen(contract: contract, client: client, screen: screen, baseParams: baseParams)
119
+ case "detail":
120
+ DetailScreen(contract: contract, client: client, screen: screen, params: baseParams)
121
+ case "form", "wizard":
122
+ FormScreen(client: client, contract: contract, screen: screen, params: baseParams)
123
+ case "board":
124
+ BoardScreen(contract: contract, client: client, screen: screen, baseParams: baseParams)
125
+ case "calendar":
126
+ CalendarScreen(contract: contract, client: client, screen: screen, baseParams: baseParams)
127
+ case "job_status":
128
+ JobStatusScreen(client: client, contract: contract, screen: screen, params: baseParams)
129
+ default:
130
+ Text("Unsupported kind: \(kind)")
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ // MARK: - List
137
+
138
+ struct ListScreen: View {
139
+ @ObservedObject var contract: TodoUiContract
140
+ let client: TodoAPIClient
141
+ let screen: [String: Any]
142
+ let baseParams: [String: String]
143
+ @State private var rows: [[String: Any]] = []
144
+ @State private var errorText: String?
145
+ @State private var filters: [String: String] = [:]
146
+
147
+ var body: some View {
148
+ let title = screen["title"] as? String ?? "List"
149
+ let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String
150
+ let screenId = screen["id"] as? String ?? ""
151
+
152
+ VStack(alignment: .leading, spacing: 8) {
153
+ if screenId == "task_list" {
154
+ filterBar
155
+ }
156
+ if let errorText {
157
+ Text(errorText).foregroundStyle(.red)
158
+ } else if rows.isEmpty {
159
+ Text((screen["emptyState"] as? [String: Any])?["title"] as? String ?? "Empty")
160
+ } else {
161
+ List(0 ..< rows.count, id: \.self) { i in
162
+ let row = rows[i]
163
+ NavigationLink {
164
+ linkedDetail(row: row, listScreenId: screenId)
165
+ } label: {
166
+ VStack(alignment: .leading) {
167
+ Text(rowTitle(row))
168
+ .font(.headline)
169
+ Text(rowSubtitle(row))
170
+ .font(.caption)
171
+ .foregroundStyle(.secondary)
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ .navigationTitle(title)
178
+ .toolbar {
179
+ if let primary = (screen["actions"] as? [String: Any])?["primary"] as? [String: Any],
180
+ let pid = primary["id"] as? String,
181
+ Visibility.canShowAction(pid, screen: screen, resource: nil) {
182
+ ToolbarItem(placement: .primaryAction) {
183
+ NavigationLink("Add") {
184
+ linkedCreate(listScreenId: screenId)
185
+ }
186
+ }
187
+ }
188
+ if screenId == "task_list", Visibility.canShowAction("cap_export_tasks", screen: screen, resource: nil) {
189
+ ToolbarItem(placement: .automatic) {
190
+ ExportTasksButton(client: client, contract: contract)
191
+ }
192
+ }
193
+ }
194
+ .task {
195
+ if let loadCap {
196
+ await reload(loadCap: loadCap)
197
+ }
198
+ }
199
+ }
200
+
201
+ @ViewBuilder
202
+ private var filterBar: some View {
203
+ VStack(alignment: .leading) {
204
+ Text("Filters").font(.caption).foregroundStyle(.secondary)
205
+ HStack {
206
+ TextField("project_id", text: bindingFilter("project_id"))
207
+ TextField("owner_id", text: bindingFilter("owner_id"))
208
+ TextField("status", text: bindingFilter("status"))
209
+ }
210
+ .textFieldStyle(.roundedBorder)
211
+ Button("Apply") {
212
+ Task {
213
+ await reload(loadCap: "cap_list_tasks")
214
+ }
215
+ }
216
+ }
217
+ .padding(.horizontal)
218
+ }
219
+
220
+ private func bindingFilter(_ key: String) -> Binding<String> {
221
+ Binding(
222
+ get: { filters[key] ?? "" },
223
+ set: { filters[key] = $0 }
224
+ )
225
+ }
226
+
227
+ @MainActor
228
+ private func reload(loadCap: String) async {
229
+ errorText = nil
230
+ do {
231
+ let input = baseParams.merging(filters) { _, new in new }
232
+ let json = try await client.requestCapability(loadCap, input: input)
233
+ rows = client.extractRows(from: json)
234
+ } catch {
235
+ errorText = String(describing: error)
236
+ }
237
+ }
238
+
239
+ @ViewBuilder
240
+ private func linkedDetail(row: [String: Any], listScreenId: String) -> some View {
241
+ let rid = row["id"] as? String ?? ""
242
+ if listScreenId.contains("task") {
243
+ DetailScreen(contract: contract, client: client, screen: contract.screen(id: "task_detail") ?? [:], params: ["task_id": rid])
244
+ } else if listScreenId.contains("project") {
245
+ DetailScreen(contract: contract, client: client, screen: contract.screen(id: "project_detail") ?? [:], params: ["project_id": rid])
246
+ } else if listScreenId.contains("user") {
247
+ DetailScreen(contract: contract, client: client, screen: contract.screen(id: "user_detail") ?? [:], params: ["user_id": rid])
248
+ } else {
249
+ Text("Detail")
250
+ }
251
+ }
252
+
253
+ @ViewBuilder
254
+ private func linkedCreate(listScreenId: String) -> some View {
255
+ switch listScreenId {
256
+ case "project_list":
257
+ FormScreen(client: client, contract: contract, screen: contract.screen(id: "project_create") ?? [:], params: [:])
258
+ case "user_list":
259
+ FormScreen(client: client, contract: contract, screen: contract.screen(id: "user_create") ?? [:], params: [:])
260
+ case "task_list", "task_board", "task_calendar":
261
+ FormScreen(client: client, contract: contract, screen: contract.screen(id: "task_create") ?? [:], params: [:])
262
+ default:
263
+ Text("Create")
264
+ }
265
+ }
266
+ }
267
+
268
+ private func rowTitle(_ row: [String: Any]) -> String {
269
+ (row["title"] as? String)
270
+ ?? (row["name"] as? String)
271
+ ?? (row["display_name"] as? String)
272
+ ?? ((row["id"] as? String) ?? "row")
273
+ }
274
+
275
+ private func rowSubtitle(_ row: [String: Any]) -> String {
276
+ (row["status"] as? String) ?? ""
277
+ }
278
+
279
+ // MARK: - Detail
280
+
281
+ struct DetailScreen: View {
282
+ @ObservedObject var contract: TodoUiContract
283
+ let client: TodoAPIClient
284
+ let screen: [String: Any]
285
+ let params: [String: String]
286
+ @State private var payload: [String: Any]?
287
+ @State private var errorText: String?
288
+
289
+ var body: some View {
290
+ Group {
291
+ if let errorText {
292
+ Text(errorText).foregroundStyle(.red)
293
+ } else if let payload {
294
+ ScrollView {
295
+ VStack(alignment: .leading, spacing: 8) {
296
+ ForEach(Array(payload.keys.sorted()), id: \.self) { key in
297
+ HStack {
298
+ Text(key).font(.caption).foregroundStyle(.secondary)
299
+ Spacer()
300
+ Text(String(describing: payload[key] ?? ""))
301
+ .textSelection(.enabled)
302
+ }
303
+ }
304
+ }
305
+ .padding()
306
+ }
307
+ } else {
308
+ ProgressView()
309
+ }
310
+ }
311
+ .navigationTitle((screen["title"] as? String) ?? "Detail")
312
+ .toolbar {
313
+ ToolbarItemGroup(placement: .bottomBar) {
314
+ if let primary = (screen["actions"] as? [String: Any])?["primary"] as? [String: Any],
315
+ let cid = primary["id"] as? String {
316
+ NavigationLink("Edit") {
317
+ editForm(cid: cid)
318
+ }
319
+ }
320
+ if let secondary = (screen["actions"] as? [String: Any])?["secondary"] as? [String: Any],
321
+ let cid = secondary["id"] as? String {
322
+ Button("Complete") {
323
+ Task { await runSecondary(cid) }
324
+ }
325
+ }
326
+ if let destructive = (screen["actions"] as? [String: Any])?["destructive"] as? [String: Any],
327
+ let cid = destructive["id"] as? String {
328
+ Button("Delete", role: .destructive) {
329
+ Task { await runDestructive(cid) }
330
+ }
331
+ }
332
+ }
333
+ }
334
+ .task {
335
+ await load()
336
+ }
337
+ }
338
+
339
+ private func loadCapId() -> String? {
340
+ (screen["loadCapability"] as? [String: Any])?["id"] as? String
341
+ }
342
+
343
+ @MainActor
344
+ private func load() async {
345
+ guard let cap = loadCapId() else { return }
346
+ do {
347
+ let json = try await client.requestCapability(cap, input: params)
348
+ payload = json as? [String: Any]
349
+ } catch {
350
+ errorText = String(describing: error)
351
+ }
352
+ }
353
+
354
+ @MainActor
355
+ private func runSecondary(_ capabilityId: String) async {
356
+ guard capabilityId == "cap_complete_task",
357
+ let taskId = params["task_id"] ?? params["id"],
358
+ let detail = payload else { return }
359
+ let etag = String(describing: detail["updated_at"] ?? "")
360
+ var headers: [String: String] = [:]
361
+ if !etag.isEmpty { headers["If-Match"] = etag }
362
+ let body: [String: Any] = [
363
+ "task_id": taskId,
364
+ "completed": true,
365
+ "idempotency_key": UUID().uuidString
366
+ ]
367
+ do {
368
+ _ = try await client.requestCapability(capabilityId, input: body, extraHeaders: headers)
369
+ await load()
370
+ } catch {
371
+ errorText = String(describing: error)
372
+ }
373
+ }
374
+
375
+ @MainActor
376
+ private func runDestructive(_ capabilityId: String) async {
377
+ guard let rid = params["task_id"] ?? params["project_id"] ?? params["user_id"] ?? params["id"] else { return }
378
+ var input: [String: Any] = [:]
379
+ if capabilityId.contains("task") { input["task_id"] = rid }
380
+ if capabilityId.contains("project") { input["project_id"] = rid }
381
+ if capabilityId.contains("user") { input["user_id"] = rid }
382
+ do {
383
+ _ = try await client.requestCapability(capabilityId, input: input)
384
+ } catch {
385
+ errorText = String(describing: error)
386
+ }
387
+ }
388
+
389
+ @ViewBuilder
390
+ private func editForm(cid: String) -> some View {
391
+ let sid = cid.contains("project") ? "project_edit" : cid.contains("user") ? "user_edit" : "task_edit"
392
+ FormScreen(client: client, contract: contract, screen: contract.screen(id: sid) ?? [:], params: params)
393
+ }
394
+ }
395
+
396
+ // MARK: - Form
397
+
398
+ struct FormScreen: View {
399
+ let client: TodoAPIClient
400
+ @ObservedObject var contract: TodoUiContract
401
+ let screen: [String: Any]
402
+ let params: [String: String]
403
+ @State private var values: [String: String] = [:]
404
+ @State private var message: String?
405
+
406
+ var body: some View {
407
+ Form {
408
+ ForEach(formFieldNames(), id: \.self) { name in
409
+ TextField(name, text: binding(name))
410
+ }
411
+ Button("Submit") {
412
+ Task { await submit() }
413
+ }
414
+ if let message {
415
+ Text(message)
416
+ }
417
+ }
418
+ .navigationTitle((screen["title"] as? String) ?? "Form")
419
+ .task {
420
+ await preload()
421
+ }
422
+ }
423
+
424
+ private func formFieldNames() -> [String] {
425
+ guard let submitId = (screen["submitCapability"] as? [String: Any])?["id"] as? String else { return [] }
426
+ return client.bodyFieldNames(for: submitId)
427
+ }
428
+
429
+ private func binding(_ name: String) -> Binding<String> {
430
+ Binding(
431
+ get: { values[name] ?? "" },
432
+ set: { values[name] = $0 }
433
+ )
434
+ }
435
+
436
+ private func preload() async {
437
+ guard let sid = screen["id"] as? String else { return }
438
+ if sid.contains("edit") {
439
+ let getCap = sid.contains("task") ? "cap_get_task" : sid.contains("project") ? "cap_get_project" : "cap_get_user"
440
+ var p = params
441
+ if sid.contains("task") { p["task_id"] = params["task_id"] ?? params["id"] ?? "" }
442
+ if sid.contains("project") { p["project_id"] = params["project_id"] ?? params["id"] ?? "" }
443
+ if sid.contains("user") { p["user_id"] = params["user_id"] ?? params["id"] ?? "" }
444
+ do {
445
+ let json = try await client.requestCapability(getCap, input: p)
446
+ if let dict = json as? [String: Any] {
447
+ for key in dict.keys {
448
+ values[key] = String(describing: dict[key] ?? "")
449
+ }
450
+ }
451
+ } catch {
452
+ message = String(describing: error)
453
+ }
454
+ }
455
+ }
456
+
457
+ private func submit() async {
458
+ guard let submitId = (screen["submitCapability"] as? [String: Any])?["id"] as? String else { return }
459
+ var input: [String: Any] = [:]
460
+ for name in formFieldNames() {
461
+ if let v = values[name], !v.isEmpty {
462
+ input[name] = v
463
+ }
464
+ }
465
+ for (k, v) in params where input[k] == nil {
466
+ input[k] = v
467
+ }
468
+ var headers: [String: String] = [:]
469
+ if submitId == "cap_update_task", let etag = values["updated_at"] {
470
+ headers["If-Match"] = etag
471
+ }
472
+ do {
473
+ _ = try await client.requestCapability(submitId, input: input, extraHeaders: headers)
474
+ message = "Saved"
475
+ } catch {
476
+ message = String(describing: error)
477
+ }
478
+ }
479
+ }
480
+
481
+ extension TodoAPIClient {
482
+ func bodyFieldNames(for capabilityId: String) -> [String] {
483
+ guard let contract = contracts[capabilityId] as? [String: Any],
484
+ let rc = contract["requestContract"] as? [String: Any],
485
+ let transport = rc["transport"] as? [String: Any],
486
+ let body = transport["body"] as? [[String: Any]] else {
487
+ return []
488
+ }
489
+ return body.compactMap { $0["name"] as? String }
490
+ }
491
+ }
492
+
493
+ struct ExportTasksButton: View {
494
+ let client: TodoAPIClient
495
+ @ObservedObject var contract: TodoUiContract
496
+ @State private var jobId: String?
497
+
498
+ var body: some View {
499
+ Group {
500
+ if let jobId {
501
+ NavigationLink("Export job") {
502
+ JobStatusScreen(
503
+ client: client,
504
+ contract: contract,
505
+ screen: contract.screen(id: "task_exports") ?? [:],
506
+ params: ["job_id": jobId]
507
+ )
508
+ }
509
+ } else {
510
+ Button("Export") {
511
+ Task { await run() }
512
+ }
513
+ }
514
+ }
515
+ }
516
+
517
+ private func run() async {
518
+ do {
519
+ let json = try await client.requestCapability("cap_export_tasks", input: [:])
520
+ if let dict = json as? [String: Any], let id = dict["job_id"] as? String {
521
+ jobId = id
522
+ }
523
+ } catch {
524
+ jobId = nil
525
+ }
526
+ }
527
+ }
528
+
529
+ // MARK: - Board & Calendar
530
+
531
+ struct BoardScreen: View {
532
+ @ObservedObject var contract: TodoUiContract
533
+ let client: TodoAPIClient
534
+ let screen: [String: Any]
535
+ let baseParams: [String: String]
536
+ @State private var rows: [[String: Any]] = []
537
+
538
+ private var grouped: [String: [[String: Any]]] {
539
+ Dictionary(grouping: rows, by: { ($0["status"] as? String) ?? "unknown" })
540
+ }
541
+
542
+ var body: some View {
543
+ ScrollView {
544
+ LazyVStack(alignment: .leading) {
545
+ ForEach(Array(grouped.keys.sorted()), id: \.self) { key in
546
+ Text(key).font(.headline)
547
+ ForEach(Array((grouped[key] ?? []).enumerated()), id: \.offset) { _, row in
548
+ NavigationLink {
549
+ DetailScreen(
550
+ contract: contract,
551
+ client: client,
552
+ screen: contract.screen(id: "task_detail") ?? [:],
553
+ params: ["task_id": row["id"] as? String ?? ""]
554
+ )
555
+ } label: {
556
+ Text(rowTitle(row))
557
+ }
558
+ }
559
+ }
560
+ }
561
+ .padding()
562
+ }
563
+ .navigationTitle(screen["title"] as? String ?? "Board")
564
+ .task {
565
+ await load()
566
+ }
567
+ }
568
+
569
+ private func load() async {
570
+ guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
571
+ do {
572
+ let json = try await client.requestCapability(cap, input: baseParams)
573
+ rows = client.extractRows(from: json)
574
+ } catch {
575
+ rows = []
576
+ }
577
+ }
578
+ }
579
+
580
+ struct CalendarScreen: View {
581
+ @ObservedObject var contract: TodoUiContract
582
+ let client: TodoAPIClient
583
+ let screen: [String: Any]
584
+ let baseParams: [String: String]
585
+ @State private var rows: [[String: Any]] = []
586
+
587
+ private var grouped: [String: [[String: Any]]] {
588
+ Dictionary(grouping: rows, by: { row in
589
+ if let d = row["due_date"] as? String {
590
+ return String(d.prefix(10))
591
+ }
592
+ return "unscheduled"
593
+ })
594
+ }
595
+
596
+ var body: some View {
597
+ List {
598
+ ForEach(Array(grouped.keys.sorted()), id: \.self) { day in
599
+ Section(day) {
600
+ ForEach(Array((grouped[day] ?? []).enumerated()), id: \.offset) { _, row in
601
+ NavigationLink {
602
+ DetailScreen(
603
+ contract: contract,
604
+ client: client,
605
+ screen: contract.screen(id: "task_detail") ?? [:],
606
+ params: ["task_id": row["id"] as? String ?? ""]
607
+ )
608
+ } label: {
609
+ Text(rowTitle(row))
610
+ }
611
+ }
612
+ }
613
+ }
614
+ }
615
+ .navigationTitle(screen["title"] as? String ?? "Calendar")
616
+ .task {
617
+ await load()
618
+ }
619
+ }
620
+
621
+ private func load() async {
622
+ guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
623
+ do {
624
+ let json = try await client.requestCapability(cap, input: baseParams)
625
+ rows = client.extractRows(from: json)
626
+ } catch {
627
+ rows = []
628
+ }
629
+ }
630
+ }
631
+
632
+ struct JobStatusScreen: View {
633
+ let client: TodoAPIClient
634
+ @ObservedObject var contract: TodoUiContract
635
+ let screen: [String: Any]
636
+ let params: [String: String]
637
+ @State private var job: [String: Any]?
638
+
639
+ var body: some View {
640
+ Group {
641
+ if let job {
642
+ VStack(alignment: .leading, spacing: 8) {
643
+ ForEach(Array(job.keys.sorted()), id: \.self) { k in
644
+ Text("\(k): \(String(describing: job[k] ?? ""))")
645
+ }
646
+ if let terminal = (screen["actions"] as? [String: Any])?["terminal"] as? [String: Any],
647
+ let cid = terminal["id"] as? String {
648
+ Button("Download") {
649
+ Task { await download(cid) }
650
+ }
651
+ }
652
+ }
653
+ .padding()
654
+ } else {
655
+ ProgressView()
656
+ }
657
+ }
658
+ .navigationTitle((screen["title"] as? String) ?? "Export")
659
+ .task {
660
+ await loadJob()
661
+ }
662
+ }
663
+
664
+ private func loadJob() async {
665
+ guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
666
+ do {
667
+ let json = try await client.requestCapability(cap, input: params)
668
+ job = json as? [String: Any]
669
+ } catch {
670
+ job = [:]
671
+ }
672
+ }
673
+
674
+ private func download(_ capabilityId: String) async {
675
+ let jobId = params["job_id"] ?? ""
676
+ do {
677
+ _ = try await client.requestCapability(capabilityId, input: ["job_id": jobId])
678
+ } catch {
679
+ // ignore download errors in stub
680
+ }
681
+ }
682
+ }