JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialprismanextjsnpmStorybookcssTailwind

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

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

On this page

  • Part 6: Polish, Persistence, and Publishing
  • Extending @dxsolo/ui: Three Final Components
  • Component 1: Toggle
  • Component 2: Kbd
  • Component 3: Tooltip
  • Storybook Stories
  • Exporting
  • Commit and Publish
  • Dark Mode
  • The Theme Strategy
  • Cleaning Up globals.css
  • The Theme Provider
  • Preventing the Flash
  • Adding the Theme Toggle to the Sidebar
  • Fixing Hardcoded Colors
  • Keyboard Shortcuts
  • The Shortcuts Hook
  • Adding Shortcuts to the Board
  • The Shortcuts Help Dialog
  • Visual Indicator for Keyboard Shortcuts
  • Adding Tooltips to Action Buttons
  • Project Settings
  • The Settings Route
  • The Settings Component
  • The Update Project Action
  • Linking to Settings from the Sidebar
  • Preparing for Production
  • Environment Variables
  • Build Verification
  • Database for Production
  • Deploying to Vercel
  • Step 1: Push to GitHub
  • Step 2: Connect to Vercel
  • Step 3: Configure Environment Variables
  • Step 4: Configure the Build
  • Step 5: Deploy
  • Custom Domain
  • Run It
  • Full File Reference
  • The Complete Component Library
  • What We Built in This Series
  • Where to Go from Here

On this page

  • Part 6: Polish, Persistence, and Publishing
  • Extending @dxsolo/ui: Three Final Components
  • Component 1: Toggle
  • Component 2: Kbd
  • Component 3: Tooltip
  • Storybook Stories
  • Exporting
  • Commit and Publish
  • Dark Mode
  • The Theme Strategy
  • Cleaning Up globals.css
  • The Theme Provider
  • Preventing the Flash
  • Adding the Theme Toggle to the Sidebar
  • Fixing Hardcoded Colors
  • Keyboard Shortcuts
  • The Shortcuts Hook
  • Adding Shortcuts to the Board
  • The Shortcuts Help Dialog
  • Visual Indicator for Keyboard Shortcuts
  • Adding Tooltips to Action Buttons
  • Project Settings
  • The Settings Route
  • The Settings Component
  • The Update Project Action
  • Linking to Settings from the Sidebar
  • Preparing for Production
  • Environment Variables
  • Build Verification
  • Database for Production
  • Deploying to Vercel
  • Step 1: Push to GitHub
  • Step 2: Connect to Vercel
  • Step 3: Configure Environment Variables
  • Step 4: Configure the Build
  • Step 5: Deploy
  • Custom Domain
  • Run It
  • Full File Reference
  • The Complete Component Library
  • What We Built in This Series
  • Where to Go from Here
PreviousBuilding 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 6: Polish, Persistence, and Publishing

This is the final part. Over the last five posts, we built a working kanban board: data model, column layout, drag and drop, issue editing with markdown, and project/epic management with filtering. It works. But "works" is not the same as "finished."

This post is about the difference. We will add dark mode with a manual toggle, keyboard shortcuts for power users, a project settings page with a danger zone, and then deploy the whole thing to Vercel. Along the way, we will add three final components to @dxsolo/ui: Toggle, Kbd, and Tooltip.

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

Extending @dxsolo/ui: Three Final Components

Our component library has grown from five components in Part 1 to twelve after Part 5. Three more will round it out for this project.

bash
cd path/to/jpComponent/my-ui

Component 1: Toggle

A toggle switch is the right control for dark mode. Checkboxes say "agree/disagree." Toggles say "on/off." The visual metaphor matters — users expect a switch for something that changes the environment.

Create lib/components/Toggle/Toggle.tsx:

tsx
// lib/components/Toggle/Toggle.tsx
import { forwardRef, useId } from 'react';
import { cn } from '../../utils/cn';
 
type ToggleProps = Omit<React.ComponentPropsWithoutRef<'button'>, 'role' | 'type'> & {
  checked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  label?: string;
  size?: 'sm' | 'md';
};
 
export const Toggle = forwardRef<HTMLButtonElement, ToggleProps>(
  ({ className, checked = false, onCheckedChange, label, size = 'md', id: externalId, ...props }, ref) => {
    const generatedId = useId();
    const id = externalId ?? generatedId;
 
    const trackSize = size === 'sm' ? 'h-5 w-9' : 'h-6 w-11';
    const thumbSize = size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4';
    const thumbTranslate = size === 'sm'
      ? (checked ? 'translate-x-[18px]' : 'translate-x-[3px]')
      : (checked ? 'translate-x-[22px]' : 'translate-x-[3px]');
 
    return (
      <div className="flex items-center gap-2">
        <button
          ref={ref}
          id={id}
          type="button"
          role="switch"
          aria-checked={checked}
          aria-labelledby={label ? `${id}-label` : undefined}
          onClick={() => onCheckedChange?.(!checked)}
          className={cn(
            trackSize,
            'relative inline-flex shrink-0 cursor-pointer rounded-full border-2 border-transparent',
            'transition-colors duration-200',
            'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
            'disabled:cursor-not-allowed disabled:opacity-50',
            checked ? 'bg-primary' : 'bg-muted',
            className
          )}
          {...props}
        >
          <span
            className={cn(
              thumbSize,
              'pointer-events-none inline-block rounded-full bg-white shadow-sm',
              'transition-transform duration-200',
              thumbTranslate
            )}
            style={{ marginTop: size === 'sm' ? '2px' : '3px' }}
          />
        </button>
        {label && (
          <label
            id={`${id}-label`}
            htmlFor={id}
            className="text-sm text-foreground select-none cursor-pointer"
          >
            {label}
          </label>
        )}
      </div>
    );
  }
);
 
Toggle.displayName = 'Toggle';

Create lib/components/Toggle/index.ts:

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

