Building the Core Kanban Board UI

In this chapter, we’re laying the visual and interactive groundwork for Kanbots: a functional Kanban board. This isn’t just about pretty pixels; it’s about creating the canvas where our AI agents will operate. By the end of this milestone, you will have a desktop application with a fully interactive Kanban board, allowing you to add, edit, and move task cards between columns. This core UI is essential for managing the AI-driven development tasks we’ll introduce later.

This chapter is critical because a robust, intuitive UI is the user’s direct interface to the powerful AI orchestration we’re building. Without a solid foundation here, managing complex multi-agent workflows would be cumbersome. When we finish, you’ll be able to confirm that you can manage tasks efficiently, setting the stage for integrating AI automation.

Planning the Kanban Board Structure

Our Kanbots application will present a familiar Kanban interface: columns representing stages (e.g., “To Do,” “In Progress,” “Done”) and cards representing individual tasks. Each card will eventually host one or more AI agents.

Core Components and Data Flow

We’ll use Svelte 5’s reactivity model and component-based architecture to build the UI. The state of our Kanban board (columns and cards) will be managed using Svelte stores, providing a centralized, reactive data source.

The primary components we’ll build are:

  • Board.svelte: The top-level component that orchestrates columns.
  • Column.svelte: Represents a single Kanban column, managing its title and the list of cards within it.
  • Card.svelte: Displays an individual task card, including its title and description.

Data will flow from the Board component, which holds the overall state, down to Column and then Card components via props. User interactions (e.g., adding a card, dragging a card) will trigger updates to the Svelte store, which in turn will reactively update the UI.

flowchart TD App[App] --> Board[Board] Board --> Column[Column] Column --> Card[Card] User_Action[User Action] --> Store[Svelte Store] Store --> Board

📌 Key Idea: A clear component hierarchy and centralized state management are crucial for building complex, interactive UIs that are easy to reason about and maintain.

Project File Structure

Within our existing Tauri project (initialized in Chapter 1), we’ll focus on the src-tauri/src/main.rs (minimal for now) and src/ directory for the Svelte frontend.

kanbots/
├── src-tauri/
│   ├── src/
│   │   └── main.rs
│   └── Cargo.toml
└── src/
    ├── App.svelte         // Main Svelte application
    ├── main.ts            // Svelte entry point
    ├── lib/
    │   ├── components/
    │   │   ├── Board.svelte
    │   │   ├── Column.svelte
    │   │   └── Card.svelte
    │   └── stores.ts      // Svelte stores for state management
    └── index.css          // Global styles

Step-by-Step Implementation

Let’s build out our Kanban board incrementally. We’ll start with the data model, then the components, and finally, the drag-and-drop functionality.

1. Define Data Models and Svelte Store

First, we need to define the structure for our cards and columns. We’ll use TypeScript for type safety and create a Svelte writable store to manage the board’s state.

Create src/lib/stores.ts:

// src/lib/stores.ts
import { writable } from 'svelte/store';
import { v4 as uuidv4 } from 'uuid'; // For unique IDs

// Define the shape of a Card
export interface Card {
  id: string;
  title: string;
  description: string;
  columnId: string; // To link cards to columns
}

// Define the shape of a Column
export interface Column {
  id: string;
  title: string;
}

// Initial board data
const initialColumns: Column[] = [
  { id: 'todo', title: 'To Do' },
  { id: 'in-progress', title: 'In Progress' },
  { id: 'done', title: 'Done' },
];

const initialCards: Card[] = [
  { id: uuidv4(), title: 'Set up Tauri + Svelte', description: 'Initialize the project and basic structure.', columnId: 'todo' },
  { id: uuidv4(), title: 'Design Kanban UI', description: 'Sketch out the layout for columns and cards.', columnId: 'in-progress' },
  { id: uuidv4(), title: 'Implement Card Drag & Drop', description: 'Enable moving cards between columns.', columnId: 'in-progress' },
  { id: uuidv4(), title: 'Write Chapter 1', description: 'Initial project setup documentation.', columnId: 'done' },
];

