kanban-lite 1.0.21 → 1.0.23

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 (44) hide show
  1. package/{CLAUDE.md → AGENTS.md} +13 -0
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +10 -0
  4. package/dist/cli.js +168 -102
  5. package/dist/extension.js +178 -104
  6. package/dist/mcp-server.js +145 -95
  7. package/dist/sdk/index.cjs +126 -93
  8. package/dist/sdk/index.mjs +126 -93
  9. package/dist/sdk/sdk/KanbanSDK.d.ts +39 -7
  10. package/dist/sdk/shared/config.d.ts +4 -0
  11. package/dist/sdk/shared/types.d.ts +4 -0
  12. package/dist/standalone-webview/index.js +58 -58
  13. package/dist/standalone-webview/index.js.map +1 -1
  14. package/dist/standalone-webview/style.css +1 -1
  15. package/dist/standalone.js +606 -364
  16. package/dist/webview/index.js +57 -57
  17. package/dist/webview/index.js.map +1 -1
  18. package/dist/webview/style.css +1 -1
  19. package/docs/plans/2026-02-26-settings-tabs-design.md +40 -0
  20. package/docs/plans/2026-02-26-settings-tabs.md +166 -0
  21. package/docs/plans/2026-02-27-zoom-settings-design.md +82 -0
  22. package/docs/plans/2026-02-27-zoom-settings.md +395 -0
  23. package/docs/sdk.md +3 -6
  24. package/package.json +1 -1
  25. package/src/cli/index.ts +12 -2
  26. package/src/extension/KanbanPanel.ts +25 -5
  27. package/src/mcp-server/index.ts +20 -2
  28. package/src/sdk/KanbanSDK.ts +64 -7
  29. package/src/sdk/__tests__/KanbanSDK.test.ts +17 -1
  30. package/src/sdk/__tests__/metadata.test.ts +3 -1
  31. package/src/sdk/__tests__/multi-board.test.ts +2 -0
  32. package/src/sdk/parser.ts +50 -83
  33. package/src/shared/config.ts +14 -2
  34. package/src/shared/types.ts +4 -0
  35. package/src/standalone/__tests__/server.integration.test.ts +2 -2
  36. package/src/standalone/index.ts +7 -4
  37. package/src/standalone/server.ts +31 -6
  38. package/src/webview/App.tsx +42 -3
  39. package/src/webview/assets/main.css +31 -2
  40. package/src/webview/components/KanbanBoard.tsx +35 -3
  41. package/src/webview/components/KanbanColumn.tsx +40 -4
  42. package/src/webview/components/SettingsPanel.tsx +179 -77
  43. package/src/webview/components/Toolbar.tsx +127 -32
  44. package/src/webview/store/index.ts +26 -28
@@ -32,6 +32,8 @@ function createV2Config(overrides?: Partial<KanbanConfig>): KanbanConfig {
32
32
  compactMode: false,
33
33
  markdownEditorMode: false,
34
34
  showDeletedColumn: false,
35
+ boardZoom: 100,
36
+ cardZoom: 100,
35
37
  port: 3000,
36
38
  ...overrides
37
39
  }
package/src/sdk/parser.ts CHANGED
@@ -50,45 +50,25 @@ export function parseFeatureFile(content: string, filePath: string): Feature | n
50
50
  const frontmatter = frontmatterMatch[1]
51
51
  const rest = frontmatterMatch[2] || ''
52
52
 
