From 1675fda4bf4656d02cbc06abef7c56ed3349f349 Mon Sep 17 00:00:00 2001 From: Joel Bystedt Date: Mon, 22 Jun 2026 14:20:53 +0200 Subject: [PATCH 1/5] Redesign Kanban column chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle the Kanban column to match the Tendril board design: - Columns sit on a light gray rounded panel with white cards on top - Header shows a hollow circle indicator (tinted ring when a column color is set), a medium-weight title, the count, and a ··· overflow button - Drag-over uses a subtle ring instead of a flat background fill Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/ui/shadcn-io/kanban.tsx | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/frontend/src/components/ui/shadcn-io/kanban.tsx b/src/frontend/src/components/ui/shadcn-io/kanban.tsx index 18310d948..9a6333bb7 100644 --- a/src/frontend/src/components/ui/shadcn-io/kanban.tsx +++ b/src/frontend/src/components/ui/shadcn-io/kanban.tsx @@ -1,6 +1,7 @@ "use client"; import { createContext, useContext, useState, useCallback, useRef, ReactNode } from "react"; +import { Circle, MoreHorizontal } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; @@ -210,9 +211,9 @@ export function KanbanColumn({ id, name, color, width, children, className }: Ka return (
-
-

- {color &&
} - {name || id} - {showCounts && ( - ({columnTaskCount}) - )} -

+
+ {color ? ( + + ) : ( + + )} +

{name || id}

+ {showCounts && ( + {columnTaskCount} + )} +
{children}
From 1b8af0dfa9d12a11ee3e4953b1dc2a40f1b2c926 Mon Sep 17 00:00:00 2001 From: Joel Bystedt Date: Wed, 15 Jul 2026 14:00:05 +0200 Subject: [PATCH 2/5] update branch --- src/Ivy/Views/Kanban/KanbanBuilder.cs | 22 +++++- src/Ivy/Widgets/Kanban/Kanban.cs | 8 ++ .../src/components/ui/shadcn-io/kanban.tsx | 5 +- src/frontend/src/widgets/kanban/types.ts | 10 ++- .../src/widgets/kanban/useKanbanData.ts | 78 ++++++++++++++----- 5 files changed, 98 insertions(+), 25 deletions(-) diff --git a/src/Ivy/Views/Kanban/KanbanBuilder.cs b/src/Ivy/Views/Kanban/KanbanBuilder.cs index f8beae4f5..79e79bc2f 100644 --- a/src/Ivy/Views/Kanban/KanbanBuilder.cs +++ b/src/Ivy/Views/Kanban/KanbanBuilder.cs @@ -24,6 +24,7 @@ public class KanbanBuilder( private Size? _width = Size.Full(); private Size? _height = Size.Full(); private Size? _columnWidth; + private TGroupKey[]? _staticColumns; public KanbanBuilder Builder(Func, IBuilder> builder) { @@ -111,6 +112,17 @@ public KanbanBuilder ColumnHeader(Func hea return this; } + /// + /// Defines a fixed set of columns that always render, in the given order, + /// even when a column has no cards. Cards whose group key is not listed are + /// appended as extra columns after the fixed ones. + /// + public KanbanBuilder Columns(params TGroupKey[] columns) + { + _staticColumns = columns; + return this; + } + private static string HumanizeGroupKey(TGroupKey key) { if (key is Enum enumValue) @@ -121,7 +133,7 @@ private static string HumanizeGroupKey(TGroupKey key) public override object? Build() { - if (!records.Any()) + if (!records.Any() && _staticColumns == null) { return _empty ?? new Fragment(); } @@ -212,7 +224,13 @@ private static string HumanizeGroupKey(TGroupKey key) ShowCounts = true, Width = _width ?? Size.Full(), Height = _height ?? Size.Full(), - ColumnWidth = _columnWidth + ColumnWidth = _columnWidth, + Columns = _staticColumns? + .Select((key, index) => new KanbanColumnDef( + key, + _columnHeaderSelector != null ? _columnHeaderSelector(key) : HumanizeGroupKey(key), + index)) + .ToArray() }; if (_onMove != null) diff --git a/src/Ivy/Widgets/Kanban/Kanban.cs b/src/Ivy/Widgets/Kanban/Kanban.cs index 020047c1a..9121e5144 100644 --- a/src/Ivy/Widgets/Kanban/Kanban.cs +++ b/src/Ivy/Widgets/Kanban/Kanban.cs @@ -1,6 +1,12 @@ // ReSharper disable once CheckNamespace namespace Ivy; +/// +/// A fixed column definition for a Kanban board. When provided, columns render +/// in the given order even when they contain no cards. +/// +public record KanbanColumnDef(object Id, string? Name = null, int? Order = null); + /// /// A visual board for managing tasks and workflows. /// @@ -16,6 +22,8 @@ internal Kanban() { } [Prop] public Size? ColumnWidth { get; set; } + [Prop] public KanbanColumnDef[]? Columns { get; set; } + [Event] public EventHandler>? OnCardMove { get; set; } public static Kanban operator |(Kanban kanban, KanbanCard child) diff --git a/src/frontend/src/components/ui/shadcn-io/kanban.tsx b/src/frontend/src/components/ui/shadcn-io/kanban.tsx index 9a6333bb7..9bf2fb896 100644 --- a/src/frontend/src/components/ui/shadcn-io/kanban.tsx +++ b/src/frontend/src/components/ui/shadcn-io/kanban.tsx @@ -263,7 +263,10 @@ export function KanbanCards({ id, children }: KanbanCardsProps) { const showLineIndicator = isTargetColumn && isSameColumnDrag; return ( - + // The viewport's content wrapper defaults to display:table, which sizes to its + // content and lets wide cards overflow the fixed column width; force it to block + // so cards are laid out at the column width and truncate internally instead. +
{columnTasks.map((task, index) => { const isDraggedCard = task.id === draggedCardId; diff --git a/src/frontend/src/widgets/kanban/types.ts b/src/frontend/src/widgets/kanban/types.ts index 9dc69b23d..decc4d5f1 100644 --- a/src/frontend/src/widgets/kanban/types.ts +++ b/src/frontend/src/widgets/kanban/types.ts @@ -8,6 +8,14 @@ export interface Column { order: number; } +/** Column definition as serialized from the backend Kanban.Columns prop. */ +export interface ProvidedColumn { + id: string | number; + name?: string; + color?: string; + order?: number; +} + export interface TaskWithWidgetId extends Task { widgetId: string; } @@ -29,7 +37,7 @@ export interface ExtractedKanbanData { export interface KanbanWidgetProps { id: string; - columns?: Column[]; + columns?: ProvidedColumn[]; tasks?: Task[]; events?: string[]; width?: string; diff --git a/src/frontend/src/widgets/kanban/useKanbanData.ts b/src/frontend/src/widgets/kanban/useKanbanData.ts index a630903cb..e813984bf 100644 --- a/src/frontend/src/widgets/kanban/useKanbanData.ts +++ b/src/frontend/src/widgets/kanban/useKanbanData.ts @@ -1,6 +1,12 @@ import React from "react"; import { Task } from "@/components/ui/shadcn-io/kanban"; -import type { Column, TaskWithWidgetId, CardData, ExtractedKanbanData } from "./types"; +import type { + Column, + ProvidedColumn, + TaskWithWidgetId, + CardData, + ExtractedKanbanData, +} from "./types"; interface WidgetNodeChild { type: string; @@ -51,13 +57,56 @@ function sortColumnKeysByBackendOrder(columnKeys: string[]): string[] { }); } +function normalizeProvidedColumns(columns: ProvidedColumn[]): Column[] { + return columns.map((column, index) => ({ + id: String(column.id), + name: column.name ?? String(column.id), + color: column.color ?? "", + order: column.order ?? index, + })); +} + +/** + * Builds the final column list: provided (static) columns first in their + * declared order, followed by any extra columns discovered on the cards. + * Without provided columns, falls back to deriving columns from the cards. + */ +function buildColumns( + dataColumnKeys: string[], + columnNameMap: Map, + providedColumns: Column[], +): Column[] { + if (providedColumns.length > 0) { + const knownIds = new Set(providedColumns.map((c) => c.id)); + const extras = dataColumnKeys.filter((key) => !knownIds.has(key)); + return [ + ...providedColumns, + ...extras.map((key, index) => ({ + id: key, + name: columnNameMap.get(key) ?? key, + color: "", + order: providedColumns.length + index, + })), + ]; + } + + const sortedKeys = sortColumnKeysByBackendOrder(dataColumnKeys); + return sortedKeys.map((key, index) => ({ + id: key, + name: columnNameMap.get(key) ?? key, + color: "", + order: index, + })); +} + export function useKanbanData( slots: { default?: React.ReactNode[] } | undefined, tasks: Task[], - columns: Column[], + columns: ProvidedColumn[], widgetNodeChildren?: WidgetNodeChild[], ): ExtractedKanbanData { return React.useMemo(() => { + const providedColumns = normalizeProvidedColumns(columns); const kanbanChildren = (widgetNodeChildren || []).filter((c) => c.type === "Ivy.KanbanCard"); if (kanbanChildren.length > 0) { const extractedCards: CardData[] = []; @@ -87,24 +136,18 @@ export function useKanbanData( const allColumnKeys = extractColumnKeysFromCards(extractedCards); const columnNameMap = buildColumnNameMap(extractedCards); - const finalColumnKeys = sortColumnKeysByBackendOrder(allColumnKeys); - - const extractedColumns: Column[] = finalColumnKeys.map((key, index) => ({ - id: key, - name: columnNameMap.get(key) ?? key, - color: "", - order: index, - })); + const extractedColumns = buildColumns(allColumnKeys, columnNameMap, providedColumns); + const columnIndexById = new Map(extractedColumns.map((c, i) => [c.id, i])); const extractedTasks: TaskWithWidgetId[] = extractedCards.map((card) => { const column = card.columnKey || "Default"; - const columnIndex = finalColumnKeys.indexOf(column); + const columnIndex = columnIndexById.get(column) ?? 0; return { id: card.cardId, title: "", status: column, - statusOrder: columnIndex >= 0 ? columnIndex : 0, + statusOrder: columnIndex, priority: card.priority || 0, description: "", assignee: "", @@ -130,14 +173,7 @@ export function useKanbanData( const statusKeys = Array.from(statusMap.keys()); const columnNameMap = buildColumnNameMap(extractedCards); - const columnKeys = sortColumnKeysByBackendOrder(statusKeys); - - const extractedColumns: Column[] = columnKeys.map((status, index) => ({ - id: status, - name: columnNameMap.get(status) ?? status, - color: "", - order: index, - })); + const extractedColumns = buildColumns(statusKeys, columnNameMap, providedColumns); const cardToTaskMap = new Map(); tasks.forEach((task) => { @@ -161,7 +197,7 @@ export function useKanbanData( return { tasks: tasks.map((t) => ({ ...t, widgetId: t.id })), - columns, + columns: providedColumns, cards: [], }; }, [slots, tasks, columns, widgetNodeChildren]); From fe7b3a79baa0838aa5eab647f56f537ccbb05c15 Mon Sep 17 00:00:00 2001 From: Joel Bystedt Date: Wed, 15 Jul 2026 14:42:06 +0200 Subject: [PATCH 3/5] Add Kanban column header icons KanbanColumnDef gains an Icon (Lucide name) and KanbanBuilder a ColumnIcon selector; the column header renders the icon in place of the color ring, with a semibold title to match the board redesign. Co-Authored-By: Claude Opus 4.8 --- src/Ivy/Views/Kanban/KanbanBuilder.cs | 14 +++++++++++++- src/Ivy/Widgets/Kanban/Kanban.cs | 5 +++-- .../src/components/ui/shadcn-io/kanban.tsx | 19 ++++++++++++++++--- .../src/widgets/kanban/KanbanWidget.tsx | 1 + src/frontend/src/widgets/kanban/types.ts | 2 ++ .../src/widgets/kanban/useKanbanData.ts | 1 + 6 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/Ivy/Views/Kanban/KanbanBuilder.cs b/src/Ivy/Views/Kanban/KanbanBuilder.cs index 79e79bc2f..0f1f2eea7 100644 --- a/src/Ivy/Views/Kanban/KanbanBuilder.cs +++ b/src/Ivy/Views/Kanban/KanbanBuilder.cs @@ -20,6 +20,7 @@ public class KanbanBuilder( private Func? _customCardRenderer; private Func, ValueTask>? _onMove; private Func? _columnHeaderSelector; + private Func? _columnIconSelector; private object? _empty; private Size? _width = Size.Full(); private Size? _height = Size.Full(); @@ -112,6 +113,16 @@ public KanbanBuilder ColumnHeader(Func hea return this; } + /// + /// Maps a column group key to a Lucide icon name (e.g. "Feather") shown in the + /// column header. Only applies to fixed columns declared via . + /// + public KanbanBuilder ColumnIcon(Func iconSelector) + { + _columnIconSelector = iconSelector; + return this; + } + /// /// Defines a fixed set of columns that always render, in the given order, /// even when a column has no cards. Cards whose group key is not listed are @@ -229,7 +240,8 @@ private static string HumanizeGroupKey(TGroupKey key) .Select((key, index) => new KanbanColumnDef( key, _columnHeaderSelector != null ? _columnHeaderSelector(key) : HumanizeGroupKey(key), - index)) + index, + _columnIconSelector?.Invoke(key))) .ToArray() }; diff --git a/src/Ivy/Widgets/Kanban/Kanban.cs b/src/Ivy/Widgets/Kanban/Kanban.cs index 9121e5144..fbf9e2c94 100644 --- a/src/Ivy/Widgets/Kanban/Kanban.cs +++ b/src/Ivy/Widgets/Kanban/Kanban.cs @@ -3,9 +3,10 @@ namespace Ivy; /// /// A fixed column definition for a Kanban board. When provided, columns render -/// in the given order even when they contain no cards. +/// in the given order even when they contain no cards. is +/// an optional Lucide icon name shown in the column header (e.g. "Feather"). /// -public record KanbanColumnDef(object Id, string? Name = null, int? Order = null); +public record KanbanColumnDef(object Id, string? Name = null, int? Order = null, string? Icon = null); /// /// A visual board for managing tasks and workflows. diff --git a/src/frontend/src/components/ui/shadcn-io/kanban.tsx b/src/frontend/src/components/ui/shadcn-io/kanban.tsx index 9bf2fb896..04ddb864e 100644 --- a/src/frontend/src/components/ui/shadcn-io/kanban.tsx +++ b/src/frontend/src/components/ui/shadcn-io/kanban.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useCallback, useRef, ReactNode } from "react"; import { Circle, MoreHorizontal } from "lucide-react"; +import Icon from "@/components/Icon"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; @@ -21,6 +22,7 @@ export interface Column { id: string; name: string; color: string; + icon?: string; } interface DropTarget { @@ -144,12 +146,21 @@ interface KanbanColumnProps { id: string; name?: string; color?: string; + icon?: string; width?: string; children: ReactNode; className?: string; } -export function KanbanColumn({ id, name, color, width, children, className }: KanbanColumnProps) { +export function KanbanColumn({ + id, + name, + color, + icon, + width, + children, + className, +}: KanbanColumnProps) { const { onCardMove, data, @@ -225,12 +236,14 @@ export function KanbanColumn({ id, name, color, width, children, className }: Ka onDrop={handleDrop} >
- {color ? ( + {icon ? ( + + ) : color ? ( ) : ( )} -

{name || id}

+

{name || id}

{showCounts && ( {columnTaskCount} )} diff --git a/src/frontend/src/widgets/kanban/KanbanWidget.tsx b/src/frontend/src/widgets/kanban/KanbanWidget.tsx index cbd32fce6..34d89b35e 100644 --- a/src/frontend/src/widgets/kanban/KanbanWidget.tsx +++ b/src/frontend/src/widgets/kanban/KanbanWidget.tsx @@ -71,6 +71,7 @@ export const KanbanWidget: React.FC = ({ id={column.id} name={column.name} color={column.color} + icon={column.icon} width={columnWidth} > diff --git a/src/frontend/src/widgets/kanban/types.ts b/src/frontend/src/widgets/kanban/types.ts index decc4d5f1..9e24926ca 100644 --- a/src/frontend/src/widgets/kanban/types.ts +++ b/src/frontend/src/widgets/kanban/types.ts @@ -6,6 +6,7 @@ export interface Column { name: string; color: string; order: number; + icon?: string; } /** Column definition as serialized from the backend Kanban.Columns prop. */ @@ -14,6 +15,7 @@ export interface ProvidedColumn { name?: string; color?: string; order?: number; + icon?: string; } export interface TaskWithWidgetId extends Task { diff --git a/src/frontend/src/widgets/kanban/useKanbanData.ts b/src/frontend/src/widgets/kanban/useKanbanData.ts index e813984bf..7f7b7aa8b 100644 --- a/src/frontend/src/widgets/kanban/useKanbanData.ts +++ b/src/frontend/src/widgets/kanban/useKanbanData.ts @@ -63,6 +63,7 @@ function normalizeProvidedColumns(columns: ProvidedColumn[]): Column[] { name: column.name ?? String(column.id), color: column.color ?? "", order: column.order ?? index, + icon: column.icon, })); } From 81458f816320b8c7c710ea8b85c94b0016d5f3be Mon Sep 17 00:00:00 2001 From: Joel Bystedt Date: Thu, 16 Jul 2026 10:50:27 +0200 Subject: [PATCH 4/5] Fix enum column conversion in Kanban OnMove and refine board layout - KanbanBuilder: card-move events with enum group keys were silently dropped (Convert.ChangeType cannot parse enum names); convert via Enum.TryParse with JsonElement/JsonNode unwrapping - Board: 16px gap between columns - Columns: 16px corner radius; without an explicit width they now share the full board width (flex-1, min 280px, max 1600px) - Cards container: uniform 12px padding for symmetric card gutters Co-Authored-By: Claude Fable 5 --- src/Ivy/Views/Kanban/KanbanBuilder.cs | 80 ++++++++++++++----- .../src/components/ui/shadcn-io/kanban.tsx | 16 ++-- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/Ivy/Views/Kanban/KanbanBuilder.cs b/src/Ivy/Views/Kanban/KanbanBuilder.cs index 0f1f2eea7..72dc44a34 100644 --- a/src/Ivy/Views/Kanban/KanbanBuilder.cs +++ b/src/Ivy/Views/Kanban/KanbanBuilder.cs @@ -134,6 +134,60 @@ public KanbanBuilder Columns(params TGroupKey[] columns) return this; } + /// + /// Converts the raw ToColumn payload of a card-move event (a string, number, or + /// JsonElement/JsonNode from event deserialization) back into the board's group + /// key type. Enum keys round-trip as their serialized names, which + /// cannot parse. + /// + private static bool TryConvertGroupKey(object toColumn, out TGroupKey key) + { + if (toColumn is TGroupKey typedKey) + { + key = typedKey; + return true; + } + + // Unwrap JSON payloads to a plain string/number before converting. + var raw = toColumn switch + { + System.Text.Json.JsonElement je => je.ValueKind switch + { + System.Text.Json.JsonValueKind.String => je.GetString(), + System.Text.Json.JsonValueKind.Number => je.GetRawText(), + _ => je.ToString() + }, + System.Text.Json.Nodes.JsonValue jv => jv.TryGetValue(out var s) ? s : jv.ToString(), + System.Text.Json.Nodes.JsonNode jn => jn.ToString(), + _ => toColumn.ToString() + }; + + key = default!; + if (raw == null) + return false; + + try + { + if (typeof(TGroupKey).IsEnum) + { + if (Enum.TryParse(typeof(TGroupKey), raw, ignoreCase: true, out var parsed) && + Enum.IsDefined(typeof(TGroupKey), parsed!)) + { + key = (TGroupKey)parsed!; + return true; + } + return false; + } + + key = (TGroupKey)Convert.ChangeType(raw, typeof(TGroupKey)); + return true; + } + catch + { + return false; + } + } + private static string HumanizeGroupKey(TGroupKey key) { if (key is Enum enumValue) @@ -251,29 +305,13 @@ private static string HumanizeGroupKey(TGroupKey key) { OnCardMove = new(e => { - if (e.Value.ToColumn == null) + if (e.Value.ToColumn == null || !TryConvertGroupKey(e.Value.ToColumn, out var groupKey)) return ValueTask.CompletedTask; - if (e.Value.ToColumn is TGroupKey groupKey) - { - return _onMove(new Event( - e.EventName, - e.Sender, - (e.Value.CardId, groupKey, e.Value.TargetIndex))); - } - - try - { - var convertedKey = (TGroupKey)Convert.ChangeType(e.Value.ToColumn, typeof(TGroupKey)); - return _onMove(new Event( - e.EventName, - e.Sender, - (e.Value.CardId, convertedKey, e.Value.TargetIndex))); - } - catch - { - return ValueTask.CompletedTask; - } + return _onMove(new Event( + e.EventName, + e.Sender, + (e.Value.CardId, groupKey, e.Value.TargetIndex))); }) }; } diff --git a/src/frontend/src/components/ui/shadcn-io/kanban.tsx b/src/frontend/src/components/ui/shadcn-io/kanban.tsx index 04ddb864e..8d81e3543 100644 --- a/src/frontend/src/components/ui/shadcn-io/kanban.tsx +++ b/src/frontend/src/components/ui/shadcn-io/kanban.tsx @@ -134,7 +134,7 @@ interface KanbanBoardProps { export function KanbanBoard({ children, className }: KanbanBoardProps) { return (
{children} @@ -222,15 +222,15 @@ export function KanbanColumn({ return (
-
+
{columnTasks.map((task, index) => { const isDraggedCard = task.id === draggedCardId; const shouldShift = From 9b838cb532ea02999671e4e9fa2dcb6058bb2ee5 Mon Sep 17 00:00:00 2001 From: Joel Bystedt Date: Thu, 16 Jul 2026 13:30:04 +0200 Subject: [PATCH 5/5] Refine Kanban column layout: smaller gap, fill width, muted bg - Reduce gap between columns (gap-4 -> gap-2) - Columns fill the board width and no longer overflow horizontally (min-w-0 instead of min-w-70); keep the 1600px max-width - Solid --muted column background (was bg-muted/40) so columns read as #FAFAFA in light and #212121-range in dark Co-Authored-By: Claude Opus 4.8 --- .../src/components/ui/shadcn-io/kanban.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/frontend/src/components/ui/shadcn-io/kanban.tsx b/src/frontend/src/components/ui/shadcn-io/kanban.tsx index 8d81e3543..8b78092c5 100644 --- a/src/frontend/src/components/ui/shadcn-io/kanban.tsx +++ b/src/frontend/src/components/ui/shadcn-io/kanban.tsx @@ -134,8 +134,8 @@ interface KanbanBoardProps { export function KanbanBoard({ children, className }: KanbanBoardProps) { return (
{children}
@@ -222,11 +222,11 @@ export function KanbanColumn({ return (