JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialamplifyprismanextjsnpmStorybookcssTailwind

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 4

AdminMarch 23, 202621 min read
Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 4

On this page

  • Part 4: The Issue Detail View with Markdown Editing
  • The Design Decision: Modal vs. Route vs. Panel
  • Option 1: A Dedicated Route
  • Option 2: A Side Panel
  • Option 3: A Modal
  • Our Choice: Modal
  • Choosing a Markdown Library
  • Full-Featured Editors
  • The Lightweight Approach
  • Our Choice: Textarea + react-markdown
  • Extending @dxsolo/ui: Four New Components
  • Component 1: Textarea
  • Component 2: Select
  • Component 3: Checkbox
  • Component 4: Badge
  • Storybook Stories
  • Exporting the New Components
  • Commit and Publish
  • Installation
  • Configuring the Typography Plugin
  • Opening the Detail View
  • Distinguishing Clicks from Drags
  • Adding the Click Callback
  • Tracking Selection in the Board
  • The Issue Detail Modal
  • The Markdown Preview Component
  • The Subtask List
  • Server Actions
  • Updating Issues
  • Subtask Actions
  • Wiring It All Together
  • Refreshing on Close
  • Run it
  • Handling Edge Cases
  • Keeping the Board in Sync
  • Saving on Close
  • Subtask Count on the Board
  • Full File Reference
  • What We Built in Part 4
  • Troubleshooting
  • What is Next

On this page

  • Part 4: The Issue Detail View with Markdown Editing
  • The Design Decision: Modal vs. Route vs. Panel
  • Option 1: A Dedicated Route
  • Option 2: A Side Panel
  • Option 3: A Modal
  • Our Choice: Modal
  • Choosing a Markdown Library
  • Full-Featured Editors
  • The Lightweight Approach
  • Our Choice: Textarea + react-markdown
  • Extending @dxsolo/ui: Four New Components
  • Component 1: Textarea
  • Component 2: Select
  • Component 3: Checkbox
  • Component 4: Badge
  • Storybook Stories
  • Exporting the New Components
  • Commit and Publish
  • Installation
  • Configuring the Typography Plugin
  • Opening the Detail View
  • Distinguishing Clicks from Drags
  • Adding the Click Callback
  • Tracking Selection in the Board
  • The Issue Detail Modal
  • The Markdown Preview Component
  • The Subtask List
  • Server Actions
  • Updating Issues
  • Subtask Actions
  • Wiring It All Together
  • Refreshing on Close
  • Run it
  • Handling Edge Cases
  • Keeping the Board in Sync
  • Saving on Close
  • Subtask Count on the Board
  • Full File Reference
  • What We Built in Part 4
  • Troubleshooting
  • What is Next
PreviousBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 3NextBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 5

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

Part 4: The Issue Detail View with Markdown Editing

Welcome back. In Part 3, we built a complete drag-and-drop system: cards move between columns, insert at precise positions, and persist to the database with optimistic updates. The board works. But clicking a card does nothing.

Now we fix that. By the end of this post, clicking an issue card will open a modal showing the full issue — an editable title, a markdown description with live preview, a subtask checklist, and metadata controls for priority and status. Changes auto-save as you type.

Along the way, we will add four new components to @dxsolo/ui: Textarea, Select, Checkbox, and Badge. This is the first time in the series we extend the component library. The goal is to show the workflow: identify a need in the app, build the component in the library, publish, and consume.

Series overview:

  1. Project Setup and Data Modeling
  2. The Board Layout: CSS Grid, Container Queries, and Column Architecture
  3. Drag and Drop That Actually Works
  4. The Issue Detail View with Markdown Editing
  5. Projects, Epics, and Filtering
  6. Polish, Persistence, and Publishing

The Design Decision: Modal vs. Route vs. Panel

Before writing code, we need to decide how the detail view opens. There are three reasonable options:

Option 1: A Dedicated Route

Navigate to /projects/[projectId]/issues/[issueId]. This gives you a URL for every issue, which is great for sharing and deep-linking. But it means leaving the board view entirely. You lose context — you cannot see the other columns while editing an issue. For a solo dev's board, this feels disruptive.

Option 2: A Side Panel

A slide-out panel on the right side of the board, like Linear or Notion. The board stays visible but compressed. This is the most polished UX, but it requires rethinking the board layout to accommodate a variable-width panel. It also complicates drag and drop — what happens when you drag a card while the panel is open?

Option 3: A Modal

A centered modal overlay. The board is still visible (dimmed) behind the modal. Clicking outside or pressing Escape closes it. This is the simplest to implement, keeps the board context visible, and avoids layout complications with drag and drop.

Our Choice: Modal

We are going with a modal. Here is why:

  1. We already have one. The Modal component from @dxsolo/ui handles the overlay, focus trapping, and close behavior. We do not need to build infrastructure.
  2. No layout changes. The board layout from Part 2 stays exactly as-is. The modal floats above it.
  3. Context preservation. The user can see which column the issue is in while editing it.
  4. Simplest path to done. A side panel is a better long-term UX, but a modal gets us editing issues today with minimal code.

We can always migrate to a side panel later. The editing components we build will be the same either way.


Choosing a Markdown Library

Our Issue model has a description field that stores markdown. We need two things: a way to edit markdown and a way to render it. Let's evaluate the options.

Full-Featured Editors

Libraries like @uiw/react-md-editor, Milkdown, and Tiptap provide rich editing experiences — toolbars, keyboard shortcuts, live formatting. They are impressive but heavy. @uiw/react-md-editor pulls in CodeMirror. Tiptap uses ProseMirror. These are real editor frameworks with real bundle sizes.

For a solo dev's kanban board, we do not need a toolbar for bold and italic. We are developers. We know markdown syntax.

The Lightweight Approach

Use a plain <textarea> for editing and react-markdown for rendering. Toggle between write and preview modes using our Tabs component from @dxsolo/ui. This gives us:

  • A textarea that we fully control (no third-party editor quirks)
  • Beautiful rendered markdown with GitHub Flavored Markdown support
  • A small bundle: react-markdown + remark-gfm are lightweight
  • Full styling control via Tailwind's @tailwindcss/typography plugin

Our Choice: Textarea + react-markdown

We will use a <textarea> for writing and react-markdown for rendering. A tabbed interface lets the user switch between "Write" and "Preview" modes — the same pattern GitHub uses for issue descriptions.