Design decisions:

  • <button> with role="switch", not a styled checkbox. A checkbox with CSS tricks still announces as "checkbox" to screen readers. A button with role="switch" and aria-checked announces as "toggle switch, on" — the correct semantics.
  • onCheckedChange instead of onChange. This is intentional. The onChange event on a button does not exist. By naming the callback onCheckedChange, we avoid confusion with native HTML events and match the pattern used by libraries like Radix UI.
  • CSS transitions, not animation libraries. The thumb slides via translate-x with transition-transform duration-200. Smooth enough. No spring physics needed for a toggle.
  • Two sizes. sm for inline use (sidebar, toolbar), md for form contexts. Both are proportional.

Component 2: Kbd

Keyboard shortcut indicators. Small, styled <kbd> elements that show key combinations like ⌘K or Esc. These are display-only — they do not handle keyboard events themselves.

Create lib/components/Kbd/Kbd.tsx:

tsx
// lib/components/Kbd/Kbd.tsx
import { forwardRef } from 'react';
import { cn } from '../../utils/cn';
 
type KbdProps = React.ComponentPropsWithoutRef<'kbd'>;
 
export const Kbd = forwardRef<HTMLElement, KbdProps>(
  ({ className, ...props }, ref) => (
    <kbd
      ref={ref}
      className={cn(
        'inline-flex items-center justify-center',
        'min-w-[1.5rem] h-5 px-1.5',
        'text-[11px] font-mono font-medium',
        'bg-muted text-muted-foreground',
        'border border-border rounded',
        'shadow-[0_1px_0_1px] shadow-border/50',
        className
      )}
      {...props}
    />
  )
);
 
Kbd.displayName = 'Kbd';

Create lib/components/Kbd/index.ts:

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

The shadow-[0_1px_0_1px] creates a subtle 3D "keycap" effect — a 1px inset shadow on the bottom that makes the key look slightly raised, like a physical keyboard key. Combined with the border and muted background, it reads as a keyboard shortcut at a glance.

The min-w-[1.5rem] ensures single-character keys like "N" are not too narrow. The font-mono keeps character widths consistent.

Component 3: Tooltip

Tooltips show contextual hints on hover. We need these for action buttons (the "+" in column headers, the edit/delete icons on epics) and for keyboard shortcut hints.

Create lib/components/Tooltip/Tooltip.tsx:

tsx
// lib/components/Tooltip/Tooltip.tsx
import { useState, useRef, useCallback } from 'react';
import { cn } from '../../utils/cn';
 
type TooltipProps = {
  content: React.ReactNode;
  children: React.ReactElement<{ onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler }>;
  position?: 'top' | 'bottom' | 'left' | 'right';
  delay?: number;
  className?: string;
};
 
const positionClasses: Record<string, string> = {
  top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
  bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
  left: 'right-full top-1/2 -translate-y-1/2 mr-2',
  right: 'left-full top-1/2 -translate-y-1/2 ml-2',
};
 
export function Tooltip({
  content,
  children,
  position = 'top',
  delay = 400,
  className,
}: TooltipProps) {
  const [visible, setVisible] = useState(false);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
 
  const show = useCallback(() => {
    timeoutRef.current = setTimeout(() => setVisible(true), delay);
  }, [delay]);
 
  const hide = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }
    setVisible(false);
  }, []);
 
  return (
    <span className="relative inline-flex">
      <span
        onMouseEnter={show}
        onMouseLeave={hide}
        onFocus={show}
        onBlur={hide}
      >
        {children}
      </span>
      {visible && (
        <span
          role="tooltip"
          className={cn(
            'absolute z-50 whitespace-nowrap',
            'rounded-md px-2 py-1',
            'text-xs font-medium',
            'bg-foreground text-background',
            'shadow-md',
            'pointer-events-none',
            'animate-in fade-in-0 duration-150',
            positionClasses[position],
            className
          )}
        >
          {content}
        </span>
      )}
    </span>
  );
}

Create lib/components/Tooltip/index.ts:

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

This is a simple CSS-positioned tooltip, not a Popper/Floating UI solution. The tradeoffs:

  • No collision detection. If a tooltip is near the edge of the viewport, it might get clipped. For a kanban board where action buttons are in predictable positions, this is fine. If you need edge-aware positioning, add @floating-ui/react later.
  • Delay on show, instant hide. The 400ms delay prevents tooltips from flickering when the mouse passes over a row of buttons. Hiding is instant because a lingering tooltip after the mouse leaves feels broken.
  • role="tooltip" for accessibility. Screen readers announce the tooltip content when the trigger element receives focus.
  • Focus support. onFocus/onBlur handlers ensure keyboard users see tooltips too.
  • pointer-events-none prevents the tooltip itself from interfering with mouse events.

Note that Tooltip does not use forwardRef — it is a wrapper component, not a native element wrapper. It renders a <span> around the children rather than forwarding to a single element.

Storybook Stories

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

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { Toggle } from './Toggle';
 
const meta: Meta<typeof Toggle> = {
  component: Toggle,
  argTypes: {
    label: { control: 'text' },
    size: { control: 'select', options: ['sm', 'md'] },
    disabled: { control: 'boolean' },
  },
};
 
export default meta;
type Story = StoryObj<typeof Toggle>;
 
export const Default: Story = {
  render: (args) => {
    const [checked, setChecked] = useState(false);
    return <Toggle {...args} checked={checked} onCheckedChange={setChecked} />;
  },
};
 
export const WithLabel: Story = {
  render: (args) => {
    const [checked, setChecked] = useState(true);
    return <Toggle {...args} checked={checked} onCheckedChange={setChecked} label="Dark Mode" />;
  },
};
 
export const Small: Story = {
  render: (args) => {
    const [checked, setChecked] = useState(false);
    return <Toggle {...args} checked={checked} onCheckedChange={setChecked} label="Compact" size="sm" />;
  },
};
 