// Svelte writable stores
export const columns = writable<Column[]>(initialColumns);
export const cards = writable<Card[]>(initialCards);

// Function to add a new card
export function addCard(columnId: string, title: string, description: string) {
  cards.update(currentCards => [
    ...currentCards,
    { id: uuidv4(), title, description, columnId }
  ]);
}

// Function to update a card
export function updateCard(cardId: string, newTitle: string, newDescription: string) {
  cards.update(currentCards =>
    currentCards.map(card =>
      card.id === cardId ? { ...card, title: newTitle, description: newDescription } : card
    )
  );
}

// Function to move a card between columns
export function moveCard(cardId: string, targetColumnId: string) {
  cards.update(currentCards =>
    currentCards.map(card =>
      card.id === cardId ? { ...card, columnId: targetColumnId } : card
    )
  );
}

Explanation:

  • writable from svelte/store creates reactive stores. When cards or columns are updated, any components subscribing to them will automatically re-render.
  • Card and Column interfaces define our data types.
  • uuidv4 from the uuid library (install it: npm install uuid @types/uuid) ensures unique IDs for cards, which is crucial for identifying and manipulating them.
  • initialColumns and initialCards provide some starting data.
  • addCard, updateCard, and moveCard are helper functions to modify the cards store, encapsulating the update logic.

2. Create the Card Component

This component will display a single task card and make it draggable.

Create src/lib/components/Card.svelte:

<!-- src/lib/components/Card.svelte -->
<script lang="ts">
  import { cards, updateCard } from '$lib/stores';
  import type { Card } from '$lib/stores';

  export let card: Card;

  let isEditing = false;
  let editedTitle = card.title;
  let editedDescription = card.description;

  function handleSave() {
    updateCard(card.id, editedTitle, editedDescription);
    isEditing = false;
  }

  function handleDragStart(event: DragEvent) {
    if (event.dataTransfer) {
      event.dataTransfer.setData('text/plain', card.id);
      event.dataTransfer.effectAllowed = 'move';
    }
  }
</script>

<div
  class="card"
  draggable="true"
  on:dragstart={handleDragStart}
  on:dblclick={() => (isEditing = true)}