But we do not have a Textarea component in @dxsolo/ui. We also need Select for status/priority dropdowns, Checkbox for subtasks, and Badge for priority labels. Let's build these first.


Extending @dxsolo/ui: Four New Components

The component library currently ships Button, Card, Input, Modal, and Tabs. The issue detail view needs four more primitives. We will build them in the library, publish a new version, and consume them in the kanban app.

If you have the library repo cloned locally, cd into it:

bash
cd path/to/jpComponent/my-ui

Component 1: Textarea

The Textarea component follows the same pattern as Input — a forwardRef wrapper around a native element with optional label, helperText, and error props. The only difference is the element type and a few textarea-specific styles.

Create lib/components/Textarea/Textarea.tsx:

tsx
// lib/components/Textarea/Textarea.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
 
type TextareaProps = React.ComponentPropsWithoutRef<'textarea'> & {
  label?: string;
  helperText?: string;
  error?: string;
};
 
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
  ({ className, label, helperText, error, id: externalId, ...props }, ref) => {
    const generatedId = useId();
    const id = externalId ?? generatedId;
    const helperId = `${id}-helper`;
    const errorId = `${id}-error`;
    const describedBy = error ? errorId : helperText ? helperId : undefined;
 
    return (
      <div className="flex flex-col gap-1.5">
        {label && (
          <label htmlFor={id} className="text-sm font-medium text-foreground">
            {label}
          </label>
        )}
        <textarea
          ref={ref}
          id={id}
          className={cn(
            'w-full min-h-[80px] rounded-md border px-3 py-2 text-sm',
            'bg-background text-foreground placeholder:text-muted-foreground',
            'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
            'disabled:cursor-not-allowed disabled:opacity-50',
            'resize-y',
            error
              ? 'border-destructive focus-visible:ring-destructive'
              : 'border-input-border',
            className
          )}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          {...props}
        />
        {error && (
          <p id={errorId} className="text-sm text-destructive" role="alert">
            {error}
          </p>
        )}
        {!error && helperText && (
          <p id={helperId} className="text-sm text-muted-foreground">
            {helperText}
          </p>
        )}
      </div>
    );
  }
);
 
Textarea.displayName = 'Textarea';

Create the barrel export lib/components/Textarea/index.ts:

typescript
export { Textarea } from './Textarea';

This is nearly identical to Input. The differences: <textarea> instead of <input>, min-h-[80px] for a reasonable default height, py-2 instead of a fixed height, and resize-y to allow vertical resizing. The accessibility pattern is the same: auto-generated IDs, aria-describedby linking to helper/error text, and aria-invalid for error states.

Component 2: Select

Native <select> elements are underused in React. They are accessible by default, keyboard-navigable, and work on every device. A styled wrapper that matches our Input component is all we need.

Create lib/components/Select/Select.tsx:

tsx
// lib/components/Select/Select.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
 
type SelectProps = React.ComponentPropsWithoutRef<'select'> & {
  label?: string;
  helperText?: string;
  error?: string;
};
 
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
  ({ className, label, helperText, error, id: externalId, children, ...props }, ref) => {
    const generatedId = useId();
    const id = externalId ?? generatedId;
    const helperId = `${id}-helper`;
    const errorId = `${id}-error`;
    const describedBy = error ? errorId : helperText ? helperId : undefined;
 
    return (
      <div className="flex flex-col gap-1.5">
        {label && (
          <label htmlFor={id} className="text-sm font-medium text-foreground">
            {label}
          </label>
        )}
        <select
          ref={ref}
          id={id}
          className={cn(
            'h-10 w-full rounded-md border px-3 text-sm appearance-none',
            'bg-background text-foreground',
            'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
            'disabled:cursor-not-allowed disabled:opacity-50',
            error
              ? 'border-destructive focus-visible:ring-destructive'
              : 'border-input-border',
            className
          )}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          {...props}
        >
          {children}
        </select>
        {error && (
          <p id={errorId} className="text-sm text-destructive" role="alert">
            {error}
          </p>
        )}
        {!error && helperText && (
          <p id={helperId} className="text-sm text-muted-foreground">
            {helperText}
          </p>
        )}
      </div>
    );
  }
);
 
Select.displayName = 'Select';

Create lib/components/Select/index.ts:

typescript
export { Select } from './Select';

The Select follows the exact same wrapper pattern as Input and Textarea. The appearance-none class removes the browser's default dropdown arrow styling so you can add a custom one via CSS if desired. The children prop passes through <option> elements — the component does not abstract over the options themselves.

Component 3: Checkbox

A checkbox with an optional label. This is simpler than the others because a checkbox does not need helper text or error states in most cases.

Create lib/components/Checkbox/Checkbox.tsx:

tsx
// lib/components/Checkbox/Checkbox.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
 
type CheckboxProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'type'> & {
  label?: string;
};
 
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
  ({ className, label, id: externalId, ...props }, ref) => {
    const generatedId = useId();
    const id = externalId ?? generatedId;
 
    return (
      <div className="flex items-center gap-2">
        <input
          ref={ref}
          type="checkbox"
          id={id}
          className={cn(
            'h-4 w-4 rounded border border-input-border',
            'text-primary bg-background',
            'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
            'disabled:cursor-not-allowed disabled:opacity-50',
            className
          )}
          {...props}
        />
        {label && (
          <label htmlFor={id} className="text-sm text-foreground select-none cursor-pointer">
            {label}
          </label>
        )}
      </div>
    );
  }
);
 
Checkbox.displayName = 'Checkbox';

Create lib/components/Checkbox/index.ts:

typescript
export { Checkbox } from './Checkbox';

We omit type from the props with Omit<..., 'type'> to prevent consumers from accidentally changing the input type. The label is clickable via htmlFor — clicking the text toggles the checkbox, which is the expected behavior.

Component 4: Badge

Priority labels ("Urgent", "High", "Med", "Low") and status pills ("Open", "In Progress") are colored inline labels. We have been hand-styling these with Tailwind classes. A Badge component with CVA variants makes them reusable and consistent.

Create lib/components/Badge/variants.ts:

typescript
// lib/components/Badge/variants.ts
import { cva } from 'class-variance-authority';
 
