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

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:
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.
cd path/to/jpComponent/my-uiA 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:
// 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:
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.translate-x with transition-transform duration-200. Smooth enough. No spring physics needed for a toggle.sm for inline use (sidebar, toolbar), md for form contexts. Both are proportional.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:
// 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:
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.
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:
// 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:
export { Tooltip } from './Tooltip';This is a simple CSS-positioned tooltip, not a Popper/Floating UI solution. The tradeoffs:
@floating-ui/react later.role="tooltip" for accessibility. Screen readers announce the tooltip content when the trigger element receives focus.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.
Create lib/components/Toggle/Toggle.stories.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:
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:
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>
),
};Update lib/index.ts:
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';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 mainIn the kanban app:
cd path/to/solo-kanban
npm install @dxsolo/ui@latestThe @dxsolo/ui theme has had dark mode tokens since the beginning. The theme.css file defines a .dark class with inverted colors:
@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.
Currently the app uses @media (prefers-color-scheme: dark) in globals.css with hardcoded hex values. This is a problem:
@dxsolo/ui theme tokens — the hardcoded #0a0a0a is not the same as the library's dark background..dark class approach conflict.We are going to:
prefers-color-scheme media query from globals.css.dark class on <html> to toggle dark modelocalStorage, falling back to the OS settingReplace src/app/globals.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.
We need a small piece of client-side logic to:
localStorage for a saved preferencematchMedia.dark class on <html>Create src/components/theme/ThemeProvider.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.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:
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.
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:
+ import { Toggle } from "@dxsolo/ui";
+ import { useTheme } from "@/components/theme/ThemeProvider";Inside the component, get the theme state:
const { theme, toggleTheme } = useTheme();Add the toggle at the bottom of the sidebar, after the epics section:
+ {/* 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 */}
<ModalThat is it. One component, one line of state, three lines of JSX. The Toggle from @dxsolo/ui handles the visuals, useTheme handles the logic.
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:
- 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]} ${- <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">- <span className="text-xs text-gray-400">{issues.length}</span>
+ <span className="text-xs text-muted-foreground">{issues.length}</span>The empty state placeholder:
- "border-gray-200"
+ "border-border"- <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:
- 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:
- 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:
- <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">- <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.
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.
Create src/hooks/useKeyboardShortcuts.ts:
"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:
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.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.keydown listener and checks all shortcuts in order. First match wins.Update Board.tsx to use the shortcuts hook:
+ 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:
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:
// 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:
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 componentThis 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:
{issuesByStatus.map((col) => (
<Column
key={col.status}
// ... existing props
{...(col.status === "OPEN" && {
showCreateForm: showCreateInOpen,
onCreateFormChange: setShowCreateInOpen,
})}
/>
))}Create src/components/board/ShortcutsDialog.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:
+ import { ShortcutsDialog } from "./ShortcutsDialog";
// Inside the return statement, after IssueDetailModal:
+ <ShortcutsDialog
+ open={showShortcuts}
+ onClose={() => setShowShortcuts(false)}
+ />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:
- import { Toggle } from "@dxsolo/ui";
+ import { Toggle, Kbd } from "@dxsolo/ui";{/* 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.
Now that we have the Tooltip component, we can add hints to the "+" buttons and other icon-only actions. In Column.tsx:
+ 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.
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.
Create src/app/projects/[projectId]/settings/page.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>
);
}Create src/components/settings/ProjectSettings.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.
We need to add updateProject to the project actions from Part 5:
// 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;
+ }In Part 5's sidebar, the Board button was hardcoded. Now we have a real settings page to link to:
<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>Before deploying, we need to make the project production-ready.
Create .env.example at the project root:
# 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.
Run a production build locally to catch any issues before deploying:
npm run buildIf the build succeeds, you will see a summary of all routes and their sizes. If it fails, the most common issues are:
prisma directly.DATABASE_URL.Our local setup uses Docker Compose with a PostgreSQL container. For production, you need a hosted PostgreSQL database. Options:
POSTGRES_URL env var and Vercel handles the rest.Whichever you choose, you need the connection string in the DATABASE_URL environment variable. Run the Prisma migration against the production database:
DATABASE_URL="your-production-url" npx prisma migrate deployprisma 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.
Vercel is the natural host for a Next.js app. The deployment is straightforward.
Make sure your code is committed and pushed:
git add .
git commit -m "feat: add dark mode, keyboard shortcuts, project settings, and production prep"
git push origin mainsolo-kanban repositoryIn the Vercel project settings, add your environment variables:
| Variable | Value |
|---|---|
DATABASE_URL | Your production PostgreSQL URL |
Vercel's default settings work for most Next.js apps. The only addition is running the Prisma migration before the build. Update package.json:
"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.
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.
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.
Start the dev server one last time:
npm run devVisit http://localhost:3000. Here is the full feature set to verify:
Dark mode:
localStorage).Keyboard shortcuts:
Shift + ? (focus must not be in an input). The shortcuts help dialog opens.Escape to close it.N — the create issue form should appear in the Open column with the input focused.Escape to close it.? for keyboard shortcuts" above the dark mode toggle.Project settings:
Tooltips:
Here is every file we created or modified in this part:
In @dxsolo/ui (the component library):
| File | Purpose |
|---|---|
lib/components/Toggle/Toggle.tsx | Switch control with role="switch" semantics |
lib/components/Toggle/Toggle.stories.tsx | Storybook stories for Toggle |
lib/components/Kbd/Kbd.tsx | Keyboard key indicator with keycap styling |
lib/components/Kbd/Kbd.stories.tsx | Storybook stories for Kbd |
lib/components/Tooltip/Tooltip.tsx | CSS-positioned hover/focus tooltip |
lib/components/Tooltip/Tooltip.stories.tsx | Storybook stories for Tooltip |
lib/index.ts | Updated exports |
In the kanban app:
| File | Purpose |
|---|---|
src/app/globals.css | Cleaned up — removed hardcoded colors, uses library theme |
src/app/layout.tsx | Added ThemeProvider, anti-flash script, semantic body classes |
src/components/theme/ThemeProvider.tsx | Theme context with localStorage persistence |
src/hooks/useKeyboardShortcuts.ts | Keyboard shortcut hook with input-awareness |
src/components/board/ShortcutsDialog.tsx | Help dialog listing all keyboard shortcuts |
src/components/board/Board.tsx | Added keyboard shortcuts and shortcuts dialog |
src/components/board/Column.tsx | Semantic color tokens, tooltip on "+" button |
src/components/board/IssueCard.tsx | Semantic color tokens for dark mode |
src/components/layout/AppShell.tsx | Semantic color tokens for sidebar |
src/components/layout/Sidebar.tsx | Added dark mode toggle, settings link |
src/app/projects/[projectId]/settings/page.tsx | Project settings route |
src/components/settings/ProjectSettings.tsx | General settings + danger zone for deletion |
src/actions/project-actions.ts | Added updateProject action |
.env.example | Environment variable template |
package.json | Updated build script for Prisma migration |
Over six posts, @dxsolo/ui grew from five components to fifteen:
| Component | Part Added | Pattern |
|---|---|---|
| Button | 1 | CVA variants |
| Input | 1 | forwardRef + label/error |
| Card | 1 | Compound (4 sub-components) |
| Modal | 1 | Portal + focus trap |
| Tabs | 1 | Compound + context |
| Textarea | 4 | Mirrors Input |
| Select | 4 | Native select wrapper |
| Checkbox | 4 | Input + label |
| Badge | 4 | CVA variants |
| ColorPicker | 5 | Radio group semantics |
| AlertDialog | 5 | Safe-focus confirmation |
| Toggle | 6 | role="switch" button |
| Kbd | 6 | Styled <kbd> |
| Tooltip | 6 | CSS 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.
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:
@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.This project is a foundation, not a ceiling. Here are some directions you might take it:
⌘K) that searches issues across all projects.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