paseo-kanban 0.1.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.
@@ -0,0 +1,498 @@
1
+ import { useState, useEffect, useMemo, useSyncExternalStore } from "react";
2
+ import { View, Text, Pressable, StyleSheet } from "react-native";
3
+ import { Modal, TextInput, Icon, ScrollView } from "@getpaseo/plugin/client/react-native";
4
+ import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
5
+ import type { KanbanLane } from "../shared/kanban";
6
+ import type { ProjectItem } from "./use-projects";
7
+ import type { TaskEditSession } from "./kanban-session";
8
+ import { useI18n } from "./i18n";
9
+
10
+ type PluginTheme = PluginSurfaceProps["theme"];
11
+
12
+ export interface TaskModalProps {
13
+ open: boolean;
14
+ onClose: () => void;
15
+ session: TaskEditSession;
16
+ lanes?: KanbanLane[];
17
+ projects: ProjectItem[];
18
+ theme: PluginTheme;
19
+ layout: { compact: boolean; platform: "ios" | "android" | "web" };
20
+ }
21
+
22
+ export function TaskModal({
23
+ open,
24
+ onClose,
25
+ session,
26
+ lanes: propLanes,
27
+ projects,
28
+ theme,
29
+ layout,
30
+ }: TaskModalProps) {
31
+ const { t } = useI18n();
32
+ const snapshot = useSyncExternalStore(
33
+ session.subscribe,
34
+ session.getSnapshot,
35
+ session.getSnapshot
36
+ );
37
+
38
+ const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
39
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
40
+
41
+ useEffect(() => {
42
+ if (open) {
43
+ setNewSubtaskTitle("");
44
+ setShowDeleteConfirm(false);
45
+ }
46
+ }, [open, session]);
47
+
48
+ const lanes = propLanes ?? snapshot.lanes;
49
+ const isSaving = snapshot.isSaving;
50
+ const errorMessage = snapshot.error;
51
+
52
+ const styles = useMemo(
53
+ () =>
54
+ StyleSheet.create({
55
+ modalBody: {
56
+ flex: 1,
57
+ minHeight: 0,
58
+ },
59
+ scrollBody: {
60
+ flex: 1,
61
+ minHeight: 0,
62
+ },
63
+ container: {
64
+ gap: layout.compact ? 12 : 16,
65
+ },
66
+ fieldGroup: {
67
+ gap: 6,
68
+ },
69
+ label: {
70
+ fontSize: 13,
71
+ fontWeight: "600",
72
+ color: theme.colors.foreground,
73
+ },
74
+ input: {
75
+ backgroundColor: theme.colors.surface2,
76
+ color: theme.colors.foreground,
77
+ borderColor: theme.colors.border,
78
+ borderWidth: 1,
79
+ borderRadius: 8,
80
+ paddingHorizontal: 12,
81
+ paddingVertical: 8,
82
+ fontSize: 14,
83
+ },
84
+ multilineInput: {
85
+ backgroundColor: theme.colors.surface2,
86
+ color: theme.colors.foreground,
87
+ borderColor: theme.colors.border,
88
+ borderWidth: 1,
89
+ borderRadius: 8,
90
+ paddingHorizontal: 12,
91
+ paddingVertical: 8,
92
+ fontSize: 14,
93
+ minHeight: 80,
94
+ textAlignVertical: "top",
95
+ },
96
+ selectorRow: {
97
+ flexDirection: "row",
98
+ flexWrap: "wrap",
99
+ gap: 8,
100
+ },
101
+ optionChip: {
102
+ paddingHorizontal: 10,
103
+ paddingVertical: 6,
104
+ borderRadius: 6,
105
+ borderWidth: 1,
106
+ borderColor: theme.colors.border,
107
+ backgroundColor: theme.colors.surface1,
108
+ },
109
+ optionChipSelected: {
110
+ borderColor: theme.colors.accent,
111
+ backgroundColor: theme.colors.accent,
112
+ },
113
+ optionChipText: {
114
+ fontSize: 13,
115
+ color: theme.colors.foreground,
116
+ },
117
+ optionChipTextSelected: {
118
+ color: theme.colors.accentForeground,
119
+ fontWeight: "600",
120
+ },
121
+ subtaskItem: {
122
+ flexDirection: "row",
123
+ alignItems: "center",
124
+ gap: 8,
125
+ paddingVertical: 6,
126
+ borderBottomWidth: 1,
127
+ borderBottomColor: theme.colors.border,
128
+ },
129
+ subtaskCheck: {
130
+ width: 22,
131
+ height: 22,
132
+ borderRadius: 4,
133
+ borderWidth: 1,
134
+ borderColor: theme.colors.border,
135
+ alignItems: "center",
136
+ justifyContent: "center",
137
+ backgroundColor: theme.colors.surface2,
138
+ },
139
+ subtaskCheckCompleted: {
140
+ backgroundColor: theme.colors.accent,
141
+ borderColor: theme.colors.accent,
142
+ },
143
+ subtaskInput: {
144
+ flex: 1,
145
+ fontSize: 13,
146
+ color: theme.colors.foreground,
147
+ paddingVertical: 4,
148
+ },
149
+ subtaskInputCompleted: {
150
+ textDecorationLine: "line-through",
151
+ color: theme.colors.foregroundMuted,
152
+ },
153
+ errorBanner: {
154
+ backgroundColor: theme.colors.surface2,
155
+ borderColor: theme.colors.statusDanger,
156
+ borderWidth: 1,
157
+ borderRadius: 8,
158
+ padding: 10,
159
+ },
160
+ errorText: {
161
+ color: theme.colors.statusDanger,
162
+ fontSize: 13,
163
+ },
164
+ buttonRow: {
165
+ flexDirection: "row",
166
+ justifyContent: "flex-end",
167
+ gap: 10,
168
+ marginTop: 10,
169
+ },
170
+ cancelButton: {
171
+ paddingHorizontal: 16,
172
+ paddingVertical: 10,
173
+ borderRadius: 8,
174
+ borderWidth: 1,
175
+ borderColor: theme.colors.border,
176
+ backgroundColor: theme.colors.surface1,
177
+ },
178
+ cancelButtonText: {
179
+ color: theme.colors.foreground,
180
+ fontSize: 14,
181
+ },
182
+ saveButton: {
183
+ paddingHorizontal: 18,
184
+ paddingVertical: 10,
185
+ borderRadius: 8,
186
+ backgroundColor: theme.colors.accent,
187
+ },
188
+ saveButtonDisabled: {
189
+ opacity: 0.6,
190
+ },
191
+ saveButtonText: {
192
+ color: theme.colors.accentForeground,
193
+ fontSize: 14,
194
+ fontWeight: "600",
195
+ },
196
+ deleteSection: {
197
+ marginTop: 12,
198
+ paddingTop: 12,
199
+ borderTopWidth: 1,
200
+ borderTopColor: theme.colors.border,
201
+ flexDirection: "row",
202
+ justifyContent: "space-between",
203
+ alignItems: "center",
204
+ },
205
+ deleteButton: {
206
+ paddingHorizontal: 12,
207
+ paddingVertical: 8,
208
+ borderRadius: 6,
209
+ backgroundColor: theme.colors.surface1,
210
+ borderWidth: 1,
211
+ borderColor: theme.colors.statusDanger,
212
+ },
213
+ deleteButtonText: {
214
+ color: theme.colors.statusDanger,
215
+ fontSize: 13,
216
+ fontWeight: "500",
217
+ },
218
+ }),
219
+ [theme, layout.compact]
220
+ );
221
+
222
+ const handleAddSubtask = () => {
223
+ const ok = session.addSubtask(newSubtaskTitle);
224
+ if (ok) {
225
+ setNewSubtaskTitle("");
226
+ }
227
+ };
228
+
229
+ const handleSave = async () => {
230
+ const result = await session.save();
231
+ if (result.success) {
232
+ onClose();
233
+ }
234
+ };
235
+
236
+ const handleDelete = async () => {
237
+ const result = await session.deleteTask();
238
+ if (result.success) {
239
+ onClose();
240
+ }
241
+ };
242
+
243
+ return (
244
+ <Modal
245
+ title={session.mode === "edit" ? t("taskModal.titleEdit") : t("taskModal.titleCreate")}
246
+ icon={<Icon name="PanelsTopLeft" size={18} color={theme.colors.foreground} />}
247
+ open={open}
248
+ onOpenChange={(next) => {
249
+ if (!next && !isSaving) {
250
+ onClose();
251
+ }
252
+ }}
253
+ >
254
+ <Modal.Content
255
+ scrollable={false}
256
+ style={{ backgroundColor: theme.colors.surface0 }}
257
+ contentContainerStyle={styles.modalBody}
258
+ >
259
+ <ScrollView
260
+ style={styles.scrollBody}
261
+ contentContainerStyle={styles.container}
262
+ keyboardShouldPersistTaps="handled"
263
+ >
264
+ {errorMessage && (
265
+ <View style={styles.errorBanner}>
266
+ <Text style={styles.errorText}>{errorMessage}</Text>
267
+ </View>
268
+ )}
269
+
270
+ <View style={styles.fieldGroup}>
271
+ <Text style={styles.label}>{t("taskModal.titleLabel")}</Text>
272
+ <TextInput
273
+ style={styles.input}
274
+ placeholder={t("taskModal.titlePlaceholder")}
275
+ placeholderTextColor={theme.colors.foregroundMuted}
276
+ value={snapshot.title}
277
+ onChangeText={(t) => session.setTitle(t)}
278
+ />
279
+ </View>
280
+
281
+ <View style={styles.fieldGroup}>
282
+ <Text style={styles.label}>{t("taskModal.laneLabel")}</Text>
283
+ <View style={styles.selectorRow}>
284
+ {lanes.map((lane) => {
285
+ const isSelected = lane.id === snapshot.laneId;
286
+ return (
287
+ <Pressable
288
+ key={lane.id}
289
+ onPress={() => session.setLaneId(lane.id)}
290
+ style={[
291
+ styles.optionChip,
292
+ isSelected && styles.optionChipSelected,
293
+ ]}
294
+ >
295
+ <Text
296
+ style={[
297
+ styles.optionChipText,
298
+ isSelected && styles.optionChipTextSelected,
299
+ ]}
300
+ >
301
+ {lane.title}
302
+ </Text>
303
+ </Pressable>
304
+ );
305
+ })}
306
+ </View>
307
+ </View>
308
+
309
+ <View style={styles.fieldGroup}>
310
+ <Text style={styles.label}>{t("taskModal.projectLabel")}</Text>
311
+ <View style={styles.selectorRow}>
312
+ <Pressable
313
+ onPress={() => session.setProjectId(null)}
314
+ style={[
315
+ styles.optionChip,
316
+ snapshot.projectId === null && styles.optionChipSelected,
317
+ ]}
318
+ >
319
+ <Text
320
+ style={[
321
+ styles.optionChipText,
322
+ snapshot.projectId === null && styles.optionChipTextSelected,
323
+ ]}
324
+ >
325
+ {t("taskModal.noProject")}
326
+ </Text>
327
+ </Pressable>
328
+ {projects.map((proj) => {
329
+ const isSelected = proj.projectId === snapshot.projectId;
330
+ return (
331
+ <Pressable
332
+ key={proj.projectId}
333
+ onPress={() => session.setProjectId(proj.projectId)}
334
+ style={[
335
+ styles.optionChip,
336
+ isSelected && styles.optionChipSelected,
337
+ ]}
338
+ >
339
+ <Text
340
+ style={[
341
+ styles.optionChipText,
342
+ isSelected && styles.optionChipTextSelected,
343
+ ]}
344
+ >
345
+ {proj.projectDisplayName}
346
+ </Text>
347
+ </Pressable>
348
+ );
349
+ })}
350
+ </View>
351
+ </View>
352
+
353
+ <View style={styles.fieldGroup}>
354
+ <Text style={styles.label}>{t("taskModal.descLabel")}</Text>
355
+ <TextInput
356
+ style={styles.multilineInput}
357
+ multiline
358
+ numberOfLines={3}
359
+ placeholder={t("taskModal.descPlaceholder")}
360
+ placeholderTextColor={theme.colors.foregroundMuted}
361
+ value={snapshot.description}
362
+ onChangeText={(t) => session.setDescription(t)}
363
+ />
364
+ </View>
365
+
366
+ {/* Subtasks Section */}
367
+ <View style={styles.fieldGroup}>
368
+ <Text style={styles.label}>
369
+ {t("taskModal.subtasksLabel", {
370
+ completed: snapshot.subtasks.filter((s) => s.completed).length,
371
+ total: snapshot.subtasks.length,
372
+ })}
373
+ </Text>
374
+
375
+ {snapshot.subtasks.map((subtask) => (
376
+ <View key={subtask.id} style={styles.subtaskItem}>
377
+ <Pressable
378
+ onPress={() =>
379
+ session.updateSubtask(subtask.id, { completed: !subtask.completed })
380
+ }
381
+ style={[
382
+ styles.subtaskCheck,
383
+ subtask.completed && styles.subtaskCheckCompleted,
384
+ ]}
385
+ >
386
+ {subtask.completed && (
387
+ <Icon name="Check" size={14} color={theme.colors.accentForeground} />
388
+ )}
389
+ </Pressable>
390
+
391
+ <TextInput
392
+ style={[
393
+ styles.subtaskInput,
394
+ subtask.completed && styles.subtaskInputCompleted,
395
+ ]}
396
+ value={subtask.title}
397
+ onChangeText={(txt) =>
398
+ session.updateSubtask(subtask.id, { title: txt })
399
+ }
400
+ placeholder={t("taskModal.subtaskPlaceholder")}
401
+ placeholderTextColor={theme.colors.foregroundMuted}
402
+ />
403
+
404
+ <Pressable
405
+ onPress={() => session.removeSubtask(subtask.id)}
406
+ hitSlop={8}
407
+ >
408
+ <Icon name="Trash2" size={14} color={theme.colors.statusDanger} />
409
+ </Pressable>
410
+ </View>
411
+ ))}
412
+
413
+ <View style={{ flexDirection: "row", gap: 8, marginTop: 4 }}>
414
+ <TextInput
415
+ style={[styles.input, { flex: 1 }]}
416
+ placeholder={t("taskModal.newSubtaskPlaceholder")}
417
+ placeholderTextColor={theme.colors.foregroundMuted}
418
+ value={newSubtaskTitle}
419
+ onChangeText={(t) => setNewSubtaskTitle(t)}
420
+ onSubmitEditing={handleAddSubtask}
421
+ />
422
+ <Pressable
423
+ onPress={handleAddSubtask}
424
+ style={[
425
+ styles.optionChip,
426
+ { backgroundColor: theme.colors.surface2, justifyContent: "center" },
427
+ ]}
428
+ >
429
+ <Text style={styles.optionChipText}>{t("taskModal.addStep")}</Text>
430
+ </Pressable>
431
+ </View>
432
+ </View>
433
+
434
+ {/* Delete Task Section for Edit Mode */}
435
+ {session.mode === "edit" && (
436
+ <View style={styles.deleteSection}>
437
+ {!showDeleteConfirm ? (
438
+ <Pressable
439
+ onPress={() => setShowDeleteConfirm(true)}
440
+ style={styles.deleteButton}
441
+ disabled={isSaving}
442
+ >
443
+ <Text style={styles.deleteButtonText}>{t("taskModal.deleteTask")}</Text>
444
+ </Pressable>
445
+ ) : (
446
+ <View style={{ flexDirection: "row", gap: 8, alignItems: "center" }}>
447
+ <Text style={{ fontSize: 13, color: theme.colors.statusDanger }}>
448
+ {t("taskModal.confirmDeletePrompt")}
449
+ </Text>
450
+ <Pressable
451
+ onPress={handleDelete}
452
+ style={[styles.deleteButton, { backgroundColor: theme.colors.statusDanger }]}
453
+ disabled={isSaving}
454
+ >
455
+ <Text style={[styles.deleteButtonText, { color: "#fff" }]}>
456
+ {t("taskModal.confirmDelete")}
457
+ </Text>
458
+ </Pressable>
459
+ <Pressable
460
+ onPress={() => setShowDeleteConfirm(false)}
461
+ style={[styles.optionChip, { backgroundColor: theme.colors.surface2 }]}
462
+ >
463
+ <Text style={styles.optionChipText}>{t("taskModal.cancel")}</Text>
464
+ </Pressable>
465
+ </View>
466
+ )}
467
+ </View>
468
+ )}
469
+
470
+ {/* Action Buttons */}
471
+ <View style={styles.buttonRow}>
472
+ <Pressable
473
+ onPress={onClose}
474
+ style={styles.cancelButton}
475
+ disabled={isSaving}
476
+ >
477
+ <Text style={styles.cancelButtonText}>{t("taskModal.cancel")}</Text>
478
+ </Pressable>
479
+
480
+ <Pressable
481
+ onPress={handleSave}
482
+ style={[styles.saveButton, isSaving && styles.saveButtonDisabled]}
483
+ disabled={isSaving}
484
+ >
485
+ <Text style={styles.saveButtonText}>
486
+ {isSaving
487
+ ? t("taskModal.saving")
488
+ : session.mode === "edit"
489
+ ? t("taskModal.saveChanges")
490
+ : t("taskModal.submitCreate")}
491
+ </Text>
492
+ </Pressable>
493
+ </View>
494
+ </ScrollView>
495
+ </Modal.Content>
496
+ </Modal>
497
+ );
498
+ }
@@ -0,0 +1,112 @@
1
+ import { usePaseo } from "@getpaseo/plugin/client";
2
+ import { useState, useEffect, useMemo } from "react";
3
+
4
+ export interface ProjectItem {
5
+ projectId: string;
6
+ projectDisplayName: string;
7
+ }
8
+
9
+ export type ProjectNameResolver = (projectId: string | null | undefined) => string | null;
10
+
11
+ export function createProjectNameResolver(projects: ProjectItem[]): ProjectNameResolver {
12
+ const index = new Map<string, string>();
13
+ for (const p of projects) {
14
+ index.set(p.projectId, p.projectDisplayName);
15
+ }
16
+ return (projectId: string | null | undefined): string | null => {
17
+ if (!projectId) return null;
18
+ return index.get(projectId) ?? projectId;
19
+ };
20
+ }
21
+
22
+ export function getProjectDisplayName(
23
+ projectId: string | null | undefined,
24
+ projectsOrResolver: ProjectItem[] | ProjectNameResolver
25
+ ): string | null {
26
+ if (!projectId) return null;
27
+ if (typeof projectsOrResolver === "function") {
28
+ return projectsOrResolver(projectId);
29
+ }
30
+ const match = projectsOrResolver.find((p) => p.projectId === projectId);
31
+ return match ? match.projectDisplayName : projectId;
32
+ }
33
+
34
+ export interface ProjectsState {
35
+ projects?: ProjectItem[];
36
+ isLoading?: boolean;
37
+ error?: string | null;
38
+ }
39
+
40
+ export function fetchProjectsWithOrderControl(onUpdate: (state: ProjectsState) => void) {
41
+ let latestRequestId = 0;
42
+ let isDestroyed = false;
43
+
44
+ return {
45
+ execute: async (fetchFn: () => Promise<any>): Promise<void> => {
46
+ const requestId = ++latestRequestId;
47
+ try {
48
+ const result = await fetchFn();
49
+ if (isDestroyed || requestId !== latestRequestId) {
50
+ return;
51
+ }
52
+ onUpdate({
53
+ projects: (result?.projects ?? []) as ProjectItem[],
54
+ error: null,
55
+ isLoading: false,
56
+ });
57
+ } catch (err) {
58
+ if (isDestroyed || requestId !== latestRequestId) {
59
+ return;
60
+ }
61
+ onUpdate({
62
+ error: err instanceof Error ? err.message : String(err),
63
+ isLoading: false,
64
+ });
65
+ }
66
+ },
67
+ destroy: () => {
68
+ isDestroyed = true;
69
+ },
70
+ };
71
+ }
72
+
73
+ export function useProjects() {
74
+ const paseo = usePaseo();
75
+ const [projects, setProjects] = useState<ProjectItem[]>([]);
76
+ const [isLoading, setIsLoading] = useState(true);
77
+ const [error, setError] = useState<string | null>(null);
78
+
79
+ useEffect(() => {
80
+ const controller = fetchProjectsWithOrderControl((update) => {
81
+ if (update.projects !== undefined) {
82
+ setProjects(update.projects);
83
+ }
84
+ if (update.error !== undefined) {
85
+ setError(update.error);
86
+ }
87
+ if (update.isLoading !== undefined) {
88
+ setIsLoading(update.isLoading);
89
+ }
90
+ });
91
+
92
+ controller.execute(() => paseo.projects.list());
93
+ const unsubscribe = paseo.projects.subscribe(() => {
94
+ controller.execute(() => paseo.projects.list());
95
+ });
96
+
97
+ return () => {
98
+ controller.destroy();
99
+ unsubscribe();
100
+ };
101
+ }, [paseo]);
102
+
103
+ const resolveProjectName = useMemo(() => createProjectNameResolver(projects), [projects]);
104
+
105
+ return {
106
+ projects,
107
+ resolveProjectName,
108
+ isLoading,
109
+ isError: error !== null,
110
+ error,
111
+ };
112
+ }
@@ -0,0 +1,33 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { KanbanBoardView } from "./client/kanban-board";
3
+ import { t, getLanguage, subscribeLanguage } from "./client/i18n";
4
+
5
+ export default function contribute(client: PluginClientContext) {
6
+ client.addSurface("kanban", KanbanBoardView);
7
+
8
+ let currentLang = getLanguage();
9
+ let removeSidebar = client.addSidebarItem({
10
+ id: "kanban",
11
+ title: t("kanban.sidebarTitle", currentLang),
12
+ icon: "PanelsTopLeft",
13
+ surface: "kanban",
14
+ });
15
+
16
+ const unsubscribe = subscribeLanguage((newLang) => {
17
+ if (newLang !== currentLang) {
18
+ currentLang = newLang;
19
+ removeSidebar();
20
+ removeSidebar = client.addSidebarItem({
21
+ id: "kanban",
22
+ title: t("kanban.sidebarTitle", newLang),
23
+ icon: "PanelsTopLeft",
24
+ surface: "kanban",
25
+ });
26
+ }
27
+ });
28
+
29
+ return () => {
30
+ unsubscribe();
31
+ removeSidebar();
32
+ };
33
+ }
@@ -0,0 +1,7 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { kanbanSettings } from "./shared/kanban";
3
+
4
+ export default function contribute(server: PluginServerContext) {
5
+ server.registerSettings(kanbanSettings);
6
+ return () => {};
7
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "paseo-kanban",
3
+ "version": "0.1.0",
4
+ "description": "A lightweight, responsive Kanban board plugin for Paseo workspaces",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "breathi3552",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/breathi3552/paseo-kanban.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/breathi3552/paseo-kanban/issues"
14
+ },
15
+ "homepage": "https://github.com/breathi3552/paseo-kanban#readme",
16
+ "keywords": [
17
+ "paseo",
18
+ "paseo-plugin",
19
+ "kanban",
20
+ "board"
21
+ ],
22
+ "files": [
23
+ "paseo-plugin.json",
24
+ "index.client.tsx",
25
+ "index.server.ts",
26
+ "client/",
27
+ "shared/"
28
+ ],
29
+ "scripts": {
30
+ "test": "node --experimental-strip-types --test test/kanban.test.mjs test/kanban-session.test.mjs test/drag-hit-test.test.mjs test/kanban-drag.test.mjs test/kanban-card-drag.test.mjs test/projects.test.mjs test/i18n.test.mjs",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "devDependencies": {
34
+ "@getpaseo/plugin": "0.8.0",
35
+ "@types/react": "~19.2.0",
36
+ "react": "19.1.0",
37
+ "react-native": "0.81.5",
38
+ "typescript": "^5.9.3",
39
+ "zod": "^4.4.3"
40
+ }
41
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "paseo-kanban",
3
+ "requirements": {
4
+ "paseo": ">=0.8.0"
5
+ }
6
+ }