export const badgeVariants = cva(
  [
    'inline-flex items-center',
    'text-xs font-medium',
    'rounded-full px-2 py-0.5',
  ],
  {
    variants: {
      intent: {
        default: 'bg-muted text-foreground',
        primary: 'bg-primary/10 text-primary',
        success: 'bg-green-100 text-green-700',
        warning: 'bg-amber-100 text-amber-700',
        danger: 'bg-red-100 text-red-700',
        info: 'bg-blue-100 text-blue-700',
      },
      size: {
        sm: 'text-xs px-1.5 py-0.5',
        md: 'text-xs px-2 py-0.5',
        lg: 'text-sm px-2.5 py-1',
      },
    },
    defaultVariants: {
      intent: 'default',
      size: 'md',
    },
  }
);

Create lib/components/Badge/Badge.tsx:

tsx
// lib/components/Badge/Badge.tsx
import { forwardRef } from 'react';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '../../utils/cn';
import { badgeVariants } from './variants';
 
type BadgeProps = React.ComponentPropsWithoutRef<'span'> &
  VariantProps<typeof badgeVariants>;
 
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
  ({ className, intent, size, ...props }, ref) => (
    <span
      ref={ref}
      className={cn(badgeVariants({ intent, size }), className)}
      {...props}
    />
  )
);
 
Badge.displayName = 'Badge';

Create lib/components/Badge/index.ts:

typescript
export { Badge } from './Badge';
export { badgeVariants } from './variants';

The Badge follows the same CVA pattern as Button: a variants.ts file defines the style matrix, the component applies them via cn(). The intent names (success, warning, danger, info) map naturally to our priority levels and status colors.

Storybook Stories

Every component in @dxsolo/ui has a Storybook story. The stories serve as documentation, a visual test harness, and a development sandbox. Let's add one for each new component.

Create lib/components/Textarea/Textarea.stories.tsx:

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Textarea } from './Textarea';
 
const meta: Meta<typeof Textarea> = {
  component: Textarea,
  argTypes: {
    label: { control: 'text' },
    helperText: { control: 'text' },
    error: { control: 'text' },
    placeholder: { control: 'text' },
    disabled: { control: 'boolean' },
  },
};
 
export default meta;
type Story = StoryObj<typeof Textarea>;
 
export const Default: Story = {
  args: { placeholder: 'Write something...' },
};
 
export const WithLabel: Story = {
  args: { label: 'Description', placeholder: 'Add a description...' },
};
 
export const WithHelperText: Story = {
  args: {
    label: 'Notes',
    helperText: 'Supports markdown syntax',
    placeholder: '## Heading\n\n- list item',
  },
};
 
export const WithError: Story = {
  args: {
    label: 'Description',
    error: 'Description is required',
  },
};
 
export const Disabled: Story = {
  args: { label: 'Locked', placeholder: 'Cannot edit', disabled: true },
};

Create lib/components/Select/Select.stories.tsx:

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Select } from './Select';
 
const meta: Meta<typeof Select> = {
  component: Select,
  argTypes: {
    label: { control: 'text' },
    helperText: { control: 'text' },
    error: { control: 'text' },
    disabled: { control: 'boolean' },
  },
};
 
export default meta;
type Story = StoryObj<typeof Select>;
 
export const Default: Story = {
  render: (args) => (
    <Select {...args}>
      <option value="">Choose...</option>
      <option value="open">Open</option>
      <option value="in_progress">In Progress</option>
      <option value="done">Done</option>
    </Select>
  ),
};
 
export const WithLabel: Story = {
  render: (args) => (
    <Select {...args} label="Status">
      <option value="open">Open</option>
      <option value="in_progress">In Progress</option>
      <option value="review">Review</option>
      <option value="done">Done</option>
    </Select>
  ),
};
 
export const WithError: Story = {
  render: (args) => (
    <Select {...args} label="Priority" error="Priority is required">
      <option value="">Select...</option>
      <option value="low">Low</option>
      <option value="medium">Medium</option>
      <option value="high">High</option>
    </Select>
  ),
};
 
export const Disabled: Story = {
  render: (args) => (
    <Select {...args} label="Locked" disabled>
      <option value="fixed">Cannot change</option>
    </Select>
  ),
};

Create lib/components/Checkbox/Checkbox.stories.tsx:

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Checkbox } from './Checkbox';
 
const meta: Meta<typeof Checkbox> = {
  component: Checkbox,
  argTypes: {
    label: { control: 'text' },
    checked: { control: 'boolean' },
    disabled: { control: 'boolean' },
  },
};
 
export default meta;
type Story = StoryObj<typeof Checkbox>;
 
export const Default: Story = {
  args: { label: 'Accept terms and conditions' },
};
 
export const Checked: Story = {
  args: { label: 'Completed task', defaultChecked: true },
};
 
export const WithoutLabel: Story = {
  args: {},
};
 
export const Disabled: Story = {
  args: { label: 'Cannot toggle', disabled: true, defaultChecked: true },
};

Create lib/components/Badge/Badge.stories.tsx:

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Badge } from './Badge';
 
const meta: Meta<typeof Badge> = {
  component: Badge,
  argTypes: {
    intent: {
      control: 'select',
      options: ['default', 'primary', 'success', 'warning', 'danger', 'info'],
    },
    size: { control: 'select', options: ['sm', 'md', 'lg'] },
  },
};
 
export default meta;
type Story = StoryObj<typeof Badge>;
 
export const Default: Story = {
  args: { children: 'Badge' },
};
 
export const AllVariants: Story = {
  render: () => (
    <div className="flex flex-wrap gap-3">
      {(['default', 'primary', 'success', 'warning', 'danger', 'info'] as const).map((intent) => (
        <Badge key={intent} intent={intent}>
          {intent}
        </Badge>
      ))}
    </div>
  ),
};
 
export const Sizes: Story = {
  render: () => (
    <div className="flex items-center gap-3">
      {(['sm', 'md', 'lg'] as const).map((size) => (
        <Badge key={size} intent="primary" size={size}>
          {size}
        </Badge>
      ))}
    </div>
  ),
};

You can verify all four render correctly by running Storybook locally:

bash
npm run storybook

Exporting the New Components

Update lib/index.ts to export all four:

diff
  export { Button } from './components/Button';
  export { Input } from './components/Input';
  export { Card, CardHeader, CardContent, CardFooter } from './components/Card';
  export { Modal } from './components/Modal';
  export { Tabs, TabsList, TabsTrigger, TabsContent } from './components/Tabs';