export const Disabled: Story = {
  render: (args) => {
    return <Toggle {...args} checked label="Locked on" disabled />;
  },
};

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

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Kbd } from './Kbd';
 
const meta: Meta<typeof Kbd> = {
  component: Kbd,
};
 
export default meta;
type Story = StoryObj<typeof Kbd>;
 
export const SingleKey: Story = {
  args: { children: 'N' },
};
 
export const Combination: Story = {
  render: () => (
    <div className="flex items-center gap-1">
      <Kbd>⌘</Kbd>
      <Kbd>K</Kbd>
    </div>
  ),
};
 
export const AllKeys: Story = {
  render: () => (
    <div className="flex flex-wrap gap-3">
      {['Esc', '⌘', '⇧', 'N', 'E', '?', '↑', '↓', '←', '→', 'Enter'].map((key) => (
        <Kbd key={key}>{key}</Kbd>
      ))}
    </div>
  ),
};
 
export const InContext: Story = {
  render: () => (
    <div className="flex flex-col gap-2 text-sm">
      <div className="flex items-center justify-between w-64">
        <span>New issue</span>
        <Kbd>N</Kbd>
      </div>
      <div className="flex items-center justify-between w-64">
        <span>Search</span>
        <div className="flex gap-1">
          <Kbd>⌘</Kbd>
          <Kbd>K</Kbd>
        </div>
      </div>
      <div className="flex items-center justify-between w-64">
        <span>Close</span>
        <Kbd>Esc</Kbd>
      </div>
    </div>
  ),
};

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

tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Tooltip } from './Tooltip';
import { Button } from '../Button';
 
const meta: Meta<typeof Tooltip> = {
  component: Tooltip,
  argTypes: {
    position: { control: 'select', options: ['top', 'bottom', 'left', 'right'] },
    delay: { control: 'number' },
  },
};
 
export default meta;
type Story = StoryObj<typeof Tooltip>;
 
export const Default: Story = {
  render: (args) => (
    <div className="flex items-center justify-center p-20">
      <Tooltip {...args} content="Create a new issue">
        <Button intent="primary" size="sm">+</Button>
      </Tooltip>
    </div>
  ),
};
 
export const Positions: Story = {
  render: () => (
    <div className="flex items-center justify-center gap-8 p-20">
      {(['top', 'bottom', 'left', 'right'] as const).map((pos) => (
        <Tooltip key={pos} content={`Tooltip on ${pos}`} position={pos} delay={0}>
          <Button intent="secondary" size="sm">{pos}</Button>
        </Tooltip>
      ))}
    </div>
  ),
};
 
export const WithKbd: Story = {
  render: () => (
    <div className="flex items-center justify-center p-20">
      <Tooltip
        content={
          <span className="flex items-center gap-1.5">
            New issue <kbd className="text-[10px] opacity-75 font-mono">N</kbd>
          </span>
        }
      >
        <Button intent="ghost" size="sm">+</Button>
      </Tooltip>
    </div>
  ),
};

Exporting

Update lib/index.ts:

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 { Badge } from './components/Badge';
  export { Checkbox } from './components/Checkbox';
  export { Select } from './components/Select';
  export { Textarea } from './components/Textarea';
  export { ColorPicker } from './components/ColorPicker';
  export { AlertDialog } from './components/AlertDialog';
+ export { Toggle } from './components/Toggle';
+ export { Kbd } from './components/Kbd';
+ export { Tooltip } from './components/Tooltip';
 
  export { cn } from './utils/cn';

Commit and Publish

bash
git add .
git commit -m "feat: add Toggle, Kbd, and Tooltip components
 
- Toggle: switch control with role='switch' semantics, two sizes
- Kbd: keyboard key indicator with keycap shadow effect
- Tooltip: CSS-positioned hover/focus tooltip with configurable delay
- Storybook stories for all three components"
git push origin main

In the kanban app:

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

Dark Mode

The @dxsolo/ui theme has had dark mode tokens since the beginning. The theme.css file defines a .dark class with inverted colors:

css
@layer theme {
  .dark {
    --color-primary: oklch(0.72 0.18 260);
    --color-background: oklch(0.15 0.02 260);
    --color-foreground: oklch(0.95 0 0);
    /* ... */
  }
}

All our components use semantic tokens like bg-background, text-foreground, border-border. When the .dark class is present, these tokens resolve to the dark values. No component changes needed. We just need a way to toggle the class.

The Theme Strategy

Currently the app uses @media (prefers-color-scheme: dark) in globals.css with hardcoded hex values. This is a problem:

  1. It ignores the @dxsolo/ui theme tokens — the hardcoded #0a0a0a is not the same as the library's dark background.
  2. It gives users no control — they are stuck with their OS setting.
  3. The media query and the .dark class approach conflict.

We are going to:

  1. Remove the prefers-color-scheme media query from globals.css
  2. Use the .dark class on <html> to toggle dark mode
  3. Read the user's preference from localStorage, falling back to the OS setting
  4. Persist the choice so it survives page reloads

Cleaning Up globals.css

Replace src/app/globals.css:

css
@import "tailwindcss";
@import "@dxsolo/ui/theme.css";
@plugin "@tailwindcss/typography";
 
@source "../../node_modules/@dxsolo/ui";
 
body {
  font-family: var(--font-sans), Arial, Helvetica, sans-serif;
}

The old :root variables and @media (prefers-color-scheme) block are gone. The @theme inline block is gone too — we no longer need it because @dxsolo/ui/theme.css already defines --color-background, --color-foreground, and the font tokens. We were effectively overriding the library's theme with hardcoded values. Now the library owns all the design tokens.

The Theme Provider

We need a small piece of client-side logic to:

  1. On first load, check localStorage for a saved preference
  2. If none, check the OS preference via matchMedia
  3. Apply or remove the .dark class on <html>
  4. Provide a toggle function to child components

Create src/components/theme/ThemeProvider.tsx:

tsx
"use client";
 
import { createContext, useContext, useEffect, useState, useCallback } from "react";
 