>
  {#if isEditing}
    <input type="text" bind:value={editedTitle} class="card-input-title" />
    <textarea bind:value={editedDescription} class="card-input-description"></textarea>
    <button on:click={handleSave} class="card-save-button">Save</button>
    <button on:click={() => (isEditing = false)} class="card-cancel-button">Cancel</button>
  {:else}
    <h3>{card.title}</h3>
    <p>{card.description}</p>
  {/if}
</div>

<style>
  .card {
    background-color: var(--card-bg);
    border-radius: 8px;
    padding: 12px;
    margin-bottom: 10px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    cursor: grab;
    transition: transform 0.1s ease-in-out;
  }
  .card:active {
    cursor: grabbing;
  }
  .card h3 {
    margin-top: 0;
    margin-bottom: 8px;
    color: var(--text-color);
  }
  .card p {
    font-size: 0.9em;
    color: var(--text-color-light);
    margin-bottom: 0;
  }
  .card-input-title, .card-input-description {
    width: calc(100% - 16px);
    padding: 8px;
    margin-bottom: 8px;
    border: 1px solid var(--border-color);
    border-radius: 4px;
    background-color: var(--input-bg);
    color: var(--text-color);
  }
  .card-save-button, .card-cancel-button {
    padding: 6px 10px;
    margin-right: 5px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.85em;
  }
  .card-save-button {
    background-color: var(--primary-color);
    color: white;
  }
  .card-cancel-button {
    background-color: var(--secondary-color);
    color: var(--text-color);
  }
</style>

Explanation:

  • export let card: Card; declares a prop that expects a Card object.
  • draggable="true" makes the div element draggable.
  • on:dragstart={handleDragStart} attaches an event listener. Inside handleDragStart, event.dataTransfer.setData('text/plain', card.id) stores the card’s ID, which we’ll use to identify the dragged card.
  • on:dblclick toggles an editing state, allowing users to update card details.
  • Svelte’s bind:value is used for two-way data binding with input fields.
  • The style block provides basic styling. We’ll define CSS variables soon.

3. Create the Column Component

This component will render a column title, display its cards, and handle dropping cards.

Create src/lib/components/Column.svelte:

<!-- src/lib/components/Column.svelte -->
<script lang="ts">
  import { cards, moveCard, addCard } from '$lib/stores';
  import type { Column, Card } from '$lib/stores';
  import CardComponent from './Card.svelte';

  export let column: Column;

  // Filter cards belonging to this column
  $: columnCards = $cards.filter(card => card.columnId === column.id);

  let showAddCardForm = false;
  let newCardTitle = '';
  let newCardDescription = '';

  function handleAddCard() {
    if (newCardTitle.trim()) {
      addCard(column.id, newCardTitle, newCardDescription);
      newCardTitle = '';
      newCardDescription = '';
      showAddCardForm = false;
    }
  }

  function handleDragOver(event: DragEvent) {
    event.preventDefault(); // Crucial to allow dropping
    if (event.dataTransfer) {
      event.dataTransfer.dropEffect = 'move';
    }
  }

  function handleDrop(event: DragEvent) {
    event.preventDefault();
    const cardId = event.dataTransfer?.getData('text/plain');
    if (cardId) {
      moveCard(cardId, column.id);
    }
  }
</script>

<div
  class="column"
  on:dragover={handleDragOver}
  on:drop={handleDrop}
>
  <h2>{column.title}</h2>
  <div class="cards-container">
    {#each columnCards as card (card.id)}
      <CardComponent card={card} />
    {/each}
  </div>

  {#if showAddCardForm}
    <div class="add-card-form">
      <input type="text" bind:value={newCardTitle} placeholder="Card title" class="add-card-input" />
      <textarea bind:value={newCardDescription} placeholder="Description (optional)" class="add-card-textarea"></textarea>
      <button on:click={handleAddCard} class="add-card-button">Add</button>
      <button on:click={() => showAddCardForm = false} class="cancel-card-button">Cancel</button>
    </div>
  {:else}
    <button on:click={() => showAddCardForm = true} class="add-card-toggle-button">+ Add Card</button>
  {/if}
</div>

<style>
  .column {
    flex: 1;
    min-width: 280px;
    max-width: 320px;
    background-color: var(--column-bg);
    border-radius: 10px;
    padding: 15px;
    margin: 0 10px;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    display: flex;
    flex-direction: column;
    height: fit-content; /* Allow column to grow with content */
    min-height: 200px; /* Minimum height for drag-and-drop target */
  }
  .column h2 {
    margin-top: 0;
    margin-bottom: 20px;
    color: var(--text-color);
    text-align: center;
  }
  .cards-container {
    flex-grow: 1;
  }
  .add-card-toggle-button {
    background-color: var(--secondary-color);
    color: var(--text-color);
    border: none;
    padding: 10px 15px;
    border-radius: 6px;
    cursor: pointer;
    margin-top: 15px;
    width: 100%;
    font-size: 1em;
    transition: background-color 0.2s ease;
  }
  .add-card-toggle-button:hover {
    background-color: var(--secondary-hover);
  }
  .add-card-form {
    display: flex;
    flex-direction: column;
    margin-top: 15px;
  }
  .add-card-input, .add-card-textarea {
    width: calc(100% - 16px);
    padding: 8px;
    margin-bottom: 8px;
    border: 1px solid var(--border-color);
    border-radius: 4px;
    background-color: var(--input-bg);
    color: var(--text-color);
  }
  .add-card-button, .cancel-card-button {
    padding: 8px 12px;
    margin-top: 5px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.9em;
  }
  .add-card-button {
    background-color: var(--primary-color);
    color: white;
  }
  .cancel-card-button {
    background-color: var(--secondary-color);
    color: var(--text-color);
  }
</style>

Explanation:

  • export let column: Column; expects a Column object.
  • $: columnCards = $cards.filter(card => card.columnId === column.id); is a Svelte reactive declaration. It automatically re-runs whenever the $cards store changes, filtering cards relevant to this column.
  • on:dragover={handleDragOver} is vital. event.preventDefault() allows a drop to occur.
  • on:drop={handleDrop} retrieves the cardId from event.dataTransfer and calls moveCard from our store to update the card’s column.
  • An “Add Card” button and form are included to create new cards directly within a column.

4. Create the Board Component

This component will arrange the columns and provide overall structure.

Create src/lib/components/Board.svelte:

<!-- src/lib/components/Board.svelte -->
<script lang="ts">
  import { columns } from '$lib/stores';
  import ColumnComponent from './Column.svelte';
</script>

<div class="board">
  {#each $columns as column (column.id)}
    <ColumnComponent column={column} />
  {/each}
</div>

<style>
  .board {
    display: flex;
    flex-wrap: nowrap; /* Prevent columns from wrapping */
    padding: 20px;
    gap: 20px; /* Space between columns */
    overflow-x: auto; /* Allow horizontal scrolling if many columns */
    height: 100vh; /* Take full viewport height */
    align-items: flex-start; /* Align columns to the top */
    background-color: var(--board-bg);
  }
</style>

Explanation:

  • {#each $columns as column (column.id)} iterates over the columns store. The $ prefix automatically subscribes to the store. (column.id) is a Svelte key, which helps optimize list rendering.
  • Each ColumnComponent receives its respective column data as a prop.
  • The board class uses Flexbox to lay out columns horizontally.

5. Update App.svelte and Global Styles

Now, let’s integrate the Board component into our main App.svelte and add global styles.

Update src/App.svelte:

<!-- src/App.svelte -->
<script lang="ts">
  import Board from './lib/components/Board.svelte';
</script>

<main>
  <Board />
</main>

<style>
  :global(body) {
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    background-color: var(--board-bg); /* Ensure body matches board background */
  }

  :global(*) {
    box-sizing: border-box;
  }

  main {
    display: flex;
    min-height: 100vh; /* Ensure main takes full height */
    width: 100vw; /* Ensure main takes full width */
  }
</style>

Add src/index.css (create this file if it doesn’t exist):

/* src/index.css */
:root {
  /* Color Palette */
  --primary-color: #4CAF50; /* Green for success/primary actions */
  --secondary-color: #FFC107; /* Amber for warnings/secondary actions */
  --accent-color: #2196F3; /* Blue for info/highlights */
  --danger-color: #F44336; /* Red for errors/destructive actions */

  /* Theming - Dark Mode Inspired */
  --background-color: #1a1a1a; /* Overall app background */
  --text-color: #e0e0e0; /* Primary text color */
  --text-color-light: #b0b0b0; /* Secondary text color */
  --border-color: #333; /* Borders and dividers */
  --shadow-color: rgba(0, 0, 0, 0.3); /* Shadow color */

  /* Kanban Specific */
  --board-bg: #282c34; /* Dark background for the entire board area */
  --column-bg: #3c4048; /* Slightly lighter dark for columns */
  --card-bg: #555c66; /* Even lighter dark for cards */
  --input-bg: #444; /* Input field background */

  /* Hover States */
  --primary-hover: #45a049;
  --secondary-hover: #ffca28;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  background-color: var(--background-color);
  color: var(--text-color);
}

/* Scrollbar styling for a cleaner look */
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
}

::-webkit-scrollbar-track {
  background: var(--background-color);
}

::-webkit-scrollbar-thumb {
  background: var(--column-bg);
  border-radius: 4px;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--card-bg);
}

Explanation:

  • The :global(body) and :global(*) selectors apply styles globally, necessary for index.css to be effective within Svelte’s scoped styling.
  • main is set to flex to ensure the Board component fills the available space.
  • index.css defines a set of CSS variables (:root) for a consistent dark theme, making it easy to adjust colors later. These variables are then used throughout the components.

6. Install uuid and TypeScript Types

If you haven’t already, install the uuid library and its TypeScript types:

npm install uuid
npm install -D @types/uuid

This ensures uuidv4() is available and correctly typed in stores.ts.

Testing & Verification

Now that the core UI is in place, let’s run the application and verify its functionality.

  1. Start the Tauri development server: Navigate to your project root in the terminal and run:

    npm run tauri dev

    This command will compile your Rust backend, start the Svelte development server, and launch the Tauri desktop window.

  2. Verify Initial Board State:

    • You should see a desktop window open with three columns: “To Do,” “In Progress,” and “Done.”
    • Each column should contain the initial cards defined in src/lib/stores.ts.
  3. Test Card Creation:

    • Click the “+ Add Card” button in any column.
    • Enter a title and an optional description.
    • Click “Add.” A new card should appear at the bottom of that column.
  4. Test Card Editing:

    • Double-click on any card.
    • Edit its title and/or description.
    • Click “Save.” The card should update with the new information.
    • Click “Cancel” to discard changes.
  5. Test Drag and Drop:

    • Click and hold on a card, then drag it to another column.
    • Release the mouse button. The card should move from its original column to the new column.
    • Try moving cards within the same column as well.

If all these steps work as described, you have successfully built the core Kanban board UI.

Production Considerations

While this is a foundational step, thinking about production early helps.

  • State Persistence: Currently, our board state is lost when the application closes. For a real application, we would persist cards and columns to a local database (e.g., SQLite via Tauri’s Rust backend) or a local file. This will be addressed in a later chapter.
  • Performance: For very large boards with hundreds or thousands of cards, rendering all cards at once can become slow. Techniques like list virtualization (only rendering visible items) would be necessary. For our initial scope, this is not a concern.
  • Accessibility: Drag-and-drop functionality should ideally also be accessible via keyboard navigation. This requires additional ARIA attributes and event handlers, which is beyond our current scope but important for a polished production app.
  • Error Handling: The UI assumes successful operations. In a production scenario, failed updates to the store (e.g., if persistence fails) would need user feedback.

Common Issues & Solutions

  • Cards not draggable/droppable:
    • Issue: draggable="true" is missing on the Card component’s root element, or event.preventDefault() is missing in handleDragOver in the Column component.
    • Solution: Double-check these attributes and function calls. event.preventDefault() is critical for enabling drop targets.
  • Svelte reactivity issues:
    • Issue: Changes to stores or props don’t seem to update the UI.
    • Solution: Ensure you are correctly using $storeName to access store values and that updates are done via storeName.update(...) or storeName.set(...). Svelte’s reactivity model is powerful but requires adherence to its patterns.
  • Styling not applying:
    • Issue: Your CSS variables or component styles aren’t visible.
    • Solution: Verify src/index.css is being imported (check src/main.ts or src/App.svelte for an import statement like import './index.css';). Also, ensure :global() is used for base styles that should affect elements outside the Svelte component’s scope.

Summary & Next Step

You’ve successfully built the interactive Kanban board for Kanbots. You can now create, edit, and move task cards, providing a solid foundation for task management. This milestone is crucial because it gives us a tangible interface to interact with.

What’s ready:

  • A cross-platform desktop application powered by Tauri and Svelte.
  • A functional Kanban board with multiple columns.
  • Interactive cards that can be added, edited, and moved via drag-and-drop.

In the next chapter, we will integrate Git worktrees, which will serve as isolated environments for our AI agents, allowing them to work on tasks without interfering with each other or the main codebase.


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.

References