+ export { Textarea } from './components/Textarea';
+ export { Select } from './components/Select';
+ export { Checkbox } from './components/Checkbox';
+ export { Badge } from './components/Badge';
 
  export { cn } from './utils/cn';

Commit and Publish

The @dxsolo/ui repo uses semantic-release for automated versioning and publishing. When you push to main, the CI/CD pipeline builds, tests, and publishes to npm automatically based on your commit messages.

Use Conventional Commits so semantic-release picks the right version bump:

bash
git add .
git commit -m "feat: add Textarea, Select, Checkbox, and Badge components
 
- Textarea: multi-line input with label/error states, mirrors Input pattern
- Select: styled native select with label/error states
- Checkbox: input with optional clickable label
- Badge: inline label with CVA intent variants (danger, warning, info, etc.)
- Storybook stories for all four components"
git push origin main

The feat: prefix triggers a minor version bump (e.g., 1.0.0 → 1.1.0). Once the pipeline completes, the new version is on npm.

In the kanban app, install the latest version:

bash
cd path/to/solo-kanban
npm install @dxsolo/ui@latest

If you are developing both projects locally and do not want to wait for CI, you can use npm link to iterate faster:

bash
# In the library — build first so the dist is current
cd path/to/jpComponent/my-ui
npm run build
npm link
 
# In the kanban app
cd path/to/solo-kanban
npm link @dxsolo/ui

Installation

Now back in the kanban app. Install the markdown dependencies:

bash
npm install react-markdown remark-gfm @tailwindcss/typography

Three packages:

  • react-markdown — renders markdown strings to React elements. No dangerouslySetInnerHTML. It parses the markdown into an AST and renders React components, which means it is safe by default.
  • remark-gfm — a plugin that adds GitHub Flavored Markdown support: tables, strikethrough, task lists, and autolinks.
  • @tailwindcss/typography — provides the prose class that styles rendered HTML (headings, paragraphs, lists, code blocks) with sensible typographic defaults.

Configuring the Typography Plugin

Add the typography plugin to your CSS. With Tailwind v4, plugins are imported directly in CSS:

css
/* src/app/globals.css */
@import "tailwindcss";
@import "@dxsolo/ui/theme.css";
@plugin "@tailwindcss/typography";

Add the @plugin directive after your existing imports.


Opening the Detail View

We need three things to happen when a user clicks a card:

  1. The Board component tracks which issue is selected (or null)
  2. The IssueCard component calls a callback when clicked (not when dragged)
  3. A modal opens showing the selected issue

Distinguishing Clicks from Drags

This is trickier than it sounds. A drag starts with a mousedown, which is also the start of a click. If we attach an onClick handler to the card, it will fire when the user releases a drag, which is not what we want.

Pragmatic Drag and Drop helps here. When a drag actually starts (the user moves the mouse enough to trigger a drag), the browser cancels the click event. So a plain onClick on the card wrapper will only fire for actual clicks, not drags. We do not need any special handling.

Adding the Click Callback

Update IssueCard to accept and call an onSelect prop:

diff
  interface IssueCardProps {
    issue: IssueWithRelations;
    onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
+   onSelect: (issue: IssueWithRelations) => void;
  }