53
- const getValue = (key: string): string => {
54
- const match = frontmatter.match(new RegExp(`^${key}:\\s*(.*)$`, 'm'))
55
- if (!match) return ''
56
- const value = match[1].trim().replace(/^["']|["']$/g, '')
57
- return value === 'null' ? '' : value
53
+ let parsed: Record<string, unknown>
54
+ try {
55
+ const loaded = yaml.load(frontmatter, { schema: yaml.JSON_SCHEMA })
56
+ if (!loaded || typeof loaded !== 'object' || Array.isArray(loaded)) return null
57
+ parsed = loaded as Record<string, unknown>
58
+ } catch {
59
+ return null
58
60
  }
59
61
 
60
- const getArrayValue = (key: string): string[] => {
61
- const match = frontmatter.match(new RegExp(`^${key}:\\s*\\[([^\\]]*)\\]`, 'm'))
62
- if (!match) return []
63
- return match[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean)
62
+ const str = (key: string): string => {
63
+ const val = parsed[key]
64
+ if (val == null) return ''
65
+ return String(val)
64
66
  }
65
67
 
66
- const getMetadata = (): Record<string, any> | undefined => {
67
- const lines = frontmatter.split('\n')
68
- let metaStart = -1
69
- for (let j = 0; j < lines.length; j++) {
70
- if (/^metadata:\s*$/.test(lines[j])) {
71
- metaStart = j + 1
72
- break
73
- }
74
- }
75
- if (metaStart === -1) return undefined
76
- const indentedLines: string[] = []
77
- for (let j = metaStart; j < lines.length; j++) {
78
- if (/^\s/.test(lines[j]) || lines[j].trim() === '') {
79
- indentedLines.push(lines[j])
80
- } else {
81
- break
82
- }
83
- }
84
- if (indentedLines.length === 0) return undefined
85
- try {
86
- const parsed = yaml.load(indentedLines.join('\n'))
87
- if (parsed && typeof parsed === 'object') return parsed as Record<string, any>
88
- return undefined
89
- } catch {
90
- return undefined
91
- }
68
+ const arr = (key: string): string[] => {
69
+ const val = parsed[key]
70
+ if (!Array.isArray(val)) return []
71
+ return val.filter(v => v != null).map(String)
92
72
  }
93
73
 
94
74
  // Split rest into card body and comment sections
@@ -111,23 +91,26 @@ export function parseFeatureFile(content: string, filePath: string): Feature | n
111
91
  }
112
92
  }
113
93
 
114
- const meta = getMetadata()
115
- const actions = getArrayValue('actions')
94
+ const actions = arr('actions')
95
+ const rawMeta = parsed.metadata
96
+ const meta = rawMeta != null && typeof rawMeta === 'object' && !Array.isArray(rawMeta)
97
+ ? rawMeta as Record<string, unknown>
98
+ : undefined
116
99
 
117
100
  return {
118
- version: parseInt(getValue('version'), 10) || 0,
119
- id: getValue('id') || extractIdFromFilename(filePath),
120
- status: (getValue('status') as FeatureStatus) || 'backlog',
121
- priority: (getValue('priority') as Priority) || 'medium',
122
- assignee: getValue('assignee') || null,
123
- dueDate: getValue('dueDate') || null,
124
- created: getValue('created') || new Date().toISOString(),
125
- modified: getValue('modified') || new Date().toISOString(),
126
- completedAt: getValue('completedAt') || null,
127
- labels: getArrayValue('labels'),
128
- attachments: getArrayValue('attachments'),
101
+ version: typeof parsed.version === 'number' ? parsed.version : parseInt(str('version'), 10) || 0,
102
+ id: str('id') || extractIdFromFilename(filePath),
103
+ status: (str('status') as FeatureStatus) || 'backlog',
104
+ priority: (str('priority') as Priority) || 'medium',
105
+ assignee: parsed.assignee != null ? String(parsed.assignee) : null,
106
+ dueDate: parsed.dueDate != null ? String(parsed.dueDate) : null,
107
+ created: str('created') || new Date().toISOString(),
108
+ modified: str('modified') || new Date().toISOString(),
109
+ completedAt: parsed.completedAt != null ? String(parsed.completedAt) : null,
110
+ labels: arr('labels'),
111
+ attachments: arr('attachments'),
129
112
  comments,
130
- order: getValue('order') || 'a0',
113
+ order: str('order') || 'a0',
131
114
  content: body.trim(),
132
115
  ...(meta ? { metadata: meta } : {}),
133
116
  ...(actions.length > 0 ? { actions } : {}),
@@ -146,43 +129,27 @@ export function parseFeatureFile(content: string, filePath: string): Feature | n
146
129
  * @returns The complete markdown string ready to be written to a `.md` file.
147
130
  */
148
131
  export function serializeFeature(feature: Feature): string {
149
- const lines = [
150
- '---',
151
- `version: ${feature.version ?? CARD_FORMAT_VERSION}`,
152
- `id: "${feature.id}"`,
153
- `status: "${feature.status}"`,
154
- `priority: "${feature.priority}"`,
155
- `assignee: ${feature.assignee ? `"${feature.assignee}"` : 'null'}`,
156
- `dueDate: ${feature.dueDate ? `"${feature.dueDate}"` : 'null'}`,
157
- `created: "${feature.created}"`,
158
- `modified: "${feature.modified}"`,
159
- `completedAt: ${feature.completedAt ? `"${feature.completedAt}"` : 'null'}`,
160
- `labels: [${feature.labels.map(l => `"${l}"`).join(', ')}]`,
161
- `attachments: [${(feature.attachments || []).map(a => `"${a}"`).join(', ')}]`,
162
- `order: "${feature.order}"`,
163
- ]
164
-
165
- if (feature.actions && feature.actions.length > 0) {
166
- lines.push(`actions: [${feature.actions.map(a => `"${a}"`).join(', ')}]`)
132
+ const frontmatterObj: Record<string, unknown> = {
133
+ version: feature.version ?? CARD_FORMAT_VERSION,
134
+ id: feature.id,
135
+ status: feature.status,
136
+ priority: feature.priority,
137
+ assignee: feature.assignee ?? null,
138
+ dueDate: feature.dueDate ?? null,
139
+ created: feature.created,
140
+ modified: feature.modified,
141
+ completedAt: feature.completedAt ?? null,
142
+ labels: feature.labels,
143
+ attachments: feature.attachments || [],
144
+ order: feature.order,
145
+ ...(feature.actions?.length ? { actions: feature.actions } : {}),
146
+ ...(feature.metadata && Object.keys(feature.metadata).length > 0 ? { metadata: feature.metadata } : {}),
167
147
  }
168
148
 
169
- if (feature.metadata && Object.keys(feature.metadata).length > 0) {
170
- const metaYaml = yaml.dump(feature.metadata, { indent: 2, lineWidth: -1 })
171
- lines.push('metadata:')
172
- for (const line of metaYaml.trimEnd().split('\n')) {
173
- lines.push(' ' + line)
174
- }
175
- }
176
-
177
- lines.push('---')
178
- lines.push('')
179
-
180
- const frontmatter = lines.join('\n')
181
-
182
- let result = frontmatter + feature.content
149
+ const yamlStr = yaml.dump(frontmatterObj, { lineWidth: -1, quotingType: '"', forceQuotes: true })
150
+ let result = `---\n${yamlStr}---\n\n${feature.content}`
183
151
 
184
- const comments = feature.comments || []
185
- for (const comment of comments) {
152
+ for (const comment of feature.comments || []) {
186
153
  result += '\n\n---\n'
187
154
  result += `comment: true\n`
188
155
  result += `id: "${comment.id}"\n`
@@ -84,6 +84,10 @@ export interface KanbanConfig {
84
84
  markdownEditorMode: boolean
85
85
  /** Whether to show the deleted column in the UI. */
86
86
  showDeletedColumn: boolean
87
+ /** Zoom level for the board view (75–150). */
88
+ boardZoom: number
89
+ /** Zoom level for the card detail panel (75–150). */
90
+ cardZoom: number
87
91
  /** Port number for the standalone HTTP server. */
88
92
  port: number
89
93
  /** Registered webhook endpoints for event notifications. */
@@ -145,6 +149,8 @@ export const DEFAULT_CONFIG: KanbanConfig = {
145
149
  compactMode: false,
146
150
  markdownEditorMode: false,
147
151
  showDeletedColumn: false,
152
+ boardZoom: 100,
153
+ cardZoom: 100,
148
154
  port: 3000,
149
155
  labels: {}
150
156
  }
@@ -211,6 +217,8 @@ function migrateConfigV1ToV2(raw: Record<string, unknown>): KanbanConfig {
211
217
  compactMode: v1.compactMode,
212
218
  markdownEditorMode: v1.markdownEditorMode,
213
219
  showDeletedColumn: false,
220
+ boardZoom: 100,
221
+ cardZoom: 100,
214
222
  port: 3000
215
223
  }
216
224
  }
@@ -386,7 +394,9 @@ export function configToSettings(config: KanbanConfig): CardDisplaySettings {
386
394
  markdownEditorMode: config.markdownEditorMode,
387
395
  showDeletedColumn: config.showDeletedColumn,
388
396
  defaultPriority: config.defaultPriority,
389
- defaultStatus: config.defaultStatus
397
+ defaultStatus: config.defaultStatus,
398
+ boardZoom: config.boardZoom ?? 100,
399
+ cardZoom: config.cardZoom ?? 100
390
400
  }
391
401
  }
392
402
 
@@ -414,6 +424,8 @@ export function settingsToConfig(config: KanbanConfig, settings: CardDisplaySett
414
424
  compactMode: settings.compactMode,
415
425
  showDeletedColumn: settings.showDeletedColumn,
416
426
  defaultPriority: settings.defaultPriority,
417
- defaultStatus: settings.defaultStatus
427
+ defaultStatus: settings.defaultStatus,
428
+ boardZoom: settings.boardZoom,
429
+ cardZoom: settings.cardZoom
418
430
  }
419
431
  }
@@ -250,6 +250,10 @@ export interface CardDisplaySettings {
250
250
  defaultPriority: Priority
251
251
  /** The default column/status assigned to newly created cards. */
252
252
  defaultStatus: string
253
+ /** Zoom level for the board view as a percentage (75–150). Default 100. */
254
+ boardZoom: number
255
+ /** Zoom level for the card detail panel as a percentage (75–150). Default 100. */
256
+ cardZoom: number
253
257
  }
254
258
 
255
259
  export interface LabelDefinition {
@@ -376,7 +376,7 @@ describe('Standalone Server Integration', () => {
376
376
  expect(fileContent).toContain('status: "todo"')
377
377
  expect(fileContent).toContain('priority: "high"')
378
378
  expect(fileContent).toContain('# My New Feature')
379
- expect(fileContent).toContain('labels: ["frontend"]')
379
+ expect(fileContent).toContain('- "frontend"')
380
380
  })
381
381
 
382
382
  it('should create feature in its status subfolder', async () => {
@@ -710,7 +710,7 @@ describe('Standalone Server Integration', () => {
710
710
  const fileContent = fs.readFileSync(path.join(tempDir, 'boards', 'default', 'backlog', 'update-me.md'), 'utf-8')
711
711
  expect(fileContent).toContain('priority: "critical"')
712
712
  expect(fileContent).toContain('assignee: "alice"')
713
- expect(fileContent).toContain('labels: ["urgent"]')
713
+ expect(fileContent).toContain('- "urgent"')
714
714
  })
715
715
 
716
716
  it('should set completedAt when status changes to done', async () => {
@@ -1,8 +1,9 @@
1
+ import { readConfig } from '../shared/config'
1
2
  import { startServer } from './server'
2
3
 
3
- function parseArgs(args: string[]): { dir: string; port: number; noBrowser: boolean } {
4
+ function parseArgs(args: string[], defaultPort: number): { dir: string; port: number; noBrowser: boolean } {
4
5
  let dir = '.kanban'
5
- let port = 3000
6
+ let port = defaultPort
6
7
  let noBrowser = false
7
8
 
8
9
  for (let i = 0; i < args.length; i++) {
@@ -25,7 +26,7 @@ Usage: kanban-md [options]
25
26
 
26
27
  Options:
27
28
  -d, --dir <path> Features directory (default: .kanban)
28
- -p, --port <number> Port to listen on (default: 3000)
29
+ -p, --port <number> Port to listen on (default: .kanban.json port or 3000)
29
30
  --no-browser Don't open browser automatically
30
31
  -h, --help Show this help message
31
32
 
@@ -43,7 +44,9 @@ REST API available at http://localhost:<port>/api
43
44
  return { dir, port, noBrowser }
44
45
  }
45
46
 
46
- const { dir, port, noBrowser } = parseArgs(process.argv.slice(2))
47
+ // Read config from cwd to get the configured port default
48
+ const configPort = readConfig(process.cwd()).port
49
+ const { dir, port, noBrowser } = parseArgs(process.argv.slice(2), configPort)
47
50
 
48
51
  const server = startServer(dir, port)
49
52
 
@@ -260,10 +260,7 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
260
260
 
261
261
  async function doPurgeDeletedCards(): Promise<boolean> {
262
262
  try {
263
- const deletedCards = features.filter(f => f.status === 'deleted')
264
- for (const card of deletedCards) {
265
- await sdk.permanentlyDeleteCard(card.id, currentBoardId)
266
- }
263
+ await sdk.purgeDeletedCards(currentBoardId)
267
264
  await loadFeatures()
268
265
  broadcast(buildInitMessage())
269
266
  return true
@@ -312,6 +309,21 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
312
309
  }
313
310
  }
314
311
 
312
+ async function doCleanupColumn(columnId: string): Promise<boolean> {
313
+ try {
314
+ migrating = true
315
+ await sdk.cleanupColumn(columnId, currentBoardId)
316
+ await loadFeatures()
317
+ broadcast(buildInitMessage())
318
+ return true
319
+ } catch (err) {
320
+ console.error('Failed to cleanup column:', err)
321
+ return false
322
+ } finally {
323
+ migrating = false
324
+ }
325
+ }
326
+
315
327
  function doSaveSettings(newSettings: CardDisplaySettings): void {
316
328
  sdk.updateSettings(newSettings)
317
329
  broadcast(buildInitMessage())
@@ -518,6 +530,10 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
518
530
  await doRemoveColumn(msg.columnId as string)
519
531
  break
520
532
 
533
+ case 'cleanupColumn':
534
+ await doCleanupColumn(msg.columnId as string)
535
+ break
536
+
521
537
  case 'removeAttachment': {
522
538
  const featureId = msg.featureId as string
523
539
  const feature = await doRemoveAttachment(featureId, msg.attachment as string)
@@ -643,14 +659,17 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
643
659
 
644
660
  case 'renameLabel': {
645
661
  await sdk.renameLabel(msg.oldName as string, msg.newName as string)
662
+ await loadFeatures()
646
663
  broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
647
664
  broadcast(buildInitMessage())
648
665
  break
649
666
  }
650
667
 
651
668
  case 'deleteLabel': {
652
- sdk.deleteLabel(msg.name as string)
669
+ await sdk.deleteLabel(msg.name as string)
670
+ await loadFeatures()
653
671
  broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
672
+ broadcast(buildInitMessage())
654
673
  break
655
674
  }
656
675
 
@@ -1291,6 +1310,9 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
1291
1310
  const newName = body.newName as string
1292
1311
  if (!newName) return jsonError(res, 400, 'newName is required')
1293
1312
  await sdk.renameLabel(name, newName)
1313
+ await loadFeatures()
1314
+ broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
1315
+ broadcast(buildInitMessage())
1294
1316
  return jsonOk(res, sdk.getLabels())
1295
1317
  } catch (err) {
1296
1318
  return jsonError(res, 400, String(err))
@@ -1301,7 +1323,10 @@ export function startServer(featuresDir: string, port: number, webviewDir?: stri
1301
1323
  if (params) {
1302
1324
  try {
1303
1325
  const name = decodeURIComponent(params.name)
1304
- sdk.deleteLabel(name)
1326
+ await sdk.deleteLabel(name)
1327
+ await loadFeatures()
1328
+ broadcast({ type: 'labelsUpdated', labels: sdk.getLabels() })
1329
+ broadcast(buildInitMessage())
1305
1330
  return jsonOk(res, { success: true })
1306
1331
  } catch (err) {
1307
1332
  return jsonError(res, 400, String(err))
@@ -131,6 +131,29 @@ function App(): React.JSX.Element {
131
131
  return
132
132
  }
133
133
 
134
+ // Ctrl/Cmd +/- for board zoom, Ctrl/Cmd+Shift +/- for card detail zoom
135
+ if ((e.key === '=' || e.key === '+' || e.key === '-') && (e.ctrlKey || e.metaKey)) {
136
+ e.preventDefault()
137
+ const delta = (e.key === '-') ? -5 : 5
138
+ const { cardSettings } = useStore.getState()
139
+ if (e.shiftKey) {
140
+ const newZoom = Math.max(75, Math.min(150, cardSettings.cardZoom + delta))
141
+ if (newZoom !== cardSettings.cardZoom) {
142
+ const next = { ...cardSettings, cardZoom: newZoom }
143
+ setCardSettings(next)
144
+ vscode.postMessage({ type: 'saveSettings', settings: next })
145
+ }
146
+ } else {
147
+ const newZoom = Math.max(75, Math.min(150, cardSettings.boardZoom + delta))
148
+ if (newZoom !== cardSettings.boardZoom) {
149
+ const next = { ...cardSettings, boardZoom: newZoom }
150
+ setCardSettings(next)
151
+ vscode.postMessage({ type: 'saveSettings', settings: next })
152
+ }
153
+ }
154
+ return
155
+ }
156
+
134
157
  // Ignore if user is typing in an input or contentEditable (e.g. TipTap editor)
135
158
  if (
136
159
  e.target instanceof HTMLInputElement ||
@@ -178,7 +201,7 @@ function App(): React.JSX.Element {
178
201
  window.removeEventListener('mousedown', handleMouseDown)
179
202
  if (altDownTimer) clearTimeout(altDownTimer)
180
203
  }
181
- }, [createFeatureOpen, handleUndoLatest])
204
+ }, [createFeatureOpen, handleUndoLatest, setCardSettings])
182
205
 
183
206
  // Listen for VSCode theme changes
184
207
  useEffect(() => {
@@ -202,6 +225,13 @@ function App(): React.JSX.Element {
202
225
  return () => observer.disconnect()
203
226
  }, [setIsDarkMode])
204
227
 
228
+ // Sync zoom CSS custom properties
229
+ useEffect(() => {
230
+ const root = document.documentElement
231
+ root.style.setProperty('--board-zoom', String(cardSettings.boardZoom / 100))
232
+ root.style.setProperty('--card-zoom', String(cardSettings.cardZoom / 100))
233
+ }, [cardSettings.boardZoom, cardSettings.cardZoom])
234
+
205
235
  // Listen for messages from extension
206
236
  useEffect(() => {
207
237
  const handleMessage = (event: MessageEvent<ExtensionMessage>) => {
@@ -387,6 +417,14 @@ function App(): React.JSX.Element {
387
417
  vscode.postMessage({ type: 'removeColumn', columnId })
388
418
  }
389
419
 
420
+ const handleCleanupColumn = (columnId: string): void => {
421
+ const col = columns.find(c => c.id === columnId)
422
+ if (!col) return
423
+ const featuresInColumn = useStore.getState().features.filter(f => f.status === columnId)
424
+ if (featuresInColumn.length === 0) return
425
+ vscode.postMessage({ type: 'cleanupColumn', columnId })
426
+ }
427
+
390
428
  const handleSaveColumn = (data: { name: string; color: string }): void => {
391
429
  if (editingColumn) {
392
430
  vscode.postMessage({ type: 'editColumn', columnId: editingColumn.id, updates: data })
@@ -483,19 +521,20 @@ function App(): React.JSX.Element {
483
521
  onCreateBoard={(name) => vscode.postMessage({ type: 'createBoard', name })}
484
522
  />
485
523
  <div className="flex-1 flex overflow-hidden">
486
- <div className={editingFeature ? 'w-1/2' : 'w-full'}>
524
+ <div className={`board-zoom-scope ${editingFeature ? 'w-1/2' : 'w-full'}`}>
487
525
  <KanbanBoard
488
526
  onFeatureClick={handleFeatureClick}
489
527
  onAddFeature={handleAddFeatureInColumn}
490
528
  onMoveFeature={handleMoveFeature}
491
529
  onEditColumn={handleEditColumn}
492
530
  onRemoveColumn={handleRemoveColumn}
531
+ onCleanupColumn={handleCleanupColumn}
493
532
  onPurgeDeletedCards={handlePurgeDeletedCards}
494
533
  selectedFeatureId={editingFeature?.id}
495
534
  />
496
535
  </div>
497
536
  {editingFeature && (
498
- <div className="w-1/2">
537
+ <div className="w-1/2" style={{ fontSize: `calc(1em * var(--card-zoom, 1))` }}>
499
538
  <FeatureEditor
500
539
  featureId={editingFeature.id}
501
540
  content={editingFeature.content}
@@ -76,6 +76,7 @@ input:focus-visible {
76
76
 
77
77
  /* Prose (markdown) styling for editor */
78
78
  .prose {
79
+ font-size: calc(1rem * var(--card-zoom, 1));
79
80
  line-height: 1.625;
80
81
  }
81
82
 
@@ -281,7 +282,7 @@ input:focus-visible {
281
282
  background: transparent;
282
283
  color: var(--vscode-foreground);
283
284
  font-family: var(--vscode-editor-font-family, 'SF Mono', Monaco, Menlo, Consolas, monospace);
284
- font-size: var(--vscode-editor-font-size, 13px);
285
+ font-size: calc(var(--vscode-editor-font-size, 13px) * var(--card-zoom, 1));
285
286
  line-height: 1.6;
286
287
  tab-size: 2;
287
288
  }
@@ -330,7 +331,7 @@ input:focus-visible {
330
331
 
331
332
  /* Comment markdown styling - compact prose for comment bubbles */
332
333
  .comment-markdown {
333
- font-size: 0.75rem;
334
+ font-size: calc(0.75rem * var(--card-zoom, 1));
334
335
  line-height: 1.5;
335
336
  }
336
337
 
@@ -432,3 +433,31 @@ input:focus-visible {
432
433
  .comment-markdown em {
433
434
  font-style: italic;
434
435
  }
436
+
437
+ /* Board zoom scaling */
438
+ .board-zoom-scope {
439
+ font-size: calc(1em * var(--board-zoom, 1));
440
+ }
441
+
442
+ /* Settings slider */
443
+ .settings-slider {
444
+ -webkit-appearance: none;
445
+ appearance: none;
446
+ width: 5rem;
447
+ height: 4px;
448
+ border-radius: 2px;
449
+ outline: none;
450
+ cursor: pointer;
451
+ }
452
+
453
+ .settings-slider::-webkit-slider-thumb {
454
+ -webkit-appearance: none;
455
+ appearance: none;
456
+ width: 14px;
457
+ height: 14px;
458
+ border-radius: 50%;
459
+ background: var(--vscode-button-background);
460
+ border: 2px solid var(--vscode-editor-background);
461
+ box-shadow: 0 0 0 1px var(--vscode-button-background);
462
+ cursor: pointer;
463
+ }
@@ -1,6 +1,7 @@
1
- import { useState, useCallback } from 'react'
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
2
  import { KanbanColumn } from './KanbanColumn'
3
3
  import { useStore } from '../store'
4
+ import type { SortOrder } from '../store'
4
5
  import type { Feature, FeatureStatus } from '../../shared/types'
5
6
  import { DELETED_COLUMN } from '../../shared/types'
6
7
 
@@ -15,16 +16,41 @@ interface KanbanBoardProps {
15
16
  onMoveFeature: (featureId: string, newStatus: string, newOrder: number) => void
16
17
  onEditColumn: (columnId: string) => void
17
18
  onRemoveColumn: (columnId: string) => void
19
+ onCleanupColumn: (columnId: string) => void
18
20
  onPurgeDeletedCards: () => void
19
21
  selectedFeatureId?: string
20
22
  }
21
23
 
22
- export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEditColumn, onRemoveColumn, onPurgeDeletedCards, selectedFeatureId }: KanbanBoardProps) {
24
+ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEditColumn, onRemoveColumn, onCleanupColumn, onPurgeDeletedCards, selectedFeatureId }: KanbanBoardProps) {
25
+ const scrollContainerRef = useRef<HTMLDivElement>(null)
26
+
27
+ useEffect(() => {
28
+ if (!selectedFeatureId) return
29
+ const container = scrollContainerRef.current
30
+ if (!container) return
31
+ // Wait a frame so the panel layout transition has started and the board has shrunk
32
+ requestAnimationFrame(() => {
33
+ const cardEl = container.querySelector<HTMLElement>(`[data-card-id="${selectedFeatureId}"]`)
34
+ if (!cardEl) return
35
+ const containerRect = container.getBoundingClientRect()
36
+ const cardRect = cardEl.getBoundingClientRect()
37
+ // Check if the card is fully visible horizontally inside the scroll container
38
+ const isFullyVisible = cardRect.left >= containerRect.left && cardRect.right <= containerRect.right
39
+ if (!isFullyVisible) {
40
+ // Scroll so the card's right edge is visible with 10px breathing room
41
+ const overflow = cardRect.right - containerRect.right
42
+ container.scrollBy({ left: overflow + 10, behavior: 'smooth' })
43
+ }
44
+ })
45
+ }, [selectedFeatureId])
46
+
23
47
  const columns = useStore((s) => s.columns)
24
48
  const cardSettings = useStore((s) => s.cardSettings)
25
49
  const getFilteredFeaturesByStatus = useStore((s) => s.getFilteredFeaturesByStatus)
26
50
  const getFeaturesByStatus = useStore((s) => s.getFeaturesByStatus)
27
51
  const layout = useStore((s) => s.layout)
52
+ const columnSorts = useStore((s) => s.columnSorts)
53
+ const setColumnSort = useStore((s) => s.setColumnSort)
28
54
  const [draggedFeature, setDraggedFeature] = useState<Feature | null>(null)
29
55
  const [dropTarget, setDropTarget] = useState<DropTarget | null>(null)
30
56
 
@@ -121,7 +147,7 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
121
147
  const isVertical = layout === 'vertical'
122
148
 
123
149
  return (
124
- <div className={isVertical ? "h-full overflow-y-auto p-4" : "h-full overflow-x-auto p-4"}>
150
+ <div ref={scrollContainerRef} className={isVertical ? "h-full overflow-y-auto p-4" : "h-full overflow-x-auto p-4"}>
125
151
  <div className={isVertical ? "flex flex-col gap-4" : "flex gap-4 h-full min-w-max"}>
126
152
  {columns.map((column) => (
127
153
  <KanbanColumn
@@ -132,6 +158,7 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
132
158
  onAddFeature={onAddFeature}
133
159
  onEditColumn={onEditColumn}
134
160
  onRemoveColumn={onRemoveColumn}
161
+ onCleanupColumn={onCleanupColumn}
135
162
  onDragStart={handleDragStart}
136
163
  onDragOver={handleDragOver}
137
164
  onDragOverCard={handleDragOverCard}
@@ -141,6 +168,8 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
141
168
  dropTarget={dropTarget}
142
169
  layout={layout}
143
170
  selectedFeatureId={selectedFeatureId}
171
+ sort={(columnSorts[column.id] || 'order') as SortOrder}
172
+ onSortChange={(s) => setColumnSort(column.id, s)}
144
173
  />
145
174
  ))}
146
175
  {cardSettings.showDeletedColumn && (
@@ -152,6 +181,7 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
152
181
  onAddFeature={onAddFeature}
153
182
  onEditColumn={onEditColumn}
154
183
  onRemoveColumn={onRemoveColumn}
184
+ onCleanupColumn={onCleanupColumn}
155
185
  onDragStart={handleDragStart}
156
186
  onDragOver={handleDragOver}
157
187
  onDragOverCard={handleDragOverCard}
@@ -163,6 +193,8 @@ export function KanbanBoard({ onFeatureClick, onAddFeature, onMoveFeature, onEdi
163
193
  isDeletedColumn
164
194
  onPurgeColumn={onPurgeDeletedCards}
165
195
  selectedFeatureId={selectedFeatureId}
196
+ sort={(columnSorts[DELETED_COLUMN.id] || 'order') as SortOrder}
197
+ onSortChange={(s) => setColumnSort(DELETED_COLUMN.id, s)}
166
198
  />
167
199
  )}
168
200
  </div>