@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.
Files changed (72) hide show
  1. package/README.md +120 -24
  2. package/package.json +4 -2
  3. package/src/__tests__/activity.test.ts +16 -10
  4. package/src/__tests__/api.test.ts +99 -3
  5. package/src/__tests__/board-utils.test.ts +100 -0
  6. package/src/__tests__/commands/board.test.ts +7 -14
  7. package/src/__tests__/commands/bulk.test.ts +3 -3
  8. package/src/__tests__/commands/column.test.ts +4 -4
  9. package/src/__tests__/conflict.test.ts +64 -0
  10. package/src/__tests__/db.test.ts +2 -2
  11. package/src/__tests__/id.test.ts +1 -1
  12. package/src/__tests__/index.test.ts +233 -56
  13. package/src/__tests__/jira-adf.test.ts +180 -0
  14. package/src/__tests__/jira-cache.test.ts +304 -0
  15. package/src/__tests__/jira-client.test.ts +169 -0
  16. package/src/__tests__/jira-provider-comment.test.ts +281 -0
  17. package/src/__tests__/jira-provider-mutations.test.ts +771 -0
  18. package/src/__tests__/jira-provider-read.test.ts +594 -0
  19. package/src/__tests__/jira-wiring.test.ts +187 -0
  20. package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
  21. package/src/__tests__/linear-provider-comment.test.ts +243 -0
  22. package/src/__tests__/linear-provider-sync.test.ts +488 -0
  23. package/src/__tests__/local-provider-comment.test.ts +60 -0
  24. package/src/__tests__/mcp-core.test.ts +164 -0
  25. package/src/__tests__/mcp-server.test.ts +252 -0
  26. package/src/__tests__/metrics.test.ts +2 -2
  27. package/src/__tests__/output.test.ts +1 -1
  28. package/src/__tests__/provider-capabilities.test.ts +40 -0
  29. package/src/__tests__/server.test.ts +291 -0
  30. package/src/__tests__/webhooks.test.ts +604 -0
  31. package/src/activity.ts +2 -12
  32. package/src/api.ts +156 -21
  33. package/src/commands/board.ts +4 -14
  34. package/src/commands/bulk.ts +4 -4
  35. package/src/commands/column.ts +4 -4
  36. package/src/commands/mcp.ts +87 -0
  37. package/src/config.ts +1 -1
  38. package/src/db.ts +118 -6
  39. package/src/errors.ts +2 -0
  40. package/src/id.ts +1 -1
  41. package/src/index.ts +83 -35
  42. package/src/mcp/core.ts +193 -0
  43. package/src/mcp/errors.ts +109 -0
  44. package/src/mcp/index.ts +13 -0
  45. package/src/mcp/server.ts +512 -0
  46. package/src/mcp/types.ts +72 -0
  47. package/src/metrics.ts +1 -1
  48. package/src/output.ts +1 -1
  49. package/src/providers/capabilities.ts +22 -17
  50. package/src/providers/errors.ts +1 -1
  51. package/src/providers/index.ts +36 -6
  52. package/src/providers/jira-adf.ts +275 -0
  53. package/src/providers/jira-cache.ts +625 -0
  54. package/src/providers/jira-client.ts +390 -0
  55. package/src/providers/jira.ts +773 -0
  56. package/src/providers/linear-cache.ts +250 -71
  57. package/src/providers/linear-client.ts +255 -15
  58. package/src/providers/linear.ts +338 -20
  59. package/src/providers/local.ts +74 -23
  60. package/src/providers/types.ts +19 -3
  61. package/src/server.ts +141 -13
  62. package/src/tunnel.ts +79 -0
  63. package/src/types.ts +19 -2
  64. package/src/webhooks.ts +36 -0
  65. package/ui/dist/assets/index-DBnoKL_k.css +1 -0
  66. package/ui/dist/assets/index-qNVJ6clH.js +40 -0
  67. package/ui/dist/index.html +2 -2
  68. package/src/__tests__/commands/task.test.ts +0 -144
  69. package/src/commands/task.ts +0 -117
  70. package/src/fixtures.ts +0 -128
  71. package/ui/dist/assets/index-B8f9NB4z.css +0 -1
  72. 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.ts'
3
+ import { handleRequest } from './api'
4
4
  import type { ServerWebSocket } from 'bun'
5
- import type { KanbanProvider } from './providers/types.ts'
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
- export function startServer(provider: KanbanProvider, port: number): void {
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
- Bun.serve({
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 (url.pathname === '/ws') {
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 (url.pathname === '/api/health') {
60
- const context = await provider.getContext()
61
- return Response.json({
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: { status: 'running', wsClients: wsClients.size, provider: context.provider },
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 (url.pathname.startsWith('/api/')) {
68
- const result = await handleRequest(provider, req)
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 filePath = join(distDir, url.pathname === '/' ? 'index.html' : url.pathname)
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
@@ -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}}