diff
- export function IssueCard({ issue, onMoveIssue }: IssueCardProps) {
+ export function IssueCard({ issue, onMoveIssue, onSelect }: IssueCardProps) {

Add the click handler to the card wrapper div:

diff
- <div ref={ref} className="relative">
+ <div ref={ref} className="relative" onClick={() => onSelect(issue)}>

Three lines changed. The onClick fires only on genuine clicks — not on drags — because the browser suppresses click events when a native drag occurs.

Tracking Selection in the Board

Update Board.tsx to track the selected issue and pass onSelect through:

diff
  export function Board({ issues: initialIssues }: BoardProps) {
    const [issues, setIssues] = useState(initialIssues);
    const [isMoving, setIsMoving] = useState(false);
+   const [selectedIssue, setSelectedIssue] = useState<IssueWithRelations | null>(null);

In Column.tsx, add the new prop to the interface:

diff
  interface ColumnProps {
    status: IssueStatus;
    label: string;
    issues: IssueWithRelations[];
    onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
+   onSelectIssue: (issue: IssueWithRelations) => void;
  }

Destructure it in the function signature:

diff
- export function Column({ status, label, issues, onMoveIssue }: ColumnProps) {
+ export function Column({ status, label, issues, onMoveIssue, onSelectIssue }: ColumnProps) {

And pass it through to each card:

diff
- <IssueCard key={issue.id} issue={issue} onMoveIssue={onMoveIssue} />
+ <IssueCard key={issue.id} issue={issue} onMoveIssue={onMoveIssue} onSelect={onSelectIssue} />

Back in Board.tsx, pass the callback when rendering each column:

diff
  <Column
    key={col.status}
    status={col.status}
    label={col.label}
    issues={col.issues}
    onMoveIssue={handleMoveIssue}
+   onSelectIssue={setSelectedIssue}
  />

The data flow is: IssueCard → Column → Board → selectedIssue state. Nothing is fetched — the full issue data is already in memory from the board's issue list.


The Issue Detail Modal

Now we build the modal content. This is where our new @dxsolo/ui components pay off. Instead of hand-styling native elements, we import Select, Textarea, Badge, and Checkbox from the library.

Create src/components/board/IssueDetailModal.tsx:

tsx
"use client";
 
import { useState, useCallback, useEffect, useRef } from "react";
import {
  Modal,
  Button,
  Select,
  Badge,
  Tabs,
  TabsList,
  TabsTrigger,
  TabsContent,
  Textarea,
} from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssueStatus, IssuePriority } from "@prisma/client";
import { updateIssue } from "@/actions/update-issue";
import { MarkdownPreview } from "./MarkdownPreview";
import { SubtaskList } from "./SubtaskList";
 
interface IssueDetailModalProps {
  issue: IssueWithRelations;
  onClose: () => void;
  onUpdate: (updated: IssueWithRelations) => void;
}
 
const statusOptions: { value: IssueStatus; label: string }[] = [
  { value: "OPEN", label: "Open" },
  { value: "IN_PROGRESS", label: "In Progress" },
  { value: "REVIEW", label: "Review" },
  { value: "DONE", label: "Done" },
];
 
const priorityOptions: { value: IssuePriority; label: string }[] = [
  { value: "URGENT", label: "Urgent" },
  { value: "HIGH", label: "High" },
  { value: "MEDIUM", label: "Medium" },
  { value: "LOW", label: "Low" },
];
 
const priorityBadgeIntent: Record<IssuePriority, "danger" | "warning" | "info" | "default"> = {
  URGENT: "danger",
  HIGH: "warning",
  MEDIUM: "info",
  LOW: "default",
};
 
export function IssueDetailModal({ issue, onClose, onUpdate }: IssueDetailModalProps) {
  const [title, setTitle] = useState(issue.title);
  const [description, setDescription] = useState(issue.description ?? "");
  const [status, setStatus] = useState(issue.status);
  const [priority, setPriority] = useState(issue.priority);
  const [isSaving, setIsSaving] = useState(false);
  const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
 
  // Track latest values for flush-on-unmount
  const latestFieldsRef = useRef({ title, description, status, priority });
  useEffect(() => {
    latestFieldsRef.current = { title, description, status, priority };
  }, [title, description, status, priority]);
 
  // Auto-save with debounce
  const debouncedSave = useCallback(
    (fields: Partial<{ title: string; description: string; status: IssueStatus; priority: IssuePriority }>) => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current);
      }
 
      saveTimeoutRef.current = setTimeout(async () => {
        setIsSaving(true);
        try {
          const updated = await updateIssue(issue.id, fields);
          onUpdate({ ...issue, ...updated });
        } catch (error) {
          console.error("Failed to save:", error);
        } finally {
          setIsSaving(false);
        }
      }, 500);
    },
    [issue, onUpdate]
  );
 
  // Flush pending save on unmount
  useEffect(() => {
    return () => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current);
        const fields = latestFieldsRef.current;
        updateIssue(issue.id, fields).catch(console.error);
      }
    };
  }, [issue.id]);
 
  const handleTitleChange = (newTitle: string) => {
    setTitle(newTitle);
    debouncedSave({ title: newTitle });
  };
 
  const handleDescriptionChange = (newDescription: string) => {
    setDescription(newDescription);
    debouncedSave({ description: newDescription });
  };
 
  const handleStatusChange = (newStatus: IssueStatus) => {
    setStatus(newStatus);
    debouncedSave({ status: newStatus });
  };
 
  const handlePriorityChange = (newPriority: IssuePriority) => {
    setPriority(newPriority);
    debouncedSave({ priority: newPriority });
  };
 
  return (
    <Modal open onClose={onClose} title="" className="max-w-2xl">
      <div className="flex flex-col gap-6">
        {/* Title */}
        <input
          type="text"
          value={title}
          onChange={(e) => handleTitleChange(e.target.value)}
          className="text-xl font-semibold bg-transparent border-none outline-none w-full placeholder-gray-400 focus:ring-0"
          placeholder="Issue title"
        />
 
        {/* Metadata row */}
        <div className="flex items-center gap-4 flex-wrap">
          <Select
            value={status}
            onChange={(e) => handleStatusChange(e.target.value as IssueStatus)}
            label="Status"
            className="w-auto"
          >
            {statusOptions.map((opt) => (
              <option key={opt.value} value={opt.value}>
                {opt.label}
              </option>
            ))}
          </Select>
 
          <Select
            value={priority}
            onChange={(e) => handlePriorityChange(e.target.value as IssuePriority)}
            label="Priority"
            className="w-auto"
          >
            {priorityOptions.map((opt) => (
              <option key={opt.value} value={opt.value}>
                {opt.label}
              </option>
            ))}
          </Select>
 
          {issue.epic && (
            <Badge
              intent="primary"
              style={{
                backgroundColor: `${issue.epic.color}20`,
                color: issue.epic.color,
              }}
            >
              {issue.epic.name}
            </Badge>
          )}
 
          {isSaving && (
            <span className="text-xs text-muted-foreground">Saving...</span>
          )}
        </div>
 
        {/* Description: tabbed write/preview */}
        <Tabs defaultValue="write">
          <TabsList>
            <TabsTrigger value="write">Write</TabsTrigger>
            <TabsTrigger value="preview">Preview</TabsTrigger>
          </TabsList>
 
          <TabsContent value="write">
            <Textarea
              value={description}
              onChange={(e) => handleDescriptionChange(e.target.value)}
              placeholder="Add a description... (supports markdown)"
              className="min-h-[200px] font-mono"
            />
          </TabsContent>
 
          <TabsContent value="preview">
            <div className="min-h-[200px] p-3 border border-border rounded-md bg-background">
              {description ? (
                <MarkdownPreview content={description} />
              ) : (
                <p className="text-sm text-muted-foreground">Nothing to preview</p>
              )}
            </div>
          </TabsContent>
        </Tabs>
 
        {/* Subtasks */}
        <SubtaskList issueId={issue.id} subtasks={issue.subtasks} />
 
        {/* Footer */}
        <div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t border-border">
          <span>Created {new Date(issue.createdAt).toLocaleDateString()}</span>
          <span>Updated {new Date(issue.updatedAt).toLocaleDateString()}</span>
        </div>
      </div>
    </Modal>
  );
}

Let's break this down.

Title editing. The title is a plain <input> styled to look like a heading, not a form field. No visible border, no background — it looks like static text until you click on it. We use a raw <input> here instead of @dxsolo/ui's Input component because the Input component includes a wrapper div, label, and form-field styling that would fight against the inline heading appearance we want.

Metadata controls. Status and priority use our new Select component. The label prop gives each dropdown a label without extra markup. Changes save immediately (after the 500ms debounce).

Priority badge. The Badge component from @dxsolo/ui with the priorityBadgeIntent mapping replaces the hand-styled spans we used on the board cards. For the epic label, we override Badge's colors with inline styles since epic colors are dynamic.

Tabbed markdown editor. The description uses our Tabs component to switch between Write and Preview modes. The Write tab uses our new Textarea component with font-mono for a code-friendly editing experience. The Preview tab renders the markdown through react-markdown.

Auto-save with debounce. Every field change triggers debouncedSave, which waits 500ms of inactivity before calling the server action. This means:

  • Typing "fix the bug" triggers one save after the user stops typing, not one save per keystroke
  • Changing a select immediately queues a save
  • If the user makes multiple changes quickly, only the last set of values is saved
  • A "Saving..." indicator appears while the request is in flight

