feed-the-machine 1.1.0 → 1.3.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 (92) hide show
  1. package/bin/generate-manifest.mjs +253 -0
  2. package/bin/install.mjs +372 -26
  3. package/docs/INBOX.md +233 -0
  4. package/ftm/SKILL.md +34 -0
  5. package/ftm-audit/SKILL.md +69 -0
  6. package/ftm-brainstorm/SKILL.md +51 -0
  7. package/ftm-browse/SKILL.md +39 -0
  8. package/ftm-capture/SKILL.md +370 -0
  9. package/ftm-capture.yml +4 -0
  10. package/ftm-codex-gate/SKILL.md +59 -0
  11. package/ftm-config/SKILL.md +35 -0
  12. package/ftm-council/SKILL.md +56 -0
  13. package/ftm-dashboard/SKILL.md +34 -0
  14. package/ftm-debug/SKILL.md +84 -0
  15. package/ftm-diagram/SKILL.md +44 -0
  16. package/ftm-executor/SKILL.md +97 -0
  17. package/ftm-git/SKILL.md +60 -0
  18. package/ftm-inbox/backend/__init__.py +0 -0
  19. package/ftm-inbox/backend/adapters/__init__.py +0 -0
  20. package/ftm-inbox/backend/adapters/_retry.py +64 -0
  21. package/ftm-inbox/backend/adapters/base.py +230 -0
  22. package/ftm-inbox/backend/adapters/freshservice.py +104 -0
  23. package/ftm-inbox/backend/adapters/gmail.py +125 -0
  24. package/ftm-inbox/backend/adapters/jira.py +136 -0
  25. package/ftm-inbox/backend/adapters/registry.py +192 -0
  26. package/ftm-inbox/backend/adapters/slack.py +110 -0
  27. package/ftm-inbox/backend/db/__init__.py +0 -0
  28. package/ftm-inbox/backend/db/connection.py +54 -0
  29. package/ftm-inbox/backend/db/schema.py +78 -0
  30. package/ftm-inbox/backend/executor/__init__.py +7 -0
  31. package/ftm-inbox/backend/executor/engine.py +149 -0
  32. package/ftm-inbox/backend/executor/step_runner.py +98 -0
  33. package/ftm-inbox/backend/main.py +103 -0
  34. package/ftm-inbox/backend/models/__init__.py +1 -0
  35. package/ftm-inbox/backend/models/unified_task.py +36 -0
  36. package/ftm-inbox/backend/planner/__init__.py +6 -0
  37. package/ftm-inbox/backend/planner/generator.py +127 -0
  38. package/ftm-inbox/backend/planner/schema.py +34 -0
  39. package/ftm-inbox/backend/requirements.txt +5 -0
  40. package/ftm-inbox/backend/routes/__init__.py +0 -0
  41. package/ftm-inbox/backend/routes/execute.py +186 -0
  42. package/ftm-inbox/backend/routes/health.py +52 -0
  43. package/ftm-inbox/backend/routes/inbox.py +68 -0
  44. package/ftm-inbox/backend/routes/plan.py +271 -0
  45. package/ftm-inbox/bin/launchagent.mjs +91 -0
  46. package/ftm-inbox/bin/setup.mjs +188 -0
  47. package/ftm-inbox/bin/start.sh +10 -0
  48. package/ftm-inbox/bin/status.sh +17 -0
  49. package/ftm-inbox/bin/stop.sh +8 -0
  50. package/ftm-inbox/config.example.yml +55 -0
  51. package/ftm-inbox/package-lock.json +2898 -0
  52. package/ftm-inbox/package.json +26 -0
  53. package/ftm-inbox/postcss.config.js +6 -0
  54. package/ftm-inbox/src/app.css +199 -0
  55. package/ftm-inbox/src/app.html +18 -0
  56. package/ftm-inbox/src/lib/api.ts +166 -0
  57. package/ftm-inbox/src/lib/components/ExecutionLog.svelte +81 -0
  58. package/ftm-inbox/src/lib/components/InboxFeed.svelte +143 -0
  59. package/ftm-inbox/src/lib/components/PlanStep.svelte +271 -0
  60. package/ftm-inbox/src/lib/components/PlanView.svelte +206 -0
  61. package/ftm-inbox/src/lib/components/StreamPanel.svelte +99 -0
  62. package/ftm-inbox/src/lib/components/TaskCard.svelte +190 -0
  63. package/ftm-inbox/src/lib/components/ui/EmptyState.svelte +63 -0
  64. package/ftm-inbox/src/lib/components/ui/KawaiiCard.svelte +86 -0
  65. package/ftm-inbox/src/lib/components/ui/PillButton.svelte +106 -0
  66. package/ftm-inbox/src/lib/components/ui/StatusBadge.svelte +67 -0
  67. package/ftm-inbox/src/lib/components/ui/StreamDrawer.svelte +149 -0
  68. package/ftm-inbox/src/lib/components/ui/ThemeToggle.svelte +80 -0
  69. package/ftm-inbox/src/lib/theme.ts +47 -0
  70. package/ftm-inbox/src/routes/+layout.svelte +76 -0
  71. package/ftm-inbox/src/routes/+page.svelte +401 -0
  72. package/ftm-inbox/static/favicon.png +0 -0
  73. package/ftm-inbox/svelte.config.js +12 -0
  74. package/ftm-inbox/tailwind.config.ts +63 -0
  75. package/ftm-inbox/tsconfig.json +13 -0
  76. package/ftm-inbox/vite.config.ts +6 -0
  77. package/ftm-intent/SKILL.md +44 -0
  78. package/ftm-manifest.json +3794 -0
  79. package/ftm-map/SKILL.md +50 -0
  80. package/ftm-mind/SKILL.md +173 -66
  81. package/ftm-pause/SKILL.md +43 -0
  82. package/ftm-researcher/SKILL.md +55 -0
  83. package/ftm-resume/SKILL.md +47 -0
  84. package/ftm-retro/SKILL.md +54 -0
  85. package/ftm-routine/SKILL.md +36 -0
  86. package/ftm-state/blackboard/capabilities.json +5 -0
  87. package/ftm-state/blackboard/capabilities.schema.json +27 -0
  88. package/ftm-upgrade/SKILL.md +41 -0
  89. package/hooks/ftm-blackboard-enforcer.sh +28 -27
  90. package/hooks/ftm-plan-gate.sh +21 -25
  91. package/install.sh +238 -111
  92. package/package.json +6 -2
@@ -0,0 +1,190 @@
1
+ <script lang="ts">
2
+ import { createEventDispatcher } from 'svelte';
3
+ import { fly } from 'svelte/transition';
4
+ import StatusBadge from './ui/StatusBadge.svelte';
5
+ import PillButton from './ui/PillButton.svelte';
6
+ import type { UnifiedTask } from '$lib/api';
7
+
8
+ export let task: UnifiedTask;
9
+ export let selected = false;
10
+
11
+ const dispatch = createEventDispatcher<{
12
+ select: UnifiedTask;
13
+ generatePlan: UnifiedTask;
14
+ }>();
15
+
16
+ const sourceColors: Record<string, string> = {
17
+ jira: '#bbdefb',
18
+ freshservice: '#c8e6c9',
19
+ slack: '#e1bee7',
20
+ gmail: '#ffcdd2'
21
+ };
22
+
23
+ const sourceTextColors: Record<string, string> = {
24
+ jira: '#0d47a1',
25
+ freshservice: '#1b5e20',
26
+ slack: '#4a148c',
27
+ gmail: '#b71c1c'
28
+ };
29
+
30
+ function relativeTime(dateStr: string | null): string {
31
+ if (!dateStr) return '';
32
+ const now = Date.now();
33
+ const then = new Date(dateStr).getTime();
34
+ if (isNaN(then)) return '';
35
+ const diff = Math.floor((now - then) / 1000);
36
+ if (diff < 60) return 'just now';
37
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
38
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
39
+ return `${Math.floor(diff / 86400)}d ago`;
40
+ }
41
+
42
+ function mapStatus(s: string): 'pending' | 'planning' | 'approved' | 'executing' | 'complete' | 'failed' {
43
+ const map: Record<string, 'pending' | 'planning' | 'approved' | 'executing' | 'complete' | 'failed'> = {
44
+ open: 'pending',
45
+ pending: 'planning',
46
+ resolved: 'complete',
47
+ closed: 'complete',
48
+ };
49
+ return map[s] ?? 'pending';
50
+ }
51
+
52
+ const priorityIndicator: Record<string, string> = {
53
+ low: '○',
54
+ medium: '◑',
55
+ high: '●',
56
+ urgent: '◉'
57
+ };
58
+ </script>
59
+
60
+ <button
61
+ class="task-card"
62
+ class:selected
63
+ on:click={() => dispatch('select', task)}
64
+ transition:fly={{ x: -20, duration: 200 }}
65
+ >
66
+ <div class="card-top">
67
+ <span
68
+ class="source-badge"
69
+ style="background: {sourceColors[task.source] ?? '#e0e0e0'}; color: {sourceTextColors[task.source] ?? '#333'}"
70
+ >
71
+ {task.source}
72
+ </span>
73
+ <span class="card-time">{relativeTime(task.ingested_at ?? task.created_at)}</span>
74
+ </div>
75
+
76
+ <p class="card-title">{task.title}</p>
77
+
78
+ <div class="card-meta">
79
+ {#if task.priority}
80
+ <span class="priority" title={task.priority}>
81
+ {priorityIndicator[task.priority] ?? '○'} {task.priority}
82
+ </span>
83
+ {/if}
84
+ {#if task.assignee}
85
+ <span class="assignee" title="Assignee: {task.assignee}">
86
+ {task.assignee}
87
+ </span>
88
+ {/if}
89
+ </div>
90
+
91
+ <div class="card-bottom">
92
+ <StatusBadge status={mapStatus(task.status)} />
93
+ <PillButton
94
+ variant="primary"
95
+ size="sm"
96
+ on:click={(e) => { e.stopPropagation(); dispatch('generatePlan', task); }}
97
+ >
98
+
99
+ Generate Plan
100
+ </PillButton>
101
+ </div>
102
+ </button>
103
+
104
+ <style>
105
+ .task-card {
106
+ display: block;
107
+ width: 100%;
108
+ text-align: left;
109
+ background: var(--bg-card);
110
+ border: 2px solid var(--border-card);
111
+ border-radius: 12px;
112
+ padding: 0.65rem 0.75rem;
113
+ cursor: pointer;
114
+ transition:
115
+ border-color 0.15s ease,
116
+ box-shadow 0.15s ease,
117
+ transform 0.15s cubic-bezier(0.68, -0.55, 0.265, 1.55);
118
+ font-family: 'Nunito', sans-serif;
119
+ }
120
+
121
+ .task-card:hover {
122
+ border-color: var(--accent-primary);
123
+ transform: translateX(2px);
124
+ }
125
+
126
+ .task-card.selected {
127
+ border-color: var(--accent-primary);
128
+ box-shadow: var(--shadow-card-hover);
129
+ background: var(--bg-secondary);
130
+ }
131
+
132
+ .card-top {
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: space-between;
136
+ margin-bottom: 0.3rem;
137
+ }
138
+
139
+ .source-badge {
140
+ font-size: 0.65rem;
141
+ font-weight: 800;
142
+ text-transform: uppercase;
143
+ letter-spacing: 0.06em;
144
+ padding: 2px 8px;
145
+ border-radius: 9999px;
146
+ }
147
+
148
+ .card-time {
149
+ font-size: 0.68rem;
150
+ color: var(--text-muted);
151
+ }
152
+
153
+ .card-title {
154
+ font-size: 0.8rem;
155
+ font-weight: 700;
156
+ color: var(--text-primary);
157
+ margin: 0 0 0.3rem;
158
+ line-height: 1.35;
159
+ display: -webkit-box;
160
+ -webkit-line-clamp: 2;
161
+ -webkit-box-orient: vertical;
162
+ overflow: hidden;
163
+ }
164
+
165
+ .card-meta {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 0.5rem;
169
+ margin-bottom: 0.4rem;
170
+ font-size: 0.68rem;
171
+ color: var(--text-muted);
172
+ }
173
+
174
+ .priority {
175
+ font-weight: 700;
176
+ }
177
+
178
+ .assignee {
179
+ max-width: 120px;
180
+ overflow: hidden;
181
+ text-overflow: ellipsis;
182
+ white-space: nowrap;
183
+ }
184
+
185
+ .card-bottom {
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: space-between;
189
+ }
190
+ </style>
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ export let emoji = '🌱';
3
+ export let title = 'Nothing here yet';
4
+ export let message = 'Items will appear here when they arrive.';
5
+ </script>
6
+
7
+ <div class="empty-state">
8
+ <div class="empty-emoji" aria-hidden="true">{emoji}</div>
9
+ <p class="empty-title">{title}</p>
10
+ <p class="empty-message">{message}</p>
11
+ {#if $$slots.action}
12
+ <div class="empty-action">
13
+ <slot name="action" />
14
+ </div>
15
+ {/if}
16
+ </div>
17
+
18
+ <style>
19
+ .empty-state {
20
+ display: flex;
21
+ flex-direction: column;
22
+ align-items: center;
23
+ justify-content: center;
24
+ gap: 0.5rem;
25
+ padding: 2.5rem 1rem;
26
+ text-align: center;
27
+ color: var(--text-muted);
28
+ min-height: 120px;
29
+ }
30
+
31
+ .empty-emoji {
32
+ font-size: 2.5rem;
33
+ line-height: 1;
34
+ margin-bottom: 0.25rem;
35
+ filter: saturate(0.7) opacity(0.8);
36
+ animation: gentle-float 3s ease-in-out infinite;
37
+ }
38
+
39
+ .empty-title {
40
+ font-family: 'Nunito', sans-serif;
41
+ font-weight: 700;
42
+ font-size: 0.95rem;
43
+ color: var(--text-secondary);
44
+ margin: 0;
45
+ }
46
+
47
+ .empty-message {
48
+ font-size: 0.8rem;
49
+ color: var(--text-muted);
50
+ margin: 0;
51
+ max-width: 200px;
52
+ line-height: 1.5;
53
+ }
54
+
55
+ .empty-action {
56
+ margin-top: 0.75rem;
57
+ }
58
+
59
+ @keyframes gentle-float {
60
+ 0%, 100% { transform: translateY(0px); }
61
+ 50% { transform: translateY(-5px); }
62
+ }
63
+ </style>
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ export let accent: 'green' | 'yellow' | 'blue' | 'coral' | 'teal' | 'orange' = 'green';
3
+ export let hoverable = false;
4
+ export let compact = false;
5
+
6
+ const accentColors: Record<typeof accent, string> = {
7
+ green: '#4caf50',
8
+ yellow: '#ffd54f',
9
+ blue: '#42a5f5',
10
+ coral: '#ff7043',
11
+ teal: '#26a69a',
12
+ orange: '#ff9800'
13
+ };
14
+
15
+ $: borderColor = accentColors[accent];
16
+ </script>
17
+
18
+ <div
19
+ class="kawaii-card"
20
+ class:hoverable
21
+ class:compact
22
+ style="--card-accent: {borderColor}"
23
+ >
24
+ {#if $$slots.header}
25
+ <div class="card-header">
26
+ <slot name="header" />
27
+ </div>
28
+ {/if}
29
+
30
+ <div class="card-body">
31
+ <slot />
32
+ </div>
33
+
34
+ {#if $$slots.footer}
35
+ <div class="card-footer">
36
+ <slot name="footer" />
37
+ </div>
38
+ {/if}
39
+ </div>
40
+
41
+ <style>
42
+ .kawaii-card {
43
+ background: var(--bg-card);
44
+ border: 2px solid var(--border-card);
45
+ border-left: 4px solid var(--card-accent);
46
+ border-radius: 16px;
47
+ box-shadow: var(--shadow-card);
48
+ transition:
49
+ box-shadow 0.2s ease,
50
+ transform 0.2s cubic-bezier(0.68, -0.55, 0.265, 1.55),
51
+ border-color 0.2s ease;
52
+ overflow: hidden;
53
+ }
54
+
55
+ .kawaii-card.hoverable:hover {
56
+ box-shadow: var(--shadow-card-hover);
57
+ transform: translateY(-2px);
58
+ border-color: var(--card-accent);
59
+ }
60
+
61
+ .kawaii-card.hoverable:active {
62
+ transform: translateY(0);
63
+ }
64
+
65
+ .card-header {
66
+ padding: 12px 16px 8px;
67
+ border-bottom: 1px solid var(--border-card);
68
+ }
69
+
70
+ .card-body {
71
+ padding: 16px;
72
+ }
73
+
74
+ .compact .card-body {
75
+ padding: 10px 14px;
76
+ }
77
+
78
+ .compact .card-header {
79
+ padding: 8px 14px 6px;
80
+ }
81
+
82
+ .card-footer {
83
+ padding: 8px 16px 12px;
84
+ border-top: 1px solid var(--border-card);
85
+ }
86
+ </style>
@@ -0,0 +1,106 @@
1
+ <script lang="ts">
2
+ export let variant: 'primary' | 'ghost' | 'danger' = 'primary';
3
+ export let size: 'sm' | 'md' | 'lg' = 'md';
4
+ export let disabled = false;
5
+ export let type: 'button' | 'submit' | 'reset' = 'button';
6
+
7
+ const sizeClasses = {
8
+ sm: 'btn-sm',
9
+ md: 'btn-md',
10
+ lg: 'btn-lg'
11
+ };
12
+ </script>
13
+
14
+ <button
15
+ {type}
16
+ {disabled}
17
+ class="pill-btn pill-btn-{variant} {sizeClasses[size]}"
18
+ on:click
19
+ on:mouseenter
20
+ on:mouseleave
21
+ >
22
+ {#if $$slots.icon}
23
+ <span class="btn-icon" aria-hidden="true">
24
+ <slot name="icon" />
25
+ </span>
26
+ {/if}
27
+ <slot />
28
+ </button>
29
+
30
+ <style>
31
+ .pill-btn {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 0.4rem;
35
+ border-radius: 9999px;
36
+ font-family: 'Nunito', sans-serif;
37
+ font-weight: 700;
38
+ cursor: pointer;
39
+ border: none;
40
+ transition:
41
+ transform 0.18s cubic-bezier(0.68, -0.55, 0.265, 1.55),
42
+ box-shadow 0.18s ease,
43
+ background-color 0.18s ease,
44
+ color 0.18s ease,
45
+ border-color 0.18s ease;
46
+ user-select: none;
47
+ white-space: nowrap;
48
+ line-height: 1;
49
+ }
50
+
51
+ .pill-btn:disabled {
52
+ opacity: 0.45;
53
+ cursor: not-allowed;
54
+ transform: none !important;
55
+ }
56
+
57
+ .pill-btn:not(:disabled):hover {
58
+ transform: scale(1.06) translateY(-1px);
59
+ }
60
+
61
+ .pill-btn:not(:disabled):active {
62
+ transform: scale(0.96) translateY(0);
63
+ }
64
+
65
+ /* Sizes */
66
+ .btn-sm { padding: 0.35rem 0.9rem; font-size: 0.75rem; }
67
+ .btn-md { padding: 0.5rem 1.25rem; font-size: 0.875rem; }
68
+ .btn-lg { padding: 0.65rem 1.6rem; font-size: 1rem; }
69
+
70
+ /* Variants */
71
+ .pill-btn-primary {
72
+ background-color: var(--accent-primary);
73
+ color: #fff;
74
+ box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3);
75
+ }
76
+ .pill-btn-primary:not(:disabled):hover {
77
+ background-color: var(--accent-hover);
78
+ box-shadow: 0 4px 16px rgba(76, 175, 80, 0.4);
79
+ }
80
+
81
+ .pill-btn-ghost {
82
+ background-color: transparent;
83
+ color: var(--text-secondary);
84
+ border: 2px solid var(--border-card);
85
+ }
86
+ .pill-btn-ghost:not(:disabled):hover {
87
+ border-color: var(--accent-primary);
88
+ color: var(--accent-primary);
89
+ background-color: rgba(76, 175, 80, 0.06);
90
+ }
91
+
92
+ .pill-btn-danger {
93
+ background-color: #ef5350;
94
+ color: #fff;
95
+ box-shadow: 0 2px 8px rgba(239, 83, 80, 0.25);
96
+ }
97
+ .pill-btn-danger:not(:disabled):hover {
98
+ background-color: #c62828;
99
+ box-shadow: 0 4px 16px rgba(239, 83, 80, 0.35);
100
+ }
101
+
102
+ .btn-icon {
103
+ display: flex;
104
+ align-items: center;
105
+ }
106
+ </style>
@@ -0,0 +1,67 @@
1
+ <script context="module" lang="ts">
2
+ export type Status =
3
+ | 'pending'
4
+ | 'planning'
5
+ | 'approved'
6
+ | 'executing'
7
+ | 'complete'
8
+ | 'failed';
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ export let status: Status = 'pending';
13
+ export let label: string | undefined = undefined;
14
+
15
+ const labels: Record<Status, string> = {
16
+ pending: 'Pending',
17
+ planning: 'Planning',
18
+ approved: 'Approved',
19
+ executing: 'Executing',
20
+ complete: 'Complete',
21
+ failed: 'Failed'
22
+ };
23
+
24
+ const dots: Record<Status, string> = {
25
+ pending: '○',
26
+ planning: '◑',
27
+ approved: '●',
28
+ executing: '◉',
29
+ complete: '✓',
30
+ failed: '✕'
31
+ };
32
+
33
+ $: displayLabel = label ?? labels[status];
34
+ </script>
35
+
36
+ <span class="badge badge-{status}" role="status">
37
+ <span class="dot" aria-hidden="true">{dots[status]}</span>
38
+ {displayLabel}
39
+ </span>
40
+
41
+ <style>
42
+ .badge {
43
+ display: inline-flex;
44
+ align-items: center;
45
+ gap: 0.3rem;
46
+ padding: 4px 12px;
47
+ border-radius: 12px;
48
+ font-family: 'Nunito', sans-serif;
49
+ font-weight: 700;
50
+ font-size: 0.75rem;
51
+ letter-spacing: 0.02em;
52
+ white-space: nowrap;
53
+ user-select: none;
54
+ }
55
+
56
+ .dot {
57
+ font-size: 0.7rem;
58
+ line-height: 1;
59
+ }
60
+
61
+ .badge-pending { background: #fff9c4; color: #5d4037; }
62
+ .badge-planning { background: #bbdefb; color: #0d47a1; }
63
+ .badge-approved { background: #c8e6c9; color: #1b5e20; }
64
+ .badge-executing { background: #ffe0b2; color: #bf360c; }
65
+ .badge-complete { background: #b2dfdb; color: #004d40; }
66
+ .badge-failed { background: #ffcdd2; color: #b71c1c; }
67
+ </style>
@@ -0,0 +1,149 @@
1
+ <script lang="ts">
2
+ export let open = false;
3
+ export let height = 200;
4
+ export let title = 'Agent Output';
5
+
6
+ export let lines: string[] = [];
7
+
8
+ function toggle() {
9
+ open = !open;
10
+ }
11
+
12
+ let logEl: HTMLDivElement;
13
+
14
+ $: if (logEl && lines) {
15
+ // Auto-scroll to bottom when new lines arrive
16
+ setTimeout(() => {
17
+ if (logEl) logEl.scrollTop = logEl.scrollHeight;
18
+ }, 0);
19
+ }
20
+ </script>
21
+
22
+ <div class="stream-drawer" class:open>
23
+ <button class="drawer-toggle" on:click={toggle} aria-expanded={open}>
24
+ <span class="toggle-icon" aria-hidden="true">{open ? '▼' : '▲'}</span>
25
+ <span class="toggle-title">{title}</span>
26
+ {#if lines.length > 0}
27
+ <span class="line-count">{lines.length} lines</span>
28
+ {/if}
29
+ </button>
30
+
31
+ {#if open}
32
+ <div class="drawer-body" style="height: {height}px" bind:this={logEl}>
33
+ {#if lines.length === 0}
34
+ <p class="drawer-empty">Waiting for agent output...</p>
35
+ {:else}
36
+ {#each lines as line, i (i)}
37
+ <div class="log-line" class:log-error={line.startsWith('ERROR')} class:log-success={line.startsWith('OK') || line.startsWith('✓')}>
38
+ <span class="log-index">{String(i + 1).padStart(3, '0')}</span>
39
+ <span class="log-text">{line}</span>
40
+ </div>
41
+ {/each}
42
+ {/if}
43
+ </div>
44
+ {/if}
45
+ </div>
46
+
47
+ <style>
48
+ .stream-drawer {
49
+ position: fixed;
50
+ bottom: 0;
51
+ left: 0;
52
+ right: 0;
53
+ background: var(--bg-drawer);
54
+ border-top: 2px solid var(--border-accent);
55
+ z-index: 100;
56
+ transition: box-shadow 0.2s ease;
57
+ }
58
+
59
+ .stream-drawer.open {
60
+ box-shadow: 0 -4px 24px rgba(76, 175, 80, 0.15);
61
+ }
62
+
63
+ .drawer-toggle {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 0.5rem;
67
+ width: 100%;
68
+ padding: 0.5rem 1rem;
69
+ background: none;
70
+ border: none;
71
+ cursor: pointer;
72
+ font-family: 'Nunito', sans-serif;
73
+ font-weight: 700;
74
+ font-size: 0.8rem;
75
+ color: var(--text-secondary);
76
+ transition: color 0.15s ease, background 0.15s ease;
77
+ }
78
+
79
+ .drawer-toggle:hover {
80
+ color: var(--accent-primary);
81
+ background: rgba(76, 175, 80, 0.04);
82
+ }
83
+
84
+ .toggle-icon {
85
+ font-size: 0.65rem;
86
+ }
87
+
88
+ .toggle-title {
89
+ flex: 1;
90
+ text-align: left;
91
+ letter-spacing: 0.05em;
92
+ text-transform: uppercase;
93
+ font-size: 0.7rem;
94
+ }
95
+
96
+ .line-count {
97
+ font-size: 0.7rem;
98
+ color: var(--text-muted);
99
+ background: var(--border-card);
100
+ padding: 2px 8px;
101
+ border-radius: 9999px;
102
+ }
103
+
104
+ .drawer-body {
105
+ overflow-y: auto;
106
+ font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
107
+ font-size: 0.75rem;
108
+ line-height: 1.6;
109
+ padding: 0.5rem 0;
110
+ scrollbar-width: thin;
111
+ }
112
+
113
+ .drawer-empty {
114
+ padding: 0.75rem 1rem;
115
+ color: var(--text-muted);
116
+ font-family: 'Nunito', sans-serif;
117
+ font-style: italic;
118
+ font-size: 0.8rem;
119
+ margin: 0;
120
+ }
121
+
122
+ .log-line {
123
+ display: flex;
124
+ gap: 0.75rem;
125
+ padding: 0.1rem 1rem;
126
+ color: var(--text-secondary);
127
+ transition: background 0.1s;
128
+ }
129
+
130
+ .log-line:hover {
131
+ background: rgba(76, 175, 80, 0.04);
132
+ }
133
+
134
+ .log-index {
135
+ color: var(--text-muted);
136
+ user-select: none;
137
+ min-width: 2rem;
138
+ text-align: right;
139
+ }
140
+
141
+ .log-text {
142
+ white-space: pre-wrap;
143
+ word-break: break-word;
144
+ flex: 1;
145
+ }
146
+
147
+ .log-error .log-text { color: #ef5350; }
148
+ .log-success .log-text { color: var(--accent-primary); }
149
+ </style>