@andypai/agent-kanban 0.2.0 → 0.3.1
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.
- package/README.md +120 -24
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +16 -10
- package/src/__tests__/api.test.ts +99 -3
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +7 -14
- package/src/__tests__/commands/bulk.test.ts +3 -3
- package/src/__tests__/commands/column.test.ts +4 -4
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/db.test.ts +2 -2
- package/src/__tests__/id.test.ts +1 -1
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +180 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +488 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/metrics.test.ts +2 -2
- package/src/__tests__/output.test.ts +1 -1
- package/src/__tests__/provider-capabilities.test.ts +40 -0
- package/src/__tests__/server.test.ts +291 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +2 -12
- package/src/api.ts +156 -21
- package/src/commands/board.ts +4 -14
- package/src/commands/bulk.ts +4 -4
- package/src/commands/column.ts +4 -4
- package/src/commands/mcp.ts +87 -0
- package/src/config.ts +1 -1
- package/src/db.ts +118 -6
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +83 -35
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/metrics.ts +1 -1
- package/src/output.ts +1 -1
- package/src/providers/capabilities.ts +22 -17
- package/src/providers/errors.ts +1 -1
- package/src/providers/index.ts +36 -6
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +773 -0
- package/src/providers/linear-cache.ts +250 -71
- package/src/providers/linear-client.ts +255 -15
- package/src/providers/linear.ts +338 -20
- package/src/providers/local.ts +74 -23
- package/src/providers/types.ts +19 -3
- package/src/server.ts +141 -13
- package/src/tunnel.ts +79 -0
- package/src/types.ts +19 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
package/src/server.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { handleRequest } from './api
|
|
3
|
+
import { handleRequest } from './api'
|
|
4
4
|
import type { ServerWebSocket } from 'bun'
|
|
5
|
-
import type { KanbanProvider } from './providers/types
|
|
5
|
+
import type { KanbanProvider } from './providers/types'
|
|
6
6
|
|
|
7
7
|
const wsClients = new Set<ServerWebSocket<unknown>>()
|
|
8
8
|
const CORS_HEADERS = {
|
|
@@ -10,6 +10,25 @@ const CORS_HEADERS = {
|
|
|
10
10
|
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
|
|
11
11
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
12
12
|
}
|
|
13
|
+
const DEFAULT_BACKGROUND_SYNC_INTERVAL_MS = 30_000
|
|
14
|
+
|
|
15
|
+
interface BackgroundSyncState {
|
|
16
|
+
enabled: boolean
|
|
17
|
+
inFlight: boolean
|
|
18
|
+
warm: boolean
|
|
19
|
+
lastAttemptAt: string | null
|
|
20
|
+
lastSuccessAt: string | null
|
|
21
|
+
lastError: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StartServerOptions {
|
|
25
|
+
syncIntervalMs?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface StartedServer {
|
|
29
|
+
port: number
|
|
30
|
+
stop(closeActiveConnections?: boolean): void
|
|
31
|
+
}
|
|
13
32
|
|
|
14
33
|
function broadcast(data: unknown): void {
|
|
15
34
|
const msg = JSON.stringify(data)
|
|
@@ -24,11 +43,73 @@ function applyCorsHeaders(response: Response): void {
|
|
|
24
43
|
}
|
|
25
44
|
}
|
|
26
45
|
|
|
27
|
-
|
|
46
|
+
function jsonWithCors(body: unknown, status = 200): Response {
|
|
47
|
+
const response = Response.json(body, { status })
|
|
48
|
+
applyCorsHeaders(response)
|
|
49
|
+
return response
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function errorMessage(err: unknown): string {
|
|
53
|
+
return err instanceof Error ? err.message : String(err)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function nowIso(): string {
|
|
57
|
+
return new Date().toISOString()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function startServer(
|
|
61
|
+
provider: KanbanProvider,
|
|
62
|
+
port: number,
|
|
63
|
+
opts: StartServerOptions = {},
|
|
64
|
+
): StartedServer {
|
|
28
65
|
const distDir = join(import.meta.dir, '..', 'ui', 'dist')
|
|
29
66
|
const hasStatic = existsSync(distDir)
|
|
67
|
+
const syncIntervalMs = opts.syncIntervalMs ?? DEFAULT_BACKGROUND_SYNC_INTERVAL_MS
|
|
68
|
+
const syncCache = provider.syncCache?.bind(provider)
|
|
69
|
+
const getSyncStatus = provider.getSyncStatus?.bind(provider)
|
|
70
|
+
const backgroundSync: BackgroundSyncState = {
|
|
71
|
+
enabled: typeof syncCache === 'function',
|
|
72
|
+
inFlight: false,
|
|
73
|
+
warm: typeof syncCache !== 'function',
|
|
74
|
+
lastAttemptAt: null,
|
|
75
|
+
lastSuccessAt: null,
|
|
76
|
+
lastError: null,
|
|
77
|
+
}
|
|
78
|
+
let closed = false
|
|
79
|
+
let syncTimer: ReturnType<typeof setTimeout> | null = null
|
|
80
|
+
|
|
81
|
+
const runBackgroundSync = async (reason: 'startup' | 'interval'): Promise<void> => {
|
|
82
|
+
if (!syncCache || backgroundSync.inFlight || closed) return
|
|
83
|
+
backgroundSync.inFlight = true
|
|
84
|
+
backgroundSync.lastAttemptAt = nowIso()
|
|
85
|
+
try {
|
|
86
|
+
await syncCache()
|
|
87
|
+
backgroundSync.warm = true
|
|
88
|
+
backgroundSync.lastSuccessAt = nowIso()
|
|
89
|
+
backgroundSync.lastError = null
|
|
90
|
+
} catch (err) {
|
|
91
|
+
backgroundSync.lastError = errorMessage(err)
|
|
92
|
+
console.warn(`[server] background ${reason} sync failed:`, err)
|
|
93
|
+
} finally {
|
|
94
|
+
backgroundSync.inFlight = false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
30
97
|
|
|
31
|
-
|
|
98
|
+
const scheduleBackgroundSync = (): void => {
|
|
99
|
+
if (!syncCache || closed) return
|
|
100
|
+
syncTimer = setTimeout(async () => {
|
|
101
|
+
await runBackgroundSync('interval')
|
|
102
|
+
scheduleBackgroundSync()
|
|
103
|
+
}, syncIntervalMs)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (syncCache) {
|
|
107
|
+
void runBackgroundSync('startup').finally(() => {
|
|
108
|
+
scheduleBackgroundSync()
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const server = Bun.serve({
|
|
32
113
|
port,
|
|
33
114
|
websocket: {
|
|
34
115
|
open(ws) {
|
|
@@ -43,6 +124,9 @@ export function startServer(provider: KanbanProvider, port: number): void {
|
|
|
43
124
|
},
|
|
44
125
|
async fetch(req, server) {
|
|
45
126
|
const url = new URL(req.url)
|
|
127
|
+
const rawPath = url.pathname
|
|
128
|
+
const basePath = rawPath === '/kanban' || rawPath.startsWith('/kanban/') ? '/kanban' : ''
|
|
129
|
+
const pathname = basePath ? rawPath.slice(basePath.length) || '/' : rawPath
|
|
46
130
|
|
|
47
131
|
// Handle OPTIONS preflight first (before /api routing)
|
|
48
132
|
if (req.method === 'OPTIONS') {
|
|
@@ -50,31 +134,63 @@ export function startServer(provider: KanbanProvider, port: number): void {
|
|
|
50
134
|
}
|
|
51
135
|
|
|
52
136
|
// WebSocket upgrade
|
|
53
|
-
if (
|
|
137
|
+
if (pathname === '/ws') {
|
|
54
138
|
const upgraded = server.upgrade(req)
|
|
55
139
|
if (upgraded) return undefined as unknown as Response
|
|
56
140
|
return new Response('WebSocket upgrade failed', { status: 400 })
|
|
57
141
|
}
|
|
58
142
|
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
143
|
+
if (pathname === '/api/health') {
|
|
144
|
+
return jsonWithCors({
|
|
145
|
+
ok: true,
|
|
146
|
+
data: { status: 'running', wsClients: wsClients.size, provider: provider.type },
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (pathname === '/api/ready') {
|
|
151
|
+
const ready = backgroundSync.warm
|
|
152
|
+
return jsonWithCors(
|
|
153
|
+
{
|
|
154
|
+
ok: ready,
|
|
155
|
+
data: {
|
|
156
|
+
ready,
|
|
157
|
+
provider: provider.type,
|
|
158
|
+
backgroundSync,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
ready ? 200 : 503,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (pathname === '/api/sync-status') {
|
|
166
|
+
const providerSync = (await getSyncStatus?.()) ?? null
|
|
167
|
+
return jsonWithCors({
|
|
62
168
|
ok: true,
|
|
63
|
-
data: {
|
|
169
|
+
data: {
|
|
170
|
+
status: 'running',
|
|
171
|
+
provider: provider.type,
|
|
172
|
+
wsClients: wsClients.size,
|
|
173
|
+
backgroundSync,
|
|
174
|
+
providerSync,
|
|
175
|
+
},
|
|
64
176
|
})
|
|
65
177
|
}
|
|
66
178
|
|
|
67
|
-
if (
|
|
68
|
-
const
|
|
179
|
+
if (pathname.startsWith('/api/')) {
|
|
180
|
+
const forwardedUrl = new URL(req.url)
|
|
181
|
+
forwardedUrl.pathname = pathname
|
|
182
|
+
const forwardedReq = new Request(forwardedUrl.toString(), req)
|
|
183
|
+
const result = await handleRequest(provider, forwardedReq)
|
|
69
184
|
applyCorsHeaders(result.response)
|
|
70
185
|
if (result.mutated && result.response.ok) {
|
|
71
|
-
broadcast({ type: 'refresh' })
|
|
186
|
+
broadcast(result.event ?? { type: 'refresh' })
|
|
72
187
|
}
|
|
73
188
|
return result.response
|
|
74
189
|
}
|
|
75
190
|
|
|
76
191
|
if (hasStatic) {
|
|
77
|
-
const
|
|
192
|
+
const assetPath = pathname === '/' ? '/index.html' : pathname
|
|
193
|
+
const filePath = join(distDir, assetPath.replace(/^\//, ''))
|
|
78
194
|
const file = Bun.file(filePath)
|
|
79
195
|
if (await file.exists()) return new Response(file)
|
|
80
196
|
return new Response(Bun.file(join(distDir, 'index.html')))
|
|
@@ -88,4 +204,16 @@ export function startServer(provider: KanbanProvider, port: number): void {
|
|
|
88
204
|
})
|
|
89
205
|
|
|
90
206
|
console.info(`Dashboard running at http://localhost:${port}`)
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
port: server.port ?? port,
|
|
210
|
+
stop(closeActiveConnections = true) {
|
|
211
|
+
closed = true
|
|
212
|
+
if (syncTimer) {
|
|
213
|
+
clearTimeout(syncTimer)
|
|
214
|
+
syncTimer = null
|
|
215
|
+
}
|
|
216
|
+
server.stop(closeActiveConnections)
|
|
217
|
+
},
|
|
218
|
+
}
|
|
91
219
|
}
|
package/src/tunnel.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Subprocess } from 'bun'
|
|
2
|
+
|
|
3
|
+
const TRYCLOUDFLARE_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
4
|
+
|
|
5
|
+
export interface TunnelHandle {
|
|
6
|
+
process: Subprocess
|
|
7
|
+
stop: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TunnelOptions {
|
|
11
|
+
command?: string[]
|
|
12
|
+
onUrl?: (url: string) => void
|
|
13
|
+
log?: (message: string) => void
|
|
14
|
+
warn?: (message: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function startCloudflareTunnel(port: number, opts: TunnelOptions = {}): TunnelHandle {
|
|
18
|
+
const command = opts.command ?? [
|
|
19
|
+
'bunx',
|
|
20
|
+
'cloudflared',
|
|
21
|
+
'tunnel',
|
|
22
|
+
'--url',
|
|
23
|
+
`http://localhost:${port}`,
|
|
24
|
+
]
|
|
25
|
+
const log = opts.log ?? ((m: string) => console.info(m))
|
|
26
|
+
const warn = opts.warn ?? ((m: string) => console.warn(m))
|
|
27
|
+
|
|
28
|
+
let child: Subprocess
|
|
29
|
+
try {
|
|
30
|
+
child = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' })
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
33
|
+
warn(
|
|
34
|
+
`Failed to start cloudflared: ${msg}. Install it with 'brew install cloudflared' or see docs/providers/linear.md for setup.`,
|
|
35
|
+
)
|
|
36
|
+
throw err
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const stop = (): void => {
|
|
40
|
+
try {
|
|
41
|
+
child.kill()
|
|
42
|
+
} catch {
|
|
43
|
+
// best-effort teardown
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let announced = false
|
|
48
|
+
const announce = (url: string): void => {
|
|
49
|
+
if (announced) return
|
|
50
|
+
announced = true
|
|
51
|
+
log(`Public tunnel URL: ${url}`)
|
|
52
|
+
opts.onUrl?.(url)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const scanForUrl = async (
|
|
56
|
+
stream: ReadableStream<Uint8Array> | null | undefined,
|
|
57
|
+
): Promise<void> => {
|
|
58
|
+
if (!stream) return
|
|
59
|
+
const decoder = new TextDecoder()
|
|
60
|
+
for await (const chunk of stream) {
|
|
61
|
+
const text = decoder.decode(chunk as Uint8Array, { stream: true })
|
|
62
|
+
const match = text.match(TRYCLOUDFLARE_URL)
|
|
63
|
+
if (match) announce(match[0])
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
void scanForUrl(child.stdout as ReadableStream<Uint8Array>)
|
|
68
|
+
void scanForUrl(child.stderr as ReadableStream<Uint8Array>)
|
|
69
|
+
|
|
70
|
+
void child.exited.then((code) => {
|
|
71
|
+
if (!announced) {
|
|
72
|
+
warn(
|
|
73
|
+
`cloudflared exited (code ${code}) before a public URL was established. Is cloudflared installed? Try 'brew install cloudflared' or 'npm i -g cloudflared'.`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
return { process: child, stop }
|
|
79
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -20,8 +20,23 @@ export interface Task {
|
|
|
20
20
|
position: number
|
|
21
21
|
priority: Priority
|
|
22
22
|
assignee: string
|
|
23
|
+
assignees: string[]
|
|
24
|
+
labels: string[]
|
|
25
|
+
comment_count: number
|
|
23
26
|
project: string
|
|
24
27
|
metadata: string
|
|
28
|
+
revision?: number
|
|
29
|
+
created_at: string
|
|
30
|
+
updated_at: string
|
|
31
|
+
version: string | null
|
|
32
|
+
source_updated_at: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TaskComment {
|
|
36
|
+
id: string
|
|
37
|
+
task_id: string
|
|
38
|
+
body: string
|
|
39
|
+
author: string | null
|
|
25
40
|
created_at: string
|
|
26
41
|
updated_at: string
|
|
27
42
|
}
|
|
@@ -75,7 +90,7 @@ export interface ColumnTimeEntry {
|
|
|
75
90
|
export interface BoardConfig {
|
|
76
91
|
members: { name: string; role: 'human' | 'agent' }[]
|
|
77
92
|
projects: string[]
|
|
78
|
-
provider?: 'local' | 'linear'
|
|
93
|
+
provider?: 'local' | 'linear' | 'jira'
|
|
79
94
|
discoveredAssignees?: string[]
|
|
80
95
|
discoveredProjects?: string[]
|
|
81
96
|
}
|
|
@@ -99,6 +114,8 @@ export interface ProviderCapabilities {
|
|
|
99
114
|
taskUpdate: boolean
|
|
100
115
|
taskMove: boolean
|
|
101
116
|
taskDelete: boolean
|
|
117
|
+
comment: boolean
|
|
118
|
+
/** True when provider-backed bootstrap/dashboard activity is exposed, not merely cached internally. */
|
|
102
119
|
activity: boolean
|
|
103
120
|
metrics: boolean
|
|
104
121
|
columnCrud: boolean
|
|
@@ -113,7 +130,7 @@ export interface ProviderTeamInfo {
|
|
|
113
130
|
}
|
|
114
131
|
|
|
115
132
|
export interface BoardBootstrap {
|
|
116
|
-
provider: 'local' | 'linear'
|
|
133
|
+
provider: 'local' | 'linear' | 'jira'
|
|
117
134
|
capabilities: ProviderCapabilities
|
|
118
135
|
board: BoardView
|
|
119
136
|
config: BoardConfig
|
package/src/webhooks.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
3
|
+
|
|
4
|
+
export interface WebhookRequest {
|
|
5
|
+
headers: Record<string, string>
|
|
6
|
+
rawBody: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface WebhookResult {
|
|
10
|
+
handled: boolean
|
|
11
|
+
unauthorized?: boolean
|
|
12
|
+
message?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function verifyHmacSha256(
|
|
16
|
+
secret: string,
|
|
17
|
+
rawBody: string,
|
|
18
|
+
providedSignature: string | undefined | null,
|
|
19
|
+
encoding: 'hex' | 'base64' = 'hex',
|
|
20
|
+
): boolean {
|
|
21
|
+
if (!providedSignature) return false
|
|
22
|
+
const mac = createHmac('sha256', secret).update(rawBody).digest(encoding)
|
|
23
|
+
const expected = providedSignature.replace(/^sha256=/, '')
|
|
24
|
+
const macBuf = Buffer.from(mac)
|
|
25
|
+
const expBuf = Buffer.from(expected)
|
|
26
|
+
if (macBuf.length !== expBuf.length) return false
|
|
27
|
+
return timingSafeEqual(macBuf, expBuf)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function headerLower(headers: Record<string, string>, name: string): string | undefined {
|
|
31
|
+
const target = name.toLowerCase()
|
|
32
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
33
|
+
if (k.toLowerCase() === target) return v
|
|
34
|
+
}
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--black: #000000;--surface: #111111;--surface-raised: #1a1a1a;--border: #222222;--border-visible: #333333;--text-disabled: #666666;--text-secondary: #999999;--text-muted: var(--text-disabled);--text-primary: #e8e8e8;--text-display: #ffffff;--accent: #d71921;--accent-subtle: rgba(215, 25, 33, .15);--success: #4a9e5c;--warning: #d4a843;--interactive: #5b9bf6;--priority-urgent: #d71921;--priority-high: #d4a843;--priority-medium: #999999;--priority-low: #4a9e5c;--space-xs: 4px;--space-sm: 8px;--space-md: 16px;--space-lg: 24px;--space-xl: 32px;--space-2xl: 48px;--section-radius: 12px;--page-gutter: clamp(16px, 3vw, 32px);--safe-top: env(safe-area-inset-top, 0px);--safe-right: env(safe-area-inset-right, 0px);--safe-bottom: env(safe-area-inset-bottom, 0px);--safe-left: env(safe-area-inset-left, 0px)}html{background:var(--black)}body{font-family:Space Grotesk,system-ui,sans-serif;background:var(--black);color:var(--text-primary);min-height:100vh;overflow-x:hidden;font-weight:400;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}button,select,input,textarea{font-family:inherit}#root{width:100%;max-width:1800px;margin:0 auto;padding-top:calc(var(--space-lg) + var(--safe-top));padding-right:max(var(--page-gutter),var(--safe-right));padding-bottom:calc(var(--space-lg) + var(--safe-bottom));padding-left:max(var(--page-gutter),var(--safe-left))}.header{padding:0 0 var(--space-xl);border-bottom:1px solid var(--border);margin-bottom:var(--space-lg)}.headerTop{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-md);margin-bottom:var(--space-lg)}.headerIdentity{display:flex;flex-direction:column;gap:var(--space-sm);min-width:0}.headerTitleRow{display:flex;align-items:center;flex-wrap:wrap;gap:var(--space-sm)}.header h1{font-family:Space Grotesk,sans-serif;font-size:24px;font-weight:500;letter-spacing:-.02em;line-height:1.1;color:var(--text-display)}.header h1 span{color:var(--text-display)}.liveStatus,.providerBadge{display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:4px 12px;border-radius:999px;border:1px solid var(--border-visible);background:transparent;color:var(--text-secondary);font-family:Space Mono,monospace;font-size:11px;font-weight:400;letter-spacing:.04em;text-transform:uppercase}.providerBadge{max-width:fit-content}.newTaskBtn{min-height:44px;background:var(--text-display);color:var(--black);border:none;padding:10px 24px;border-radius:999px;font-family:Space Mono,monospace;font-size:13px;font-weight:400;letter-spacing:.06em;text-transform:uppercase;cursor:pointer;transition:opacity .15s;white-space:nowrap}.newTaskBtn:hover{opacity:.85}.statsBar{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--space-sm);margin-bottom:var(--space-lg)}.statCard{background:var(--surface);border:1px solid var(--border);border-radius:var(--section-radius);padding:var(--space-md);display:flex;flex-direction:column;gap:var(--space-xs);min-width:0}.statValue{font-family:Doto,Space Mono,monospace;font-size:36px;line-height:1;font-weight:400;color:var(--text-display);letter-spacing:-.02em}.statLabel{font-family:Space Mono,monospace;font-size:11px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.08em}.filterBar{display:flex;align-items:center;gap:var(--space-md);min-width:0}.filterLabel{font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--text-disabled);flex-shrink:0}.filterRow{display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-width:0;flex:1 1 auto}.filterGroup{display:flex;gap:6px;align-items:center;min-width:0}.filterScroller{display:flex;flex-wrap:wrap;gap:6px}.filterDivider{width:1px;align-self:stretch;background:var(--border-visible);margin:4px 6px}.filterBtn{min-height:36px;background:transparent;border:1px solid var(--border-visible);color:var(--text-secondary);padding:6px 16px;border-radius:999px;font-family:Space Mono,monospace;font-size:12px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;transition:all .15s;white-space:nowrap}.filterBtn:hover{border-color:var(--text-secondary);color:var(--text-primary)}.filterBtn.active{background:var(--text-display);border-color:var(--text-display);color:var(--black)}.filterSelect{min-height:36px;background:transparent;border:1px solid var(--border-visible);color:var(--text-secondary);padding:6px 32px 6px 14px;border-radius:999px;font-family:Space Mono,monospace;font-size:12px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;white-space:nowrap;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23666666' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;transition:border-color .15s,color .15s}.filterSelect:hover{border-color:var(--text-secondary);color:var(--text-primary)}.filterSelect:focus{outline:none;border-color:var(--text-primary);color:var(--text-primary)}.filterSelect.active{border-color:var(--text-display);color:var(--text-display)}.filterReset{background:transparent;border:none;color:var(--text-disabled);padding:6px 10px;font-family:Space Mono,monospace;font-size:11px;letter-spacing:.12em;text-transform:uppercase;cursor:pointer;transition:color .15s;margin-left:auto}.filterReset:hover{color:var(--text-primary)}.boardShell{min-height:calc(100vh - 280px - var(--safe-bottom))}.board{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(280px,1fr);gap:var(--space-sm);overflow-x:auto;overflow-y:visible;padding:var(--space-xs) 0 calc(var(--space-sm) + var(--safe-bottom));min-height:calc(100vh - 300px - var(--safe-bottom));scrollbar-width:thin;scrollbar-color:var(--border-visible) transparent;scroll-padding-inline:2px}.board:after{content:"";width:2px}.column{min-width:0;background:var(--surface);border:1px solid var(--border);border-radius:var(--section-radius);display:flex;flex-direction:column;min-height:100%}.columnHeader{padding:14px var(--space-md);border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:var(--space-sm)}.columnHeaderMain,.columnHeaderActions{display:flex;align-items:center;gap:var(--space-sm);min-width:0}.columnDot{width:6px;height:6px;border-radius:50%;flex-shrink:0;opacity:.7}.columnName{font-family:Space Mono,monospace;font-size:11px;font-weight:400;text-transform:uppercase;letter-spacing:.08em;color:var(--text-secondary);flex:1;min-width:0}.columnCount{background:transparent;border:1px solid var(--border);color:var(--text-disabled);padding:2px 8px;border-radius:999px;font-family:Space Mono,monospace;font-size:11px;min-width:24px;text-align:center}.columnAddBtn{min-width:32px;min-height:32px;background:transparent;border:1px solid var(--border);border-radius:999px;color:var(--text-disabled);cursor:pointer;font-size:16px;line-height:1;transition:all .15s}.columnAddBtn:hover{color:var(--text-primary);border-color:var(--text-secondary)}.columnBody{padding:var(--space-sm);display:flex;flex-direction:column;gap:6px;min-height:60px;flex:1}.emptyColumn{display:flex;align-items:center;justify-content:center;color:var(--text-disabled);font-family:Space Mono,monospace;font-size:11px;letter-spacing:.04em;text-transform:uppercase;flex:1;min-height:96px}.taskCard{width:100%;text-align:left;background:var(--surface-raised);border:1px solid var(--border);border-radius:8px;padding:14px var(--space-md);cursor:pointer;transition:border-color .15s}.taskCard:hover{border-color:var(--border-visible)}.taskCard.selected{border-color:var(--text-secondary)}.taskCardHeader{display:flex;align-items:flex-start;gap:var(--space-sm);margin-bottom:var(--space-xs)}.taskTitle{font-family:Space Grotesk,sans-serif;font-size:14px;font-weight:500;line-height:1.4;color:var(--text-primary);flex:1}.taskDescription{font-size:13px;color:var(--text-disabled);line-height:1.45;margin-bottom:var(--space-sm);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.taskFooter{display:flex;align-items:center;justify-content:space-between;gap:var(--space-sm)}.taskFooterLeft{display:flex;align-items:center;gap:var(--space-sm);min-width:0;flex-wrap:wrap}.assigneeAvatar{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:Space Mono,monospace;font-size:10px;font-weight:700;color:var(--black);background:var(--text-disabled);flex-shrink:0;border:1px solid var(--border-visible)}.assigneeName{font-family:Space Mono,monospace;font-size:11px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.projectTag{display:inline-flex;align-items:center;min-height:22px;font-family:Space Mono,monospace;font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:2px 10px;border-radius:999px;border:1px solid var(--border-visible);background:transparent;color:var(--text-secondary);white-space:nowrap}.taskLabels{display:flex;flex-wrap:wrap;gap:4px;margin:6px 0 0}.taskLabel{display:inline-flex;align-items:center;font-family:Space Mono,monospace;font-size:10px;padding:1px 8px;border-radius:4px;background:var(--bg-tertiary);color:var(--text-secondary);white-space:nowrap}.commentCount{font-family:Space Mono,monospace;font-size:11px;color:var(--text-secondary);white-space:nowrap}.timestamp{font-family:Space Mono,monospace;font-size:11px;color:var(--text-disabled);white-space:nowrap;flex-shrink:0}.priorityDot{width:6px;height:6px;border-radius:50%;flex-shrink:0;margin-top:5px}.priorityDot.urgent{background:var(--priority-urgent)}.priorityDot.high{background:var(--priority-high)}.priorityDot.medium{background:var(--priority-medium)}.priorityDot.low{background:var(--priority-low)}.taskDetailOverlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#000000b3;z-index:9}.taskDetail{position:fixed;top:0;right:0;width:min(420px,100vw);height:100vh;background:var(--surface);border-left:1px solid var(--border-visible);padding:calc(var(--space-xl) + var(--safe-top)) max(var(--space-lg),var(--safe-right)) calc(var(--space-xl) + var(--safe-bottom)) var(--space-lg);overflow-y:auto;z-index:10}.taskDetail .closeBtn{position:absolute;top:calc(var(--space-md) + var(--safe-top));right:max(var(--space-md),var(--safe-right));min-width:40px;min-height:40px;background:var(--surface-raised);border:1px solid var(--border-visible);color:var(--text-secondary);cursor:pointer;padding:4px 10px;border-radius:999px;font-size:18px;transition:all .15s}.taskDetail .closeBtn:hover{border-color:var(--text-secondary);color:var(--text-primary)}.detailTitle{font-family:Space Grotesk,sans-serif;font-size:18px;font-weight:500;color:var(--text-display);margin-bottom:var(--space-lg);padding-right:56px;line-height:1.4}.detailField{margin-bottom:var(--space-md)}.detailLabel{font-family:Space Mono,monospace;font-size:11px;color:var(--text-disabled);text-transform:uppercase;letter-spacing:.08em;margin-bottom:var(--space-xs)}.detailValue{font-family:Space Grotesk,sans-serif;font-size:14px;line-height:1.5;color:var(--text-primary)}.detailActions{display:flex;gap:var(--space-sm);margin-top:var(--space-xl);padding-top:var(--space-md);border-top:1px solid var(--border)}.detailSelect{background:var(--surface-raised);border:1px solid var(--border-visible);color:var(--text-primary);min-height:42px;padding:8px 14px;border-radius:8px;font-family:Space Grotesk,sans-serif;font-size:13px;flex:1}.detailSelect:focus{outline:none;border-color:var(--text-secondary)}.deleteBtn{background:transparent;border:1px solid var(--accent);color:var(--accent);min-height:42px;padding:8px 20px;border-radius:999px;font-family:Space Mono,monospace;font-size:12px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;transition:all .15s}.deleteBtn:hover{background:var(--accent-subtle)}.modalOverlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#000c;display:flex;align-items:center;justify-content:center;padding:calc(var(--space-md) + var(--safe-top)) max(var(--space-md),var(--safe-right)) calc(var(--space-md) + var(--safe-bottom)) max(var(--space-md),var(--safe-left));z-index:20}.modal{background:var(--surface);border:1px solid var(--border-visible);border-radius:16px;padding:var(--space-lg);width:480px;max-width:100%;max-height:100%;overflow-y:auto}.modal h2{font-family:Space Grotesk,sans-serif;font-size:18px;font-weight:500;color:var(--text-display);margin-bottom:var(--space-lg)}.formField{margin-bottom:var(--space-md)}.formLabel{display:block;font-family:Space Mono,monospace;font-size:11px;color:var(--text-disabled);text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}.formInput{width:100%;background:var(--surface-raised);border:1px solid var(--border-visible);color:var(--text-primary);min-height:42px;padding:10px 14px;border-radius:8px;font-family:Space Grotesk,sans-serif;font-size:14px}.formInput::placeholder{color:var(--text-disabled)}.formInput:focus{outline:none;border-color:var(--text-secondary)}textarea.formInput{resize:vertical;min-height:96px}select.formInput{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding-right:28px;background-image:url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%23666666' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}.formRow{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-sm)}.modalActions{display:flex;justify-content:flex-end;gap:var(--space-sm);margin-top:var(--space-lg)}.btnSecondary{background:transparent;border:1px solid var(--border-visible);color:var(--text-secondary);min-height:42px;padding:8px 24px;border-radius:999px;font-family:Space Mono,monospace;font-size:12px;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;transition:all .15s}.btnSecondary:hover{border-color:var(--text-secondary);color:var(--text-primary)}.btnPrimary{background:var(--text-display);border:none;color:var(--black);min-height:42px;padding:8px 24px;border-radius:999px;font-family:Space Mono,monospace;font-size:12px;font-weight:400;letter-spacing:.04em;text-transform:uppercase;cursor:pointer;transition:opacity .15s}.btnPrimary:hover{opacity:.85}.btnPrimary:disabled{opacity:.3;cursor:not-allowed}.appLayout{display:grid;grid-template-columns:minmax(0,1fr);gap:0;min-width:0}.loading{display:flex;align-items:center;justify-content:center;padding:var(--space-2xl);color:var(--text-disabled);font-family:Space Mono,monospace;font-size:12px;letter-spacing:.04em;text-transform:uppercase}.errorBanner{background:var(--accent-subtle);border:1px solid var(--accent);color:var(--accent);padding:10px var(--space-md);border-radius:8px;font-family:Space Mono,monospace;font-size:12px;margin-bottom:var(--space-md)}.wsIndicator{width:6px;height:6px;border-radius:50%;display:inline-block;flex-shrink:0}.wsIndicator.connected{background:var(--success)}.wsIndicator.disconnected{background:var(--text-disabled)}.mobileList{display:none}.mobileGroup{border-bottom:1px solid var(--border)}.mobileGroupHeader{display:flex;align-items:center;gap:var(--space-sm);min-height:48px;padding:0 var(--space-md);background:var(--surface)}.mobileGroupToggle{display:flex;align-items:center;gap:var(--space-sm);flex:1;min-height:48px;padding:12px 0;background:transparent;border:none;color:var(--text-primary);font:inherit;cursor:pointer;text-align:left}.mobileGroupToggle:hover,.mobileGroupToggle:focus-visible{color:var(--text-display)}.mobileGroupChevron{font-size:12px;color:var(--text-disabled);transition:transform .15s;width:16px;text-align:center;flex-shrink:0}.mobileGroupChevron.collapsed{transform:rotate(-90deg)}.mobileGroupDot{width:6px;height:6px;border-radius:50%;flex-shrink:0;opacity:.6}.mobileGroupName{font-family:Space Mono,monospace;font-size:11px;font-weight:400;text-transform:uppercase;letter-spacing:.08em;color:var(--text-secondary)}.mobileGroupCount{font-family:Space Mono,monospace;font-size:11px;color:var(--text-disabled);margin-right:auto}.mobileGroupAdd{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;padding:0;background:transparent;border:none;border-radius:999px;font-size:16px;color:var(--text-disabled);cursor:pointer;flex-shrink:0;transition:color .15s}.mobileGroupAdd:hover{color:var(--text-primary)}.mobileGroupBody{padding-bottom:var(--space-xs)}.mobileGroupEmpty{padding:var(--space-md) var(--space-md) var(--space-md) 46px;font-family:Space Mono,monospace;font-size:11px;color:var(--text-disabled);letter-spacing:.04em;text-transform:uppercase}.mobileTaskRow{display:flex;align-items:center;gap:10px;width:100%;min-height:48px;padding:12px var(--space-md);background:none;border:none;border-top:1px solid var(--border);color:var(--text-primary);font:inherit;cursor:pointer;text-align:left;transition:background .1s}.mobileTaskRow:hover,.mobileTaskRow:focus-visible{background:var(--surface)}.mobileTaskRow.selected{background:var(--surface);border-left:2px solid var(--text-display)}.mobileTaskRow .priorityDot{flex-shrink:0}.mobileTaskTitle{flex:1;min-width:0;font-family:Space Grotesk,sans-serif;font-size:14px;font-weight:400;line-height:1.35;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.mobileTaskProject{font-family:Space Mono,monospace;font-size:10px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-disabled);border:1px solid var(--border-visible);border-radius:999px;padding:2px 10px;white-space:nowrap;flex-shrink:0}.mobileTaskAvatar{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:Space Mono,monospace;font-size:10px;font-weight:700;color:var(--black);background:var(--text-disabled);flex-shrink:0;border:1px solid var(--border-visible)}@media(max-width:960px){.statsBar{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){:root{--page-gutter: 20px}#root{padding-top:max(var(--space-sm),var(--safe-top));padding-bottom:calc(var(--space-lg) + var(--safe-bottom));overflow-x:hidden}.header{padding:0;border-bottom:none;margin-bottom:var(--space-md)}.headerTop{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:var(--space-sm);margin-bottom:var(--space-md)}.headerIdentity{gap:6px}.headerTitleRow{align-items:center;gap:6px}.header h1{font-size:18px}.liveStatus,.providerBadge{min-height:24px;padding:3px 10px;font-size:10px}.newTaskBtn{min-width:44px;min-height:44px;padding:0 16px;align-self:start}.statsBar{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;margin-bottom:var(--space-md)}.statCard{padding:12px;gap:var(--space-xs)}.statValue{font-size:28px}.statLabel{font-size:10px}.filterBar{display:flex;flex-direction:column;align-items:stretch;gap:10px}.filterRow{gap:6px}.filterLabel{font-size:10px}.filterGroup{gap:6px}.filterBtn{min-height:34px;padding:6px 12px;font-size:11px}.filterSelect{min-height:34px;font-size:11px;padding:6px 28px 6px 12px;flex:1 1 140px}.filterDivider{display:none}.filterReset{margin-left:auto;padding:6px 4px}.boardShell{display:none}.mobileList{display:block;border-top:1px solid var(--border)}.taskDetailOverlay{position:fixed;top:0;right:0;bottom:0;left:0;z-index:9}.taskDetail{position:fixed;top:0;right:0;bottom:0;left:0;width:100vw;height:100vh;height:100dvh;border-left:none;padding:calc(var(--space-lg) + var(--safe-top)) max(var(--space-md),var(--safe-right)) calc(var(--space-lg) + var(--safe-bottom)) var(--space-md);z-index:10}.detailTitle{font-size:17px;margin-bottom:var(--space-md)}.detailActions{flex-direction:column}.deleteBtn,.detailSelect{width:100%}.modalOverlay{position:fixed;top:0;right:0;bottom:0;left:0;align-items:flex-end;padding:max(12px,var(--safe-top)) max(12px,var(--safe-right)) max(12px,var(--safe-bottom)) max(12px,var(--safe-left));z-index:20}.modal{width:100%;max-height:85vh;max-height:85dvh;overflow-y:auto;border-radius:16px;padding:var(--space-lg)}.formRow{grid-template-columns:1fr;gap:0}.modalActions{flex-direction:column-reverse}.modalActions .btnPrimary,.modalActions .btnSecondary{width:100%}}@media(max-width:520px){.mobileTaskProject{display:none}.headerTop{grid-template-columns:minmax(0,1fr)}.newTaskBtn{width:100%;text-align:center}}
|