Flush on unmount. When the modal closes, the cleanup effect checks for a pending debounced save. If one exists, it cancels the timeout and fires the save immediately using latestFieldsRef — a ref that always holds the current field values. Without the ref, the cleanup closure would capture stale values from when the effect was created.


The Markdown Preview Component

Create src/components/board/MarkdownPreview.tsx:

tsx
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
 
interface MarkdownPreviewProps {
  content: string;
}
 
export function MarkdownPreview({ content }: MarkdownPreviewProps) {
  return (
    <div className="prose prose-sm max-w-none prose-gray">
      <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
    </div>
  );
}

This is intentionally tiny. The prose class from @tailwindcss/typography does all the heavy lifting — it styles headings, paragraphs, lists, code blocks, blockquotes, tables, and links with sensible defaults.

Key Tailwind classes:

  • prose — applies typographic styles to child HTML elements
  • prose-sm — uses smaller font sizes, appropriate for a modal context
  • max-w-none — removes the default max-width that prose applies (we want it to fill the modal width)
  • prose-gray — uses gray tones for text, matching our board's color scheme

The remarkGfm plugin enables GitHub Flavored Markdown. Without it, tables, strikethrough (~~text~~), task lists (- [x] done), and autolinked URLs would not render.

Note that this component does not have "use client" at the top. ReactMarkdown renders to React elements — it does not use browser APIs or hooks. It can render on the server or client. Since it is imported by a client component (IssueDetailModal), it will run on the client, but it does not need the directive itself.


The Subtask List

Subtasks need their own component because they have their own server interactions: toggling completion, adding new subtasks, and deleting. This is where our new Checkbox component from @dxsolo/ui comes in.

Create src/components/board/SubtaskList.tsx:

tsx
"use client";
 
import { useState, useRef } from "react";
import type { Subtask } from "@prisma/client";
import { toggleSubtask, addSubtask, deleteSubtask } from "@/actions/subtask-actions";
import { Button, Checkbox, Input } from "@dxsolo/ui";
 
interface SubtaskListProps {
  issueId: string;
  subtasks: Subtask[];
}
 
export function SubtaskList({ issueId, subtasks: initialSubtasks }: SubtaskListProps) {
  const [subtasks, setSubtasks] = useState(initialSubtasks.sort((a, b) => a.order - b.order));
  const [newTitle, setNewTitle] = useState("");
  const [isAdding, setIsAdding] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);
 
  const completed = subtasks.filter((s) => s.completed).length;
 
  const handleToggle = async (subtaskId: string, checked: boolean) => {
    // Optimistic update
    setSubtasks((prev) =>
      prev.map((s) => (s.id === subtaskId ? { ...s, completed: checked } : s))
    );
 
    try {
      await toggleSubtask(subtaskId, checked);
    } catch (error) {
      console.error("Failed to toggle subtask:", error);
      // Rollback
      setSubtasks((prev) =>
        prev.map((s) => (s.id === subtaskId ? { ...s, completed: !checked } : s))
      );
    }
  };
 
  const handleAdd = async () => {
    const trimmed = newTitle.trim();
    if (!trimmed) return;
 
    setIsAdding(true);
    try {
      const created = await addSubtask(issueId, trimmed, subtasks.length);
      setSubtasks((prev) => [...prev, created]);
      setNewTitle("");
      inputRef.current?.focus();
    } catch (error) {
      console.error("Failed to add subtask:", error);
    } finally {
      setIsAdding(false);
    }
  };
 
  const handleDelete = async (subtaskId: string) => {
    const previous = subtasks;
    setSubtasks((prev) => prev.filter((s) => s.id !== subtaskId));
 
    try {
      await deleteSubtask(subtaskId);
    } catch (error) {
      console.error("Failed to delete subtask:", error);
      setSubtasks(previous);
    }
  };
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") {
      e.preventDefault();
      handleAdd();
    }
  };
 
  return (
    <div>
      {/* Header with progress */}
      <div className="flex items-center justify-between mb-2">
        <h4 className="text-sm font-medium text-foreground">Subtasks</h4>
        {subtasks.length > 0 && (
          <span className="text-xs text-muted-foreground">
            {completed}/{subtasks.length} done
          </span>
        )}
      </div>
 
      {/* Progress bar */}
      {subtasks.length > 0 && (
        <div className="h-1.5 bg-muted rounded-full overflow-hidden mb-3">
          <div
            className="h-full bg-green-500 rounded-full transition-all duration-300"
            style={{ width: `${(completed / subtasks.length) * 100}%` }}
          />
        </div>
      )}
 
      {/* Subtask items */}
      <div className="flex flex-col gap-1">
        {subtasks.map((subtask) => (
          <div
            key={subtask.id}
            className="flex items-center gap-2 group py-1 px-2 rounded hover:bg-muted"
          >
            <Checkbox
              checked={subtask.completed}
              onChange={(e) => handleToggle(subtask.id, e.target.checked)}
              label={subtask.title}
              className={subtask.completed ? "opacity-50" : ""}
            />
            <span className="flex-1" />
            <button
              onClick={() => handleDelete(subtask.id)}
              className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity text-xs"
              aria-label={`Delete subtask: ${subtask.title}`}
            >
              ✕
            </button>
          </div>
        ))}
      </div>
 
      {/* Add subtask input */}
      <div className="flex items-center gap-2 mt-2">
        <Input
          ref={inputRef}
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          onKeyDown={handleKeyDown}
          placeholder="Add a subtask..."
          disabled={isAdding}
        />
        <Button
          intent="secondary"
          size="sm"
          onClick={handleAdd}
          disabled={isAdding || !newTitle.trim()}
        >
          Add
        </Button>
      </div>
    </div>
  );
}

Notice we are importing Checkbox, Input, and Button — all from @dxsolo/ui. No hand-styled form elements.

Checkbox toggling. Each subtask uses the Checkbox component with its label prop. Toggling optimistically updates the local state and calls a server action. If the server call fails, it rolls back. The opacity-50 class on completed checkboxes provides a visual de-emphasis.

Adding subtasks. The Input component from @dxsolo/ui gives us consistent form styling. Pressing Enter or clicking Add creates a new subtask. We wait for the server here — unlike toggles, we need the server-generated ID for the new subtask.