type Theme = "light" | "dark";
 
interface ThemeContextValue {
  theme: Theme;
  toggleTheme: () => void;
}
 
const ThemeContext = createContext<ThemeContextValue>({
  theme: "light",
  toggleTheme: () => {},
});
 
export function useTheme() {
  return useContext(ThemeContext);
}
 
function getInitialTheme(): Theme {
  if (typeof window === "undefined") return "light";
 
  const stored = localStorage.getItem("theme");
  if (stored === "dark" || stored === "light") return stored;
 
  return window.matchMedia("(prefers-color-scheme: dark)").matches
    ? "dark"
    : "light";
}
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
 
  // Initialize from localStorage / OS preference
  useEffect(() => {
    const initial = getInitialTheme();
    setTheme(initial);
    document.documentElement.classList.toggle("dark", initial === "dark");
  }, []);
 
  const toggleTheme = useCallback(() => {
    setTheme((prev) => {
      const next = prev === "light" ? "dark" : "light";
      localStorage.setItem("theme", next);
      document.documentElement.classList.toggle("dark", next === "dark");
      return next;
    });
  }, []);
 
  return (
    <ThemeContext value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext>
  );
}

A few things to note:

  • getInitialTheme runs only on the client. The server always renders "light" to avoid hydration mismatches. The useEffect on mount sets the real theme immediately. There is a brief flash on first load for dark mode users — we will fix that next.
  • document.documentElement.classList.toggle directly manipulates the <html> element. This is the simplest way to apply a class that CSS can read globally. No need for CSS-in-JS or style injection.
  • localStorage persists the choice. The next time the user visits, their preference is restored before the OS media query is checked.

Preventing the Flash

Dark mode users will see a white flash on page load because the server renders light mode and the useEffect switches to dark after hydration. The fix is a tiny inline script in the <head> that runs before React hydrates:

Update src/app/layout.tsx:

tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "@/components/theme/ThemeProvider";
import "./globals.css";
 
const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});
 
const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});
 
export const metadata: Metadata = {
  title: "Solo Kanban",
  description: "A developer project board built with Next.js and @dxsolo/ui",
};
 
// Inline script to set theme before first paint — prevents flash
const themeScript = `
  (function() {
    var t = localStorage.getItem('theme');
    if (t === 'dark' || (!t && matchMedia('(prefers-color-scheme:dark)').matches)) {
      document.documentElement.classList.add('dark');
    }
  })();
`;
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html
      lang="en"
      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
      suppressHydrationWarning
    >
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />
      </head>
      <body className="min-h-full flex flex-col bg-background text-foreground">
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

The inline script runs synchronously before the browser paints. It reads localStorage and adds the .dark class if needed. By the time React hydrates, the correct class is already on <html>, so there is no flash and no mismatch.

suppressHydrationWarning on <html> tells React not to warn about the class attribute differing between server and client. This is one of the few legitimate uses of this prop — the server cannot know the user's theme preference, so a mismatch is expected and intentional.

We also added bg-background text-foreground to <body>. This ensures the body itself uses the theme tokens instead of hardcoded colors.

Adding the Theme Toggle to the Sidebar

The sidebar is the natural place for a dark mode toggle. Add it to the bottom:

In src/components/layout/Sidebar.tsx, import the toggle and theme hook:

diff
+ import { Toggle } from "@dxsolo/ui";
+ import { useTheme } from "@/components/theme/ThemeProvider";

Inside the component, get the theme state:

tsx
const { theme, toggleTheme } = useTheme();

Add the toggle at the bottom of the sidebar, after the epics section:

diff
+     {/* Theme toggle */}
+     <div className="border-t border-border pt-4">
+       <Toggle
+         checked={theme === "dark"}
+         onCheckedChange={toggleTheme}
+         label="Dark Mode"
+         size="sm"
+       />
+     </div>
 
      {/* Create Project modal */}
      <Modal

That is it. One component, one line of state, three lines of JSX. The Toggle from @dxsolo/ui handles the visuals, useTheme handles the logic.

Fixing Hardcoded Colors

The board has several hardcoded Tailwind color classes that will not adapt to dark mode. Search for bg-gray-, text-gray-, and border-gray- in the board components.

In Column.tsx, the column background uses bg-gray-50 and borders use border-gray-200. Replace them with semantic tokens:

diff
- className={`flex flex-col h-full min-h-0 rounded-lg bg-gray-50 border-t-2 ${statusBorderColors[status]} ${
+ className={`flex flex-col h-full min-h-0 rounded-lg bg-muted/50 border-t-2 ${statusBorderColors[status]} ${
diff
- <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
diff
- <span className="text-xs text-gray-400">{issues.length}</span>
+ <span className="text-xs text-muted-foreground">{issues.length}</span>

The empty state placeholder:

diff
- "border-gray-200"
+ "border-border"
diff
- <p className="text-xs text-gray-400 text-center px-4">
+ <p className="text-xs text-muted-foreground text-center px-4">

The add issue button in the column header:

diff
- className="text-gray-400 hover:text-gray-600 text-sm leading-none"
+ className="text-muted-foreground hover:text-foreground text-sm leading-none"

In AppShell.tsx:

diff
- className={`border-r border-gray-200 bg-gray-50 overflow-y-auto overflow-x-hidden transition-opacity duration-200 ${
+ className={`border-r border-border bg-muted/50 overflow-y-auto overflow-x-hidden transition-opacity duration-200 ${

In IssueCard.tsx, the subtask progress bar background:

diff
- <div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
+ <div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
diff
- <span className="text-xs text-gray-400">
+ <span className="text-xs text-muted-foreground">

The status badge colors in Column.tsx (statusColors map) use hardcoded values like bg-gray-100 text-gray-700. These work acceptably in dark mode because they are status-specific accent colors, not structural grays. You could create dark-mode-aware status colors, but the contrast is still readable, and adding a dark variant for every status color is diminishing returns.

The pattern: replace structural grays (bg-gray-50, border-gray-200, text-gray-400) with semantic tokens (bg-muted/50, border-border, text-muted-foreground). Leave accent colors that carry meaning (status badges, priority colors) as-is.


Keyboard Shortcuts

A kanban board for developers should be keyboard-driven. We will add shortcuts for the most common actions and a help dialog that lists them all.

The Shortcuts Hook

Create src/hooks/useKeyboardShortcuts.ts:

typescript
"use client";
 
import { useEffect } from "react";
 
type Shortcut = {
  key: string;
  ctrl?: boolean;
  meta?: boolean;
  shift?: boolean;
  handler: () => void;
  /** Skip this shortcut when focus is inside an input/textarea */
  ignoreInputs?: boolean;
};
 
export function useKeyboardShortcuts(shortcuts: Shortcut[]) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Check if focus is in an input/textarea
      const target = e.target as HTMLElement;
      const isInput =
        target.tagName === "INPUT" ||
        target.tagName === "TEXTAREA" ||
        target.tagName === "SELECT" ||
        target.isContentEditable;
 
      for (const shortcut of shortcuts) {
        if (shortcut.ignoreInputs !== false && isInput) continue;
 
        const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase();
        const ctrlMatch = shortcut.ctrl ? e.ctrlKey : !e.ctrlKey;
        const metaMatch = shortcut.meta ? e.metaKey : !e.metaKey;
        const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
 
        if (keyMatch && ctrlMatch && metaMatch && shiftMatch) {
          e.preventDefault();
          shortcut.handler();
          return;
        }
      }
    };
 
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [shortcuts]);
}

Key design decisions:

  • Ignores inputs by default. Pressing "N" while typing in a textarea should type "N", not open a new issue. The ignoreInputs flag defaults to true — shortcuts only fire when focus is on the body or non-input elements. Set it to false for shortcuts like Escape that should always work.
  • Modifier key matching. Each modifier (ctrl, meta, shift) must match exactly. If a shortcut does not require Ctrl, it will not fire when Ctrl is held. This prevents collisions with browser shortcuts.
  • Single handler loop. The hook registers one keydown listener and checks all shortcuts in order. First match wins.

Adding Shortcuts to the Board

Update Board.tsx to use the shortcuts hook:

diff
+ import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";

Inside the Board component, add a state for the shortcuts help dialog and the shortcut to create a new issue in the first column:

tsx
const [showShortcuts, setShowShortcuts] = useState(false);
const [showCreateInOpen, setShowCreateInOpen] = useState(false);
 
useKeyboardShortcuts([
  {
    key: "?",
    shift: true,
    handler: () => setShowShortcuts(true),
  },
  {
    key: "Escape",
    handler: () => {
      if (showShortcuts) setShowShortcuts(false);
      else if (selectedIssue) setSelectedIssue(null);
    },
    ignoreInputs: false,
  },
  {
    key: "n",
    handler: () => setShowCreateInOpen(true),
  },
]);

The ? shortcut (Shift + /) opens the help dialog. Escape closes whatever is currently open — the shortcuts dialog first, then the issue detail modal. Setting ignoreInputs: false on Escape means it works even when typing in a textarea, which is the expected behavior for dismissing overlays. N opens the create issue form in the first column (Open) — the most common place to add new work.

To make N work, the Board needs to control the create form visibility in the first column. We need to extend the Column interface to accept optional controlled props:

diff
// In Column.tsx — add to ColumnProps interface:
+   showCreateForm?: boolean;
+   onCreateFormChange?: (open: boolean) => void;

Then update the Column component to support both controlled and uncontrolled modes:

tsx
export function Column({ status, label, issues, onMoveIssue, onSelectIssue, projectId, epics, onIssueCreated, showCreateForm: externalShowCreate, onCreateFormChange }: ColumnProps) {
  const [internalShowCreate, setInternalShowCreate] = useState(false);
 
  const showCreateForm = externalShowCreate ?? internalShowCreate;
  const setShowCreateForm = (val: boolean) => {
    setInternalShowCreate(val);
    onCreateFormChange?.(val);
  };
  // ... rest of the component

This is a controlled/uncontrolled pattern — the same approach React uses for <input>. Most columns manage their own form state. The first column can optionally be driven by the Board when the N shortcut fires.

Pass the controlled props to just the first column in Board.tsx:

tsx
{issuesByStatus.map((col) => (
  <Column
    key={col.status}
    // ... existing props
    {...(col.status === "OPEN" && {
      showCreateForm: showCreateInOpen,
      onCreateFormChange: setShowCreateInOpen,
    })}
  />
))}

The Shortcuts Help Dialog

Create src/components/board/ShortcutsDialog.tsx:

tsx
"use client";
 
import { Modal, Kbd } from "@dxsolo/ui";
 
interface ShortcutsDialogProps {
  open: boolean;
  onClose: () => void;
}
 
const shortcuts = [
  { keys: ["?"], description: "Show keyboard shortcuts" },
  { keys: ["Esc"], description: "Close dialog / deselect" },
  { keys: ["N"], description: "New issue in first column" },
];
 
export function ShortcutsDialog({ open, onClose }: ShortcutsDialogProps) {
  return (
    <Modal open={open} onClose={onClose} title="Keyboard Shortcuts">
      <div className="flex flex-col gap-3">
        {shortcuts.map((shortcut) => (
          <div
            key={shortcut.description}
            className="flex items-center justify-between"
          >
            <span className="text-sm text-foreground">
              {shortcut.description}
            </span>
            <div className="flex items-center gap-1">
              {shortcut.keys.map((key) => (
                <Kbd key={key}>{key}</Kbd>
              ))}
            </div>
          </div>
        ))}
      </div>
      <p className="mt-4 text-xs text-muted-foreground">
        Shortcuts are disabled while typing in input fields.
      </p>
    </Modal>
  );
}

The Kbd component from @dxsolo/ui makes each key look like a physical keyboard key. The dialog lists all available shortcuts with their descriptions.

Render it in Board.tsx:

diff
+ import { ShortcutsDialog } from "./ShortcutsDialog";
 
  // Inside the return statement, after IssueDetailModal:
 
+ <ShortcutsDialog
+   open={showShortcuts}
+   onClose={() => setShowShortcuts(false)}
+ />

Visual Indicator for Keyboard Shortcuts

Keyboard shortcuts are invisible by default — users will not discover them unless we tell them they exist. The sidebar is the natural place for this hint — it is always visible and already houses the dark mode toggle and other settings.

In Sidebar.tsx, import Kbd and add a static hint above the theme toggle:

diff
- import { Toggle } from "@dxsolo/ui";
+ import { Toggle, Kbd } from "@dxsolo/ui";
tsx
{/* Shortcuts hint & theme toggle */}
<div className="border-t border-border pt-4 flex flex-col gap-3">
  <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
    <span>Press</span>
    <Kbd>?</Kbd>
    <span>for keyboard shortcuts</span>
  </div>
  <Toggle
    checked={theme === "dark"}
    onCheckedChange={toggleTheme}
    label="Dark Mode"
    size="sm"
  />
</div>

This is a static, display-only hint — it does not need to know about the shortcuts dialog state. The Kbd component reuses the same keycap styling from the shortcuts dialog, creating visual consistency. Placing it in the sidebar means users see it without it competing for space on the board itself.

Adding Tooltips to Action Buttons

Now that we have the Tooltip component, we can add hints to the "+" buttons and other icon-only actions. In Column.tsx:

diff
+ import { Tooltip } from "@dxsolo/ui";
 
  // The column header "+" button:
 
- <button
-   onClick={() => setShowCreateForm(true)}
-   className="text-muted-foreground hover:text-foreground text-sm leading-none"
-   aria-label={`Add issue to ${label}`}
- >
-   +
- </button>
+ <Tooltip content={`Add issue to ${label}`}>
+   <button
+     onClick={() => setShowCreateForm(true)}
+     className="text-muted-foreground hover:text-foreground text-sm leading-none"
+     aria-label={`Add issue to ${label}`}
+   >
+     +
+   </button>
+ </Tooltip>

The aria-label stays for screen readers. The tooltip provides the same information visually for mouse users.


Project Settings

Part 5 included a deleteProject server action but no UI for it. Project deletion is dangerous enough to deserve its own page with a clear danger zone.

The Settings Route

Create src/app/projects/[projectId]/settings/page.tsx:

tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { AppShell } from "@/components/layout/AppShell";
import { Sidebar } from "@/components/layout/Sidebar";
import { ProjectSettings } from "@/components/settings/ProjectSettings";
import { getProjects } from "@/actions/project-actions";
 
interface SettingsPageProps {
  params: Promise<{ projectId: string }>;
}
 
export default async function SettingsPage({ params }: SettingsPageProps) {
  const { projectId } = await params;
  const projects = await getProjects();
 
  const project = await prisma.project.findUnique({
    where: { id: projectId },
    include: {
      epics: true,
      _count: {
        select: { issues: true, epics: true },
      },
    },
  });
 
  if (!project) notFound();
 
  return (
    <AppShell
      sidebar={
        <Sidebar
          projectId={projectId}
          projects={projects}
          epics={project.epics}
          epicFilter={null}
        />
      }
    >
      <ProjectSettings project={project} issueCount={project._count.issues} />
    </AppShell>
  );
}

The Settings Component

Create src/components/settings/ProjectSettings.tsx:

tsx
"use client";
 
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Project } from "@prisma/client";
import { Card, CardContent, Input, Textarea, Button, AlertDialog } from "@dxsolo/ui";
import { updateProject, deleteProject } from "@/actions/project-actions";
 
interface ProjectSettingsProps {
  project: Project;
  issueCount: number;
}
 
export function ProjectSettings({ project, issueCount }: ProjectSettingsProps) {
  const router = useRouter();
  const [name, setName] = useState(project.name);
  const [description, setDescription] = useState(project.description ?? "");
  const [isSaving, setIsSaving] = useState(false);
  const [showDelete, setShowDelete] = useState(false);
  const [isDeleting, setIsDeleting] = useState(false);
 
  const handleSave = async () => {
    const trimmed = name.trim();
    if (!trimmed) return;
 
    setIsSaving(true);
    try {
      await updateProject(project.id, {
        name: trimmed,
        description: description.trim() || null,
      });
      router.refresh();
    } catch (error) {
      console.error("Failed to update project:", error);
    } finally {
      setIsSaving(false);
    }
  };
 
  const handleDelete = async () => {
    setIsDeleting(true);
    try {
      await deleteProject(project.id);
      // deleteProject redirects, so this is for safety
    } catch (error) {
      console.error("Failed to delete project:", error);
      setIsDeleting(false);
    }
  };
 
  return (
    <div className="max-w-2xl mx-auto p-8 flex flex-col gap-8">
      <h1 className="text-2xl font-bold text-foreground">Project Settings</h1>
 
      {/* General settings */}
      <Card>
        <CardContent className="flex flex-col gap-4 p-6">
          <h2 className="text-lg font-semibold text-foreground">General</h2>
          <Input
            label="Project Name"
            value={name}
            onChange={(e) => setName(e.target.value)}
          />
          <Textarea
            label="Description"
            value={description}
            onChange={(e) => setDescription(e.target.value)}
            placeholder="What is this project about?"
          />
          <div className="flex justify-end">
            <Button
              intent="primary"
              onClick={handleSave}
              disabled={isSaving || !name.trim()}
            >
              {isSaving ? "Saving..." : "Save Changes"}
            </Button>
          </div>
        </CardContent>
      </Card>
 
      {/* Danger zone */}
      <Card className="border-destructive/50">
        <CardContent className="flex flex-col gap-4 p-6">
          <h2 className="text-lg font-semibold text-destructive">Danger Zone</h2>
          <p className="text-sm text-muted-foreground">
            Deleting this project will permanently remove all {issueCount} issues,
            their subtasks, and all epics. This action cannot be undone.
          </p>
          <div className="flex justify-end">
            <Button intent="destructive" onClick={() => setShowDelete(true)}>
              Delete Project
            </Button>
          </div>
        </CardContent>
      </Card>
 
      <AlertDialog
        open={showDelete}
        onClose={() => setShowDelete(false)}
        onConfirm={handleDelete}
        title="Delete Project"
        description={`This will permanently delete "${project.name}" and all of its ${issueCount} issues, subtasks, and epics. This cannot be undone.`}
        confirmLabel="Delete Project"
        loading={isDeleting}
      />
    </div>
  );
}

The danger zone card has a border-destructive/50 class — a red-tinted border that signals caution. The delete button opens an AlertDialog (from Part 5) with a detailed description of what will be destroyed. The loading prop disables both buttons while the delete is in flight.

The Update Project Action

We need to add updateProject to the project actions from Part 5:

diff
  // src/actions/project-actions.ts
 
+ export async function updateProject(
+   projectId: string,
+   data: { name?: string; description?: string | null }
+ ) {
+   const project = await prisma.project.update({
+     where: { id: projectId },
+     data,
+   });
+
+   return project;
+ }

Linking to Settings from the Sidebar

In Part 5's sidebar, the Board button was hardcoded. Now we have a real settings page to link to:

diff
  <nav className="flex flex-col gap-1">
-   <Button intent="ghost" className="justify-start w-full font-medium">
-     Board
-   </Button>
+   <Button
+     intent="ghost"
+     className="justify-start w-full font-medium"
+     onClick={() => router.push(`/projects/${projectId}/board`)}
+   >
+     Board
+   </Button>
+   <Button
+     intent="ghost"
+     className="justify-start w-full"
+     onClick={() => router.push(`/projects/${projectId}/settings`)}
+   >
+     Settings
+   </Button>
  </nav>

Preparing for Production

Before deploying, we need to make the project production-ready.

Environment Variables

Create .env.example at the project root:

bash
# Database connection string
DATABASE_URL="postgresql://kanban:kanban@localhost:5432/solo_kanban"

Add .env to .gitignore if it is not already there. Never commit actual credentials.

Build Verification

Run a production build locally to catch any issues before deploying:

bash
npm run build

If the build succeeds, you will see a summary of all routes and their sizes. If it fails, the most common issues are:

  • Missing server-side imports. Client components trying to import prisma directly.
  • Type errors. TypeScript strict mode catches things that the dev server overlooks.
  • Missing environment variables. The build process runs server components, which need DATABASE_URL.

Database for Production

Our local setup uses Docker Compose with a PostgreSQL container. For production, you need a hosted PostgreSQL database. Options:

  • Vercel Postgres — zero-config if deploying to Vercel. Add the POSTGRES_URL env var and Vercel handles the rest.
  • Neon — serverless Postgres with a generous free tier. Works well with Prisma.
  • Supabase — Postgres with extras (auth, realtime). The database alone is enough for us.
  • Railway — simple Postgres hosting with a free tier.

Whichever you choose, you need the connection string in the DATABASE_URL environment variable. Run the Prisma migration against the production database:

bash
DATABASE_URL="your-production-url" npx prisma migrate deploy

prisma migrate deploy applies pending migrations without prompting. This is the command for CI/CD and production — never use prisma db push in production, as it can drop data.


Deploying to Vercel

Vercel is the natural host for a Next.js app. The deployment is straightforward.

Step 1: Push to GitHub

Make sure your code is committed and pushed:

bash
git add .
git commit -m "feat: add dark mode, keyboard shortcuts, project settings, and production prep"
git push origin main

Step 2: Connect to Vercel

  1. Go to vercel.com and sign in with your GitHub account
  2. Click "Add New Project"
  3. Import your solo-kanban repository
  4. Vercel auto-detects the Next.js framework

Step 3: Configure Environment Variables

In the Vercel project settings, add your environment variables:

VariableValue
DATABASE_URLYour production PostgreSQL URL

Step 4: Configure the Build

Vercel's default settings work for most Next.js apps. The only addition is running the Prisma migration before the build. Update package.json:

diff
  "scripts": {
    "dev": "next dev",
-   "build": "next build",
+   "build": "prisma migrate deploy && next build",
    "start": "next start",
    "lint": "eslint"
  },

This ensures the database schema is up to date before every build. prisma migrate deploy is idempotent — if all migrations are already applied, it does nothing.

Alternatively, you can set the build command in Vercel's project settings to prisma migrate deploy && next build without modifying package.json.

Step 5: Deploy

Click "Deploy." Vercel builds the app, runs the migration, and deploys it to a .vercel.app URL. Every push to main triggers a new deployment automatically.

Custom Domain

In Vercel's project settings under "Domains," add your custom domain. Vercel handles SSL certificates and DNS configuration. Point your domain's DNS to Vercel's nameservers and it propagates within minutes.


Run It

Start the dev server one last time:

bash
npm run dev

Visit http://localhost:3000. Here is the full feature set to verify:

Dark mode:

  • Toggle the "Dark Mode" switch in the sidebar. The entire app transitions to dark theme.
  • Refresh the page — the theme persists (stored in localStorage).
  • Toggle back to light mode. Verify all colors look correct in both modes.
  • Check the column backgrounds, card borders, empty states, and modal overlays in dark mode.

Keyboard shortcuts:

  • Press Shift + ? (focus must not be in an input). The shortcuts help dialog opens.
  • Press Escape to close it.
  • Press N — the create issue form should appear in the Open column with the input focused.
  • Open the issue detail modal by clicking a card. Press Escape to close it.
  • Verify the sidebar shows "Press ? for keyboard shortcuts" above the dark mode toggle.

Project settings:

  • Click "Settings" in the sidebar navigation.
  • Change the project name and description. Click Save.
  • Navigate to the board — the sidebar reflects the new name.
  • Go back to settings. Click "Delete Project." The AlertDialog focuses the Cancel button.
  • Click Cancel. Nothing happens. Click Delete — the project is removed and you are redirected.

Tooltips:

  • Hover over the "+" button in a column header. A tooltip appears after a brief delay.

Full File Reference

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

In @dxsolo/ui (the component library):

FilePurpose
lib/components/Toggle/Toggle.tsxSwitch control with role="switch" semantics
lib/components/Toggle/Toggle.stories.tsxStorybook stories for Toggle
lib/components/Kbd/Kbd.tsxKeyboard key indicator with keycap styling
lib/components/Kbd/Kbd.stories.tsxStorybook stories for Kbd
lib/components/Tooltip/Tooltip.tsxCSS-positioned hover/focus tooltip
lib/components/Tooltip/Tooltip.stories.tsxStorybook stories for Tooltip
lib/index.tsUpdated exports

In the kanban app:

FilePurpose
src/app/globals.cssCleaned up — removed hardcoded colors, uses library theme
src/app/layout.tsxAdded ThemeProvider, anti-flash script, semantic body classes
src/components/theme/ThemeProvider.tsxTheme context with localStorage persistence
src/hooks/useKeyboardShortcuts.tsKeyboard shortcut hook with input-awareness
src/components/board/ShortcutsDialog.tsxHelp dialog listing all keyboard shortcuts
src/components/board/Board.tsxAdded keyboard shortcuts and shortcuts dialog
src/components/board/Column.tsxSemantic color tokens, tooltip on "+" button
src/components/board/IssueCard.tsxSemantic color tokens for dark mode
src/components/layout/AppShell.tsxSemantic color tokens for sidebar
src/components/layout/Sidebar.tsxAdded dark mode toggle, settings link
src/app/projects/[projectId]/settings/page.tsxProject settings route
src/components/settings/ProjectSettings.tsxGeneral settings + danger zone for deletion
src/actions/project-actions.tsAdded updateProject action
.env.exampleEnvironment variable template
package.jsonUpdated build script for Prisma migration

The Complete Component Library

Over six posts, @dxsolo/ui grew from five components to fifteen:

ComponentPart AddedPattern
Button1CVA variants
Input1forwardRef + label/error
Card1Compound (4 sub-components)
Modal1Portal + focus trap
Tabs1Compound + context
Textarea4Mirrors Input
Select4Native select wrapper
Checkbox4Input + label
Badge4CVA variants
ColorPicker5Radio group semantics
AlertDialog5Safe-focus confirmation
Toggle6role="switch" button
Kbd6Styled <kbd>
Tooltip6CSS positioning + delay

Plus the cn() utility function used by every component.

Each component follows the same conventions: forwardRef where applicable, cn() for class merging, semantic theme tokens for colors, and a Storybook story for documentation. The library uses no runtime CSS — everything is Tailwind utility classes resolved at build time.


What We Built in This Series

Let's step back and look at everything we built across six posts:

Part 1 — Foundation. Next.js project, Prisma schema with PostgreSQL, @dxsolo/ui integration, seed data. The data model that everything else builds on.

Part 2 — Board layout. CSS Grid columns, container queries for responsive cards, the AppShell with a collapsible sidebar. The visual structure.

Part 3 — Drag and drop. Pragmatic DnD with edge detection, optimistic reordering with rollback, event bubbling between card and column drop targets. The core interaction.

Part 4 — Issue editing. Markdown preview with react-markdown, tabbed write/preview, subtask management, debounced auto-save with ref-based flush on unmount. The detail layer.

Part 5 — Organization. Project CRUD and switching, epic CRUD with color picker, URL-based board filtering, inline issue creation. The management layer.

Part 6 — Polish. Dark mode with flash prevention, keyboard shortcuts, project settings with danger zone, Vercel deployment. The finishing touches.

Key technical themes throughout:

  • Optimistic updates everywhere. Every mutation updates the UI immediately and rolls back on server error. Drag and drop, subtask toggling, issue editing — the pattern is always the same: update state, call server, roll back on failure.
  • Server components for data, client components for interaction. The board page is a server component that fetches data. The board itself is a client component that manages state. Server actions bridge the gap.
  • Design system as a forcing function. Building components in @dxsolo/ui before using them in the app forces clean interfaces. The Button does not know about kanban boards. The Modal does not know about issues. This separation prevents app-specific hacks from leaking into reusable primitives.
  • URL as state. The epic filter lives in the URL search params, not in React state. The project lives in the URL path. Navigation is the API.
  • Progressive enhancement over configuration. Dark mode uses a CSS class toggle, not a theme provider that re-renders the entire tree. Keyboard shortcuts are a single event listener, not a command registry. The simplest solution that works is the right solution.

Where to Go from Here

This project is a foundation, not a ceiling. Here are some directions you might take it:

  • Side panel instead of modal. Part 4 chose a modal for simplicity. A slide-out panel (like Linear) provides a better editing experience and could show the board behind it.
  • Real-time collaboration. Add Prisma Pulse or WebSockets to sync board state across tabs or users.
  • Search. A command palette (⌘K) that searches issues across all projects.
  • Bulk actions. Select multiple cards and move, delete, or re-assign them.
  • Activity log. Track who changed what and when. Prisma middleware can intercept writes.
  • Mobile. The board is usable on tablets but not phones. A swipeable column view would work for mobile.

The component library can grow too — DropdownMenu, Popover, Avatar, Skeleton loaders, Toast notifications. Each follows the same pattern: forwardRef, cn(), semantic tokens, Storybook story.

Thank you for building this with me. Ship it.


Source code: github.com/jpDxsoloOrg/solo-kanban

Live demo: Demo

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

@dxsolo/ui on npm: @dxsolo/ui