Deleting subtasks. Each subtask row shows a delete button on hover (the ✕ that appears via group-hover:opacity-100). Deletes are optimistic with rollback.

Progress display. A progress bar and counter ("3/5 done") show how many subtasks are complete. The bar animates smoothly with transition-all duration-300.


Server Actions

We need two server actions: one for updating issue fields and one for subtask operations.

Updating Issues

Create src/actions/update-issue.ts:

typescript
"use server";
 
import { prisma } from "@/lib/prisma";
import { IssueStatus, IssuePriority } from "@prisma/client";
 
interface UpdateIssueInput {
  title?: string;
  description?: string;
  status?: IssueStatus;
  priority?: IssuePriority;
}
 
export async function updateIssue(issueId: string, data: UpdateIssueInput) {
  const updated = await prisma.issue.update({
    where: { id: issueId },
    data,
    include: {
      epic: true,
      subtasks: {
        orderBy: { order: "asc" },
      },
    },
  });
 
  return updated;
}

This is intentionally simple. Prisma's update only modifies the fields you pass — if data is { title: "new title" }, only the title changes. The include returns the full issue with relations so the client can update its state with fresh data.

Note that we do not handle status changes with order reindexing here. When the user changes status via the dropdown in the detail modal, the issue keeps its current order value. This means it could temporarily share an order with another issue in the new column. The next time the board loads from the server (on page refresh), the sort((a, b) => a.order - b.order) in the board ensures a deterministic render order even with duplicate order values. For a solo dev tool, this is fine. If you wanted to be precise, you could call the same moveIssue action from Part 3 instead.

Subtask Actions

Create src/actions/subtask-actions.ts:

typescript
"use server";
 
import { prisma } from "@/lib/prisma";
 
export async function toggleSubtask(subtaskId: string, completed: boolean) {
  return prisma.subtask.update({
    where: { id: subtaskId },
    data: { completed },
  });
}
 
export async function addSubtask(issueId: string, title: string, order: number) {
  return prisma.subtask.create({
    data: {
      title,
      order,
      issueId,
    },
  });
}
 
export async function deleteSubtask(subtaskId: string) {
  return prisma.subtask.delete({
    where: { id: subtaskId },
  });
}

Three small functions, each a single Prisma call. Server actions are a natural fit for CRUD operations like this — they are just async functions that run on the server, and Next.js handles the serialization.

toggleSubtask updates the completed boolean. Nothing else changes.

addSubtask creates a new subtask at the given order position. We pass subtasks.length as the order from the client, which appends it to the end.

deleteSubtask removes the subtask entirely. We do not reindex the remaining subtasks' order values — gaps in order values do not matter because we sort by order when displaying, and the relative order is preserved.


Wiring It All Together

Now we connect the modal to the board. Back in Board.tsx, render the modal when an issue is selected:

diff
+ import { IssueDetailModal } from "./IssueDetailModal";
 
  // ... inside the Board component return:
 
  return (
    <div className="h-full p-4 overflow-x-auto">
      <div className="grid grid-cols-4 gap-4 h-full min-w-[800px]">
        {issuesByStatus.map((col) => (
          <Column
            key={col.status}
            status={col.status}
            label={col.label}
            issues={col.issues}
            onMoveIssue={handleMoveIssue}
+           onSelectIssue={setSelectedIssue}
          />
        ))}
      </div>
+
+     {selectedIssue && (
+       <IssueDetailModal
+         issue={issues.find((i) => i.id === selectedIssue.id) ?? selectedIssue}
+         onClose={handleCloseDetail}
+         onUpdate={(updated) => {
+           setIssues((prev) =>
+             prev.map((i) => (i.id === updated.id ? updated : i))
+           );
+           setSelectedIssue(updated);
+         }}
+       />
+     )}
    </div>
  );

The onUpdate callback does two things:

  1. Updates the board state. When a field changes (title, status, priority), the card on the board reflects the change immediately. If the user changes status from "Open" to "In Progress" via the modal dropdown, the card moves to the correct column when the modal closes.
  2. Updates the modal state. The selectedIssue is refreshed so the modal shows the latest data, including the updated updatedAt timestamp in the footer.

The issue prop uses issues.find() to always read the latest version from board state — if the issue was dragged to a new column before opening the modal, this ensures the modal has the post-drag data.

Refreshing on Close

When the modal closes, we want to fetch fresh data to pick up subtask changes that happened during the session:

tsx
// In Board.tsx
import { getIssue } from "@/actions/get-issue";
 
const handleCloseDetail = useCallback(async () => {
  if (!selectedIssue) return;
 
  try {
    const fresh = await getIssue(selectedIssue.id);
    if (fresh) {
      setIssues((prev) => prev.map((i) => (i.id === fresh.id ? fresh : i)));
    }
  } catch (error) {
    console.error("Failed to refresh issue:", error);
  }
 
  setSelectedIssue(null);
}, [selectedIssue]);

And the server action to fetch a single issue:

typescript
// src/actions/get-issue.ts
"use server";
 
import { prisma } from "@/lib/prisma";
 
export async function getIssue(issueId: string) {
  return prisma.issue.findUnique({
    where: { id: issueId },
    include: {
      epic: true,
      subtasks: {
        orderBy: { order: "asc" },
      },
    },
  });
}

Now when the modal closes, we fetch the latest issue data from the server — including any subtask changes — and update the board. The card's subtask progress bar stays current.

Run it

Visit http://localhost:3000. Click on any issue card. You should see:

  • A modal opens with the issue title as an editable heading
  • Status and priority dropdowns using Select from @dxsolo/ui
  • The epic label as a Badge (if the issue has one)
  • A tabbed markdown editor with Write and Preview modes
  • The Write tab uses Textarea from @dxsolo/ui
  • A subtask list with Checkbox components and an "Add" input
  • Created and updated dates in the footer

Try these interactions:

  • Edit the title. Type a new title. Wait half a second. The "Saving..." indicator appears briefly. Refresh the page — the new title persists.
  • Change status. Select a different status from the dropdown. Close the modal. The card has moved to the new column.
  • Write markdown. Switch to the Write tab. Type some markdown: ## Heading, - list item, `code`. Switch to Preview — it renders with proper typography.
  • Toggle a subtask. Check and uncheck subtask checkboxes. The progress bar updates. Completed subtasks dim.
  • Add a subtask. Type a title in the input and press Enter. It appears in the list.
  • Delete a subtask. Hover over a subtask and click the ✕ button.
  • Close the modal. Click outside the modal or press Escape. The board card reflects any changes.

Handling Edge Cases

Keeping the Board in Sync

There is a subtlety with the onUpdate callback. When the user changes status via the detail modal, the optimistic update modifies the issue's status field in the board's state. But the issue's order value does not change. It keeps whatever order it had in the old column.

This means after a status change via the modal:

  1. The card appears in the new column (correct)
  2. It slots in based on its existing order value (might overlap with another card's order)
  3. The board still renders correctly because sort((a, b) => a.order - b.order) produces a deterministic order even with duplicates
  4. On the next page load, the server returns the canonical order

For a solo dev board, this is acceptable. The card ends up in the right column, and the exact position within that column is resolved on reload. If you want immediate correct ordering, replace the status dropdown's save with the moveIssue action from Part 3, passing targetColumnIssues.length as the new order.

Saving on Close

What if the user types something and immediately closes the modal? The debounce timeout has not fired yet. We handled this in the IssueDetailModal component with the flush-on-unmount pattern:

  1. A latestFieldsRef always holds the current field values
  2. The cleanup effect checks for a pending timeout
  3. If one exists, it cancels the timeout and fires the save immediately using the ref's current values

Without the ref, the cleanup closure would capture stale values from when the effect was created. This is a common React pitfall with cleanup functions and debounced operations.

Subtask Count on the Board

When subtasks are added, toggled, or deleted in the modal, the board's issue card shows the old subtask count and progress bar. The onUpdate callback updates the issue's top-level fields (title, status, priority) but not subtasks.

The handleCloseDetail function solves this by fetching the fresh issue (including subtasks) when the modal closes. The card's progress bar updates to reflect the current state.


Full File Reference

Here is every file we created or modified in this part:

In @dxsolo/ui (the component library):

FilePurpose
lib/components/Textarea/Textarea.tsxMulti-line text input with label and error states
lib/components/Textarea/Textarea.stories.tsxStorybook stories for Textarea
lib/components/Select/Select.tsxStyled native select with label and error states
lib/components/Select/Select.stories.tsxStorybook stories for Select
lib/components/Checkbox/Checkbox.tsxCheckbox input with optional label
lib/components/Checkbox/Checkbox.stories.tsxStorybook stories for Checkbox
lib/components/Badge/Badge.tsxColored inline label with CVA intent variants
lib/components/Badge/variants.tsBadge style variants (danger, warning, info, etc.)
lib/components/Badge/Badge.stories.tsxStorybook stories for Badge
lib/index.tsUpdated exports

In the kanban app:

FilePurpose
src/components/board/IssueDetailModal.tsxModal with title editing, metadata controls, markdown, subtasks
src/components/board/MarkdownPreview.tsxRenders markdown with react-markdown and prose styling
src/components/board/SubtaskList.tsxSubtask checkboxes, add/delete, optimistic updates
src/actions/update-issue.tsServer action for updating issue fields
src/actions/subtask-actions.tsServer actions for subtask CRUD
src/actions/get-issue.tsServer action to fetch a single issue with relations
src/components/board/IssueCard.tsxAdded onSelect prop and click handler
src/components/board/Column.tsxAdded onSelectIssue prop passthrough
src/components/board/Board.tsxAdded selection state, modal rendering, close handler
src/app/globals.cssAdded @tailwindcss/typography plugin

What We Built in Part 4

Here is what we accomplished:

In the component library (@dxsolo/ui):

  • Built four new components: Textarea, Select, Checkbox, and Badge
  • Each follows the library's conventions: forwardRef, cn() for class merging, semantic theme colors, accessibility attributes
  • Badge uses CVA for intent variants, matching the Button pattern
  • Published a new version and consumed it in the kanban app

In the kanban app:

  • Added click handling to issue cards that does not interfere with drag and drop
  • Built a detail modal with inline title editing, status/priority dropdowns, and epic display
  • Chose a lightweight markdown approach: Textarea for writing, react-markdown for rendering, tabbed with Tabs
  • Configured @tailwindcss/typography for beautiful prose rendering
  • Built a subtask list with Checkbox toggling, adding, and deleting — all with optimistic updates
  • Implemented debounced auto-save so changes persist without a save button
  • Handled edge cases: stale closures on unmount, data freshness after drag, and subtask sync on close

Key concepts we covered:

  • Building a design system in tandem with an app. When the app needs a primitive, build it in the library first. This forces clean interfaces and prevents app-specific hacks from leaking into components.
  • Debounced auto-save. Buffer rapid changes and save once the user pauses. Use a ref to track latest values so the unmount cleanup always has current data.
  • Optimistic subtask updates. Toggle and delete immediately in the UI, roll back if the server rejects. Wait for server confirmation on creates (you need the generated ID).
  • Click vs. drag. The browser cancels click events when a native drag starts. A plain onClick on a draggable element works correctly without any special detection.
  • CVA for component variants. class-variance-authority creates a style matrix from variant names and values. It composes cleanly with Tailwind and keeps component APIs type-safe.

Troubleshooting

If something is not working, check these common issues:

  • Modal does not open: make sure onSelect is passed through Column to IssueCard, and that the onClick is on the wrapper div.
  • Markdown preview is unstyled: make sure @plugin "@tailwindcss/typography" is in globals.css and the prose class is on the markdown wrapper.
  • New @dxsolo/ui components not found: make sure you updated the library's lib/index.ts with the new exports, rebuilt (npm run build), and installed the latest version in the kanban app. If using npm link, restart the dev server.
  • Changes are not saving: check the browser console for server action errors. Make sure src/actions/update-issue.ts has "use server" at the top.
  • Subtask progress bar does not update on the board: make sure handleCloseDetail fetches the issue and updates state before clearing selectedIssue.
  • Badge styles missing: ensure the @source directive in globals.css includes the @dxsolo/ui package so Tailwind scans the new component classes.

What is Next

In Part 5, we build project and epic management. Right now we have one hardcoded project from the seed data. We will add the ability to create and switch between projects, manage epics with color pickers, and filter the board by epic. The sidebar's placeholder navigation links will become functional.

See you there.


Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-4

Live demo: Coming in Part 6

@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent