Files
openpylon/docs/plans/2026-02-15-visual-glow-up-implementation.md
Your Name 1592264514 docs: add visual glow-up implementation plan with 17 tasks
Detailed step-by-step plan covering settings infrastructure,
UI zoom, accent colors, density, board/column/card colors,
toasts, keyboard help, backgrounds, onboarding, and polish.
2026-02-15 20:18:38 +02:00

1767 lines
50 KiB
Markdown

# Visual Glow-Up Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Transform OpenPylon from functional-but-bare to visually polished — settings infrastructure, UI zoom, accent colors, density, board/column/card colors, toasts, keyboard help, backgrounds, onboarding, and polish.
**Architecture:** Settings-first approach. Expand the AppSettings type with new appearance fields, wire them through the Zustand app-store to CSS custom properties on `<html>`, then build the tabbed settings UI. Visual features (board colors, column colors, card covers, etc.) layer on top. Toast system is an independent Zustand store. No new npm dependencies needed — everything uses existing Tailwind, Lucide, Framer Motion, and Radix primitives.
**Tech Stack:** React 19, TypeScript, Zustand 5, Tailwind CSS 4, Radix UI, Lucide React, Framer Motion, Tauri v2, Zod 4
---
### Task 1: Expand Settings Type & Schema
**Files:**
- Modify: `src/types/settings.ts`
- Modify: `src/lib/schemas.ts`
**Step 1: Update AppSettings interface**
In `src/types/settings.ts`, replace the entire file:
```typescript
import type { ColumnWidth } from "./board";
export interface AppSettings {
theme: "light" | "dark" | "system";
dataDirectory: string | null;
recentBoardIds: string[];
// Appearance
accentColor: string;
uiZoom: number;
density: "compact" | "comfortable" | "spacious";
// Board defaults
defaultColumnWidth: ColumnWidth;
}
```
**Step 2: Update the Zod schema**
In `src/lib/schemas.ts`, replace the `appSettingsSchema`:
```typescript
export const appSettingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]).default("system"),
dataDirectory: z.string().nullable().default(null),
recentBoardIds: z.array(z.string()).default([]),
accentColor: z.string().default("160"),
uiZoom: z.number().min(0.75).max(1.5).default(1),
density: z.enum(["compact", "comfortable", "spacious"]).default("comfortable"),
defaultColumnWidth: z.enum(["narrow", "standard", "wide"]).default("standard"),
});
```
**Step 3: Verify types compile**
Run: `npx tsc --noEmit`
Expected: Should fail — `app-store.ts` default value doesn't include the new fields yet. That's expected, we'll fix it in Task 2.
**Step 4: Commit**
```bash
git add src/types/settings.ts src/lib/schemas.ts
git commit -m "feat: expand AppSettings with appearance and board default fields"
```
---
### Task 2: Wire App Store with Appearance Application
**Files:**
- Modify: `src/stores/app-store.ts`
**Step 1: Add applyAppearance and new actions**
Replace `src/stores/app-store.ts` entirely:
```typescript
import { create } from "zustand";
import type { AppSettings } from "@/types/settings";
import type { BoardMeta, ColumnWidth } from "@/types/board";
import { loadSettings, saveSettings, listBoards, ensureDataDirs } from "@/lib/storage";
export type View = { type: "board-list" } | { type: "board"; boardId: string };
interface AppState {
settings: AppSettings;
boards: BoardMeta[];
view: View;
initialized: boolean;
init: () => Promise<void>;
setTheme: (theme: AppSettings["theme"]) => void;
setAccentColor: (hue: string) => void;
setUiZoom: (zoom: number) => void;
setDensity: (density: AppSettings["density"]) => void;
setDefaultColumnWidth: (width: ColumnWidth) => void;
setView: (view: View) => void;
refreshBoards: () => Promise<void>;
addRecentBoard: (boardId: string) => void;
}
function applyTheme(theme: AppSettings["theme"]): void {
const root = document.documentElement;
if (theme === "system") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
root.classList.toggle("dark", prefersDark);
} else {
root.classList.toggle("dark", theme === "dark");
}
}
function applyAppearance(settings: AppSettings): void {
const root = document.documentElement;
// Zoom: set root font-size (Tailwind rem units scale automatically)
root.style.fontSize = `${settings.uiZoom * 16}px`;
// Accent color: regenerate --pylon-accent from OKLCH hue
const hue = settings.accentColor;
const isDark = root.classList.contains("dark");
const lightness = isDark ? "60%" : "55%";
root.style.setProperty("--pylon-accent", `oklch(${lightness} 0.12 ${hue})`);
// Density factor
const densityMap = { compact: "0.75", comfortable: "1", spacious: "1.25" };
root.style.setProperty("--density-factor", densityMap[settings.density]);
}
function updateAndSave(
get: () => AppState,
set: (partial: Partial<AppState>) => void,
patch: Partial<AppSettings>
): void {
const settings = { ...get().settings, ...patch };
set({ settings });
saveSettings(settings);
}
export const useAppStore = create<AppState>((set, get) => ({
settings: {
theme: "system",
dataDirectory: null,
recentBoardIds: [],
accentColor: "160",
uiZoom: 1,
density: "comfortable",
defaultColumnWidth: "standard",
},
boards: [],
view: { type: "board-list" },
initialized: false,
init: async () => {
await ensureDataDirs();
const settings = await loadSettings();
const boards = await listBoards();
set({ settings, boards, initialized: true });
applyTheme(settings.theme);
applyAppearance(settings);
},
setTheme: (theme) => {
updateAndSave(get, set, { theme });
applyTheme(theme);
// Re-apply appearance since accent lightness depends on dark/light
applyAppearance({ ...get().settings, theme });
},
setAccentColor: (accentColor) => {
updateAndSave(get, set, { accentColor });
applyAppearance(get().settings);
},
setUiZoom: (uiZoom) => {
updateAndSave(get, set, { uiZoom });
applyAppearance(get().settings);
},
setDensity: (density) => {
updateAndSave(get, set, { density });
applyAppearance(get().settings);
},
setDefaultColumnWidth: (defaultColumnWidth) => {
updateAndSave(get, set, { defaultColumnWidth });
},
setView: (view) => set({ view }),
refreshBoards: async () => {
const boards = await listBoards();
set({ boards });
},
addRecentBoard: (boardId) => {
const settings = get().settings;
const recent = [
boardId,
...settings.recentBoardIds.filter((id) => id !== boardId),
].slice(0, 10);
updateAndSave(get, set, { recentBoardIds: recent });
},
}));
```
**Step 2: Verify types compile**
Run: `npx tsc --noEmit`
Expected: PASS (or only unrelated warnings)
**Step 3: Commit**
```bash
git add src/stores/app-store.ts
git commit -m "feat: wire app store with appearance actions and CSS variable application"
```
---
### Task 3: Add Density CSS Variable Support
**Files:**
- Modify: `src/index.css`
**Step 1: Add density variable defaults to `:root`**
After the existing `--radius: 0.625rem;` line in `:root`, add:
```css
--density-factor: 1;
```
**Step 2: Verify the app still loads**
Run: `npm run dev` — open http://localhost:1420, confirm no visual breakage.
**Step 3: Commit**
```bash
git add src/index.css
git commit -m "feat: add density CSS variable with default value"
```
---
### Task 4: Rewrite Settings Dialog as Tabbed Panel
**Files:**
- Modify: `src/components/settings/SettingsDialog.tsx`
**Step 1: Rewrite SettingsDialog with four tabs**
Replace the entire file with a tabbed settings panel. Tabs: Appearance, Boards, Keyboard Shortcuts, About.
```typescript
import { useState } from "react";
import {
Sun, Moon, Monitor, RotateCcw,
Columns3, LayoutList, Maximize,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { useAppStore } from "@/stores/app-store";
import type { AppSettings } from "@/types/settings";
import type { ColumnWidth } from "@/types/board";
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type Tab = "appearance" | "boards" | "shortcuts" | "about";
const THEME_OPTIONS: {
value: AppSettings["theme"];
label: string;
icon: typeof Sun;
}[] = [
{ value: "light", label: "Light", icon: Sun },
{ value: "dark", label: "Dark", icon: Moon },
{ value: "system", label: "System", icon: Monitor },
];
const ACCENT_PRESETS: { hue: string; label: string }[] = [
{ hue: "160", label: "Teal" },
{ hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" },
{ hue: "350", label: "Pink" },
{ hue: "25", label: "Red" },
{ hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" },
{ hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" },
{ hue: "0", label: "Slate" },
];
const DENSITY_OPTIONS: {
value: AppSettings["density"];
label: string;
}[] = [
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
{ value: "spacious", label: "Spacious" },
];
const WIDTH_OPTIONS: { value: ColumnWidth; label: string }[] = [
{ value: "narrow", label: "Narrow" },
{ value: "standard", label: "Standard" },
{ value: "wide", label: "Wide" },
];
const SHORTCUTS: { key: string; description: string; category: string }[] = [
{ key: "Ctrl+K", description: "Open command palette", category: "Navigation" },
{ key: "Ctrl+Z", description: "Undo", category: "Board" },
{ key: "Ctrl+Shift+Z", description: "Redo", category: "Board" },
{ key: "?", description: "Keyboard shortcuts", category: "Navigation" },
{ key: "Escape", description: "Close modal / cancel", category: "Navigation" },
];
const TABS: { value: Tab; label: string }[] = [
{ value: "appearance", label: "Appearance" },
{ value: "boards", label: "Boards" },
{ value: "shortcuts", label: "Shortcuts" },
{ value: "about", label: "About" },
];
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<label className="mb-2 block font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{children}
</label>
);
}
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
const [tab, setTab] = useState<Tab>("appearance");
const settings = useAppStore((s) => s.settings);
const setTheme = useAppStore((s) => s.setTheme);
const setAccentColor = useAppStore((s) => s.setAccentColor);
const setUiZoom = useAppStore((s) => s.setUiZoom);
const setDensity = useAppStore((s) => s.setDensity);
const setDefaultColumnWidth = useAppStore((s) => s.setDefaultColumnWidth);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-lg">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Settings
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Configure your OpenPylon preferences.
</DialogDescription>
</DialogHeader>
{/* Tab bar */}
<div className="flex gap-1 border-b border-border pb-2">
{TABS.map((t) => (
<Button
key={t.value}
variant={tab === t.value ? "secondary" : "ghost"}
size="sm"
onClick={() => setTab(t.value)}
className="font-mono text-xs"
>
{t.label}
</Button>
))}
</div>
{/* Tab content */}
<div className="flex flex-col gap-5 pt-1">
{tab === "appearance" && (
<>
{/* Theme */}
<div>
<SectionLabel>Theme</SectionLabel>
<div className="flex gap-2">
{THEME_OPTIONS.map(({ value, label, icon: Icon }) => (
<Button
key={value}
type="button"
variant={settings.theme === value ? "default" : "outline"}
size="sm"
onClick={() => setTheme(value)}
className="flex-1 gap-2"
>
<Icon className="size-4" />
{label}
</Button>
))}
</div>
</div>
<Separator />
{/* UI Zoom */}
<div>
<div className="mb-2 flex items-center justify-between">
<SectionLabel>UI Zoom</SectionLabel>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-pylon-text-secondary">
{Math.round(settings.uiZoom * 100)}%
</span>
{settings.uiZoom !== 1 && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => setUiZoom(1)}
className="text-pylon-text-secondary hover:text-pylon-text"
>
<RotateCcw className="size-3" />
</Button>
)}
</div>
</div>
<input
type="range"
min="0.75"
max="1.5"
step="0.05"
value={settings.uiZoom}
onChange={(e) => setUiZoom(parseFloat(e.target.value))}
className="w-full accent-pylon-accent"
/>
<div className="mt-1 flex justify-between font-mono text-[10px] text-pylon-text-secondary">
<span>75%</span>
<span>100%</span>
<span>150%</span>
</div>
</div>
<Separator />
{/* Accent Color */}
<div>
<SectionLabel>Accent Color</SectionLabel>
<div className="flex flex-wrap gap-2">
{ACCENT_PRESETS.map(({ hue, label }) => {
const isAchromatic = hue === "0";
const bg = isAchromatic
? "oklch(50% 0 0)"
: `oklch(55% 0.12 ${hue})`;
return (
<button
key={hue}
type="button"
onClick={() => setAccentColor(hue)}
className="size-7 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: bg,
outline: settings.accentColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "2px",
}}
aria-label={label}
title={label}
/>
);
})}
</div>
</div>
<Separator />
{/* Density */}
<div>
<SectionLabel>Density</SectionLabel>
<div className="flex gap-2">
{DENSITY_OPTIONS.map(({ value, label }) => (
<Button
key={value}
type="button"
variant={settings.density === value ? "default" : "outline"}
size="sm"
onClick={() => setDensity(value)}
className="flex-1"
>
{label}
</Button>
))}
</div>
</div>
</>
)}
{tab === "boards" && (
<div>
<SectionLabel>Default Column Width</SectionLabel>
<div className="flex gap-2">
{WIDTH_OPTIONS.map(({ value, label }) => (
<Button
key={value}
type="button"
variant={settings.defaultColumnWidth === value ? "default" : "outline"}
size="sm"
onClick={() => setDefaultColumnWidth(value)}
className="flex-1"
>
{label}
</Button>
))}
</div>
</div>
)}
{tab === "shortcuts" && (
<div className="flex flex-col gap-1">
{SHORTCUTS.map(({ key, description }) => (
<div key={key} className="flex items-center justify-between py-1">
<span className="text-sm text-pylon-text">{description}</span>
<kbd className="rounded bg-pylon-column px-2 py-0.5 font-mono text-xs text-pylon-text-secondary">
{key}
</kbd>
</div>
))}
</div>
)}
{tab === "about" && (
<div className="space-y-2 text-sm text-pylon-text">
<p className="font-heading text-lg">OpenPylon</p>
<p className="text-pylon-text-secondary">
v0.1.0 &middot; Local-first Kanban board
</p>
<p className="text-pylon-text-secondary">
Built with Tauri, React, and TypeScript.
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
```
**Step 2: Verify the app loads and settings dialog works**
Run: `npm run dev`, open Settings, verify all four tabs render and settings persist.
**Step 3: Commit**
```bash
git add src/components/settings/SettingsDialog.tsx
git commit -m "feat: rewrite settings dialog with tabbed panel — appearance, boards, shortcuts, about"
```
---
### Task 5: Apply Density to Cards and Columns
**Files:**
- Modify: `src/components/board/KanbanColumn.tsx`
- Modify: `src/components/board/CardThumbnail.tsx`
- Modify: `src/components/board/BoardView.tsx`
**Step 1: Update KanbanColumn to use density-responsive padding**
In `KanbanColumn.tsx`, change the card list `<ul>` padding from `p-2` to use `calc()`:
Replace:
```
className="flex min-h-[40px] list-none flex-col gap-2 p-2"
```
With:
```
className="flex min-h-[40px] list-none flex-col p-2" style={{ gap: `calc(0.5rem * var(--density-factor))`, padding: `calc(0.5rem * var(--density-factor))` }}
```
**Step 2: Update CardThumbnail to use density-responsive padding**
In `CardThumbnail.tsx`, change the card button padding from `p-3` to inline style:
Replace the `className` on the `<motion.button>`:
```
className="w-full rounded-lg bg-pylon-surface shadow-sm text-left transition-all duration-200 hover:-translate-y-px hover:shadow-md"
```
And add a style prop:
```
style={{ ...style, padding: `calc(0.75rem * var(--density-factor))` }}
```
(Merge the existing DnD `style` with the padding.)
**Step 3: Update BoardView gap**
In `BoardView.tsx`, change the board container `gap-6 p-6` to use density:
Replace:
```
className="flex h-full gap-6 overflow-x-auto p-6"
```
With:
```
className="flex h-full overflow-x-auto" style={{ gap: `calc(1.5rem * var(--density-factor))`, padding: `calc(1.5rem * var(--density-factor))` }}
```
**Step 4: Verify all three density modes look correct**
Open Settings > Appearance > toggle between Compact/Comfortable/Spacious. Verify cards and columns compress and expand.
**Step 5: Commit**
```bash
git add src/components/board/KanbanColumn.tsx src/components/board/CardThumbnail.tsx src/components/board/BoardView.tsx
git commit -m "feat: apply density factor to card, column, and board spacing"
```
---
### Task 6: Board Color in UI (TopBar + Column Headers)
**Files:**
- Modify: `src/components/layout/TopBar.tsx`
- Modify: `src/components/board/ColumnHeader.tsx`
**Step 1: Add board color accent to TopBar**
In `TopBar.tsx`, modify the `<header>` to show the board color as a bottom border when viewing a board:
Replace the header className:
```
className="flex h-12 shrink-0 items-center gap-2 border-b border-border bg-pylon-surface px-3"
```
With:
```
className="flex h-12 shrink-0 items-center gap-2 bg-pylon-surface px-3"
style={{ borderBottom: isBoardView && board ? `2px solid ${board.color}` : '1px solid var(--border)' }}
```
Also add a color dot next to the board title. In the center section, before the board title text/button, add:
```tsx
<span
className="mr-1.5 inline-block size-2.5 rounded-full"
style={{ backgroundColor: board.color }}
/>
```
**Step 2: Add board color to column headers**
In `ColumnHeader.tsx`, add a `boardColor` prop:
Update the interface:
```typescript
interface ColumnHeaderProps {
column: Column;
cardCount: number;
boardColor?: string;
}
```
Add a 3px top border to the column header wrapper:
Replace the outer div className:
```
className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3"
```
With:
```
className="flex items-center justify-between border-b border-border px-3 pb-2 pt-3"
style={{ borderTop: boardColor ? `3px solid ${boardColor}30` : undefined }}
```
(The `30` suffix is hex 30% opacity appended to the hex color.)
**Step 3: Pass boardColor through KanbanColumn**
In `KanbanColumn.tsx`, pass the board color to ColumnHeader:
```tsx
<ColumnHeader column={column} cardCount={column.cardIds.length} boardColor={board?.color} />
```
**Step 4: Verify board color shows in TopBar border and column headers**
Open a board — verify the bottom border of the TopBar matches the board color and columns have a faint colored top line.
**Step 5: Commit**
```bash
git add src/components/layout/TopBar.tsx src/components/board/ColumnHeader.tsx src/components/board/KanbanColumn.tsx
git commit -m "feat: apply board color to TopBar border and column header accents"
```
---
### Task 7: Expand Board Types (Column Color, Card Cover, Board Background)
**Files:**
- Modify: `src/types/board.ts`
- Modify: `src/lib/schemas.ts`
- Modify: `src/lib/board-factory.ts`
- Modify: `src/stores/board-store.ts`
**Step 1: Update Column, Card, and BoardSettings types**
In `src/types/board.ts`:
Add `color` to Column:
```typescript
export interface Column {
id: string;
title: string;
cardIds: string[];
width: ColumnWidth;
color: string | null;
}
```
Add `coverColor` to Card:
```typescript
export interface Card {
id: string;
title: string;
description: string;
labels: string[];
checklist: ChecklistItem[];
dueDate: string | null;
attachments: Attachment[];
coverColor: string | null;
createdAt: string;
updatedAt: string;
}
```
Add `background` to BoardSettings:
```typescript
export interface BoardSettings {
attachmentMode: "link" | "copy";
background: "none" | "dots" | "grid" | "gradient";
}
```
**Step 2: Update Zod schemas**
In `src/lib/schemas.ts`:
Update `columnSchema`:
```typescript
export const columnSchema = z.object({
id: z.string(),
title: z.string(),
cardIds: z.array(z.string()).default([]),
width: z.enum(["narrow", "standard", "wide"]).default("standard"),
color: z.string().nullable().default(null),
});
```
Update `cardSchema` — add `coverColor` after `attachments`:
```typescript
coverColor: z.string().nullable().default(null),
```
Update `boardSettingsSchema`:
```typescript
export const boardSettingsSchema = z.object({
attachmentMode: z.enum(["link", "copy"]).default("link"),
background: z.enum(["none", "dots", "grid", "gradient"]).default("none"),
});
```
**Step 3: Update board-factory defaults**
In `src/lib/board-factory.ts`, update the `col` helper to include `color: null`:
```typescript
const col = (t: string, w: ColumnWidth = "standard") => ({
id: ulid(),
title: t,
cardIds: [] as string[],
width: w,
color: null as string | null,
});
```
And update the board's default settings:
```typescript
settings: { attachmentMode: "link", background: "none" as const },
```
**Step 4: Update board-store addCard and addColumn**
In `src/stores/board-store.ts`:
In `addColumn`, add `color: null`:
```typescript
{
id: ulid(),
title,
cardIds: [],
width: "standard" as ColumnWidth,
color: null,
}
```
In `addCard`, add `coverColor: null` to the card object:
```typescript
const card: Card = {
id: cardId,
title,
description: "",
labels: [],
checklist: [],
dueDate: null,
attachments: [],
coverColor: null,
createdAt: now(),
updatedAt: now(),
};
```
Add a `setColumnColor` action to the store interface and implementation:
Interface addition:
```typescript
setColumnColor: (columnId: string, color: string | null) => void;
```
Implementation:
```typescript
setColumnColor: (columnId, color) => {
mutate(get, set, (b) => ({
...b,
updatedAt: now(),
columns: b.columns.map((c) =>
c.id === columnId ? { ...c, color } : c
),
}));
},
```
**Step 5: Verify types compile**
Run: `npx tsc --noEmit`
Expected: PASS
**Step 6: Commit**
```bash
git add src/types/board.ts src/lib/schemas.ts src/lib/board-factory.ts src/stores/board-store.ts
git commit -m "feat: add column color, card coverColor, and board background to data model"
```
---
### Task 8: Column Color UI
**Files:**
- Modify: `src/components/board/ColumnHeader.tsx`
- Modify: `src/components/board/KanbanColumn.tsx`
**Step 1: Add Color submenu to ColumnHeader dropdown**
In `ColumnHeader.tsx`, add `setColumnColor` to store selectors:
```typescript
const setColumnColor = useBoardStore((s) => s.setColumnColor);
```
Add the color swatches data (same as settings accent presets):
```typescript
const COLOR_PRESETS = [
{ hue: "160", label: "Teal" },
{ hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" },
{ hue: "350", label: "Pink" },
{ hue: "25", label: "Red" },
{ hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" },
{ hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" },
{ hue: "0", label: "Slate" },
];
```
Add a Color submenu after the Width submenu in the dropdown:
```tsx
<DropdownMenuSub>
<DropdownMenuSubTrigger>Color</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => setColumnColor(column.id, null)}>
None
{column.color == null && (
<span className="ml-auto text-pylon-accent">*</span>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="flex flex-wrap gap-1.5 px-2 py-1.5">
{COLOR_PRESETS.map(({ hue, label }) => (
<button
key={hue}
onClick={() => setColumnColor(column.id, hue)}
className="size-5 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: `oklch(55% 0.12 ${hue})`,
outline: column.color === hue ? "2px solid currentColor" : "none",
outlineOffset: "1px",
}}
title={label}
/>
))}
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
```
**Step 2: Use column color for top border**
In the ColumnHeader, update the border-top style to prefer column color over board color:
```typescript
const effectiveColor = column.color
? `oklch(55% 0.12 ${column.color})`
: boardColor ?? undefined;
```
And apply:
```
style={{ borderTop: effectiveColor ? `3px solid ${effectiveColor}${column.color ? '' : '30'}` : undefined }}
```
(Full opacity for explicit column colors, 30% for inherited board colors.)
**Step 3: Verify column colors work**
Open a board, right-click column header > Color > pick a color. Verify the top border changes.
**Step 4: Commit**
```bash
git add src/components/board/ColumnHeader.tsx
git commit -m "feat: add column color picker submenu with 10 preset colors"
```
---
### Task 9: Card Cover Color
**Files:**
- Modify: `src/components/board/CardThumbnail.tsx`
- Modify: `src/components/card-detail/CardDetailModal.tsx`
**Step 1: Render cover color bar in CardThumbnail**
In `CardThumbnail.tsx`, add the cover color bar as the first child inside the `<motion.button>`:
```tsx
{card.coverColor && (
<div
className="-mx-3 -mt-3 mb-2 h-1 rounded-t-lg"
style={{ backgroundColor: `oklch(55% 0.12 ${card.coverColor})` }}
/>
)}
```
Note: The negative margins `-mx-3 -mt-3` need to match the card padding. Since we moved to density-based padding, adjust to use `calc(-0.75rem * var(--density-factor))` as inline margin, or simpler — wrap the cover bar outside the padding area. Actually, the simplest approach: put the cover bar before the padded content and use absolute positioning or restructure. Let's use a simpler approach — add the bar at the top with negative margins matching the density padding:
```tsx
{card.coverColor && (
<div
className="mb-2 h-1 rounded-t-lg"
style={{
backgroundColor: `oklch(55% 0.12 ${card.coverColor})`,
margin: `calc(-0.75rem * var(--density-factor)) calc(-0.75rem * var(--density-factor)) 0.5rem`,
}}
/>
)}
```
**Step 2: Add cover color picker to CardDetailModal**
In `CardDetailModal.tsx`, add a "Cover" section to the right sidebar, before Labels:
```tsx
<CoverColorPicker cardId={cardId} coverColor={card.coverColor} />
<Separator />
```
Create the `CoverColorPicker` component inline in the same file:
```tsx
function CoverColorPicker({ cardId, coverColor }: { cardId: string; coverColor: string | null }) {
const updateCard = useBoardStore((s) => s.updateCard);
const presets = [
{ hue: "160", label: "Teal" }, { hue: "240", label: "Blue" },
{ hue: "300", label: "Purple" }, { hue: "350", label: "Pink" },
{ hue: "25", label: "Red" }, { hue: "55", label: "Orange" },
{ hue: "85", label: "Yellow" }, { hue: "130", label: "Lime" },
{ hue: "200", label: "Cyan" }, { hue: "0", label: "Slate" },
];
return (
<div className="flex flex-col gap-2">
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
Cover
</h4>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => updateCard(cardId, { coverColor: null })}
className="flex size-6 items-center justify-center rounded-full border border-border text-xs text-pylon-text-secondary hover:bg-pylon-column"
title="None"
>
&times;
</button>
{presets.map(({ hue, label }) => (
<button
key={hue}
onClick={() => updateCard(cardId, { coverColor: hue })}
className="size-6 rounded-full transition-transform hover:scale-110"
style={{
backgroundColor: `oklch(55% 0.12 ${hue})`,
outline: coverColor === hue ? "2px solid currentColor" : "none",
outlineOffset: "1px",
}}
title={label}
/>
))}
</div>
</div>
);
}
```
**Step 3: Verify card covers work**
Open card detail > set a cover color > close modal > verify the card thumbnail shows the color bar.
**Step 4: Commit**
```bash
git add src/components/board/CardThumbnail.tsx src/components/card-detail/CardDetailModal.tsx
git commit -m "feat: add card cover color with picker in card detail and bar in thumbnail"
```
---
### Task 10: Richer Card Thumbnails
**Files:**
- Modify: `src/components/board/CardThumbnail.tsx`
**Step 1: Add attachment and description indicators**
Import Paperclip and AlignLeft from lucide-react:
```typescript
import { Paperclip, AlignLeft } from "lucide-react";
```
Expand the footer condition to also check for description and attachments:
Replace:
```tsx
{(hasDueDate || card.checklist.length > 0) && (
```
With:
```tsx
{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description) && (
```
Add the indicators inside the footer div, after the checklist bar:
```tsx
{card.description && (
<AlignLeft className="size-3 text-pylon-text-secondary" />
)}
{card.attachments.length > 0 && (
<span className="flex items-center gap-0.5 text-pylon-text-secondary">
<Paperclip className="size-3" />
<span className="font-mono text-xs">{card.attachments.length}</span>
</span>
)}
```
**Step 2: Verify indicators show**
Create a card with a description and attachment. Verify the icons appear in the thumbnail footer.
**Step 3: Commit**
```bash
git add src/components/board/CardThumbnail.tsx
git commit -m "feat: add description and attachment indicators to card thumbnails"
```
---
### Task 11: Toast Notification System
**Files:**
- Create: `src/stores/toast-store.ts`
- Create: `src/components/toast/ToastContainer.tsx`
- Modify: `src/App.tsx`
- Modify: `src/components/import-export/ImportExportButtons.tsx`
- Modify: `src/components/boards/BoardCard.tsx`
**Step 1: Create toast store**
Create `src/stores/toast-store.ts`:
```typescript
import { create } from "zustand";
export type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastState {
toasts: Toast[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: string) => void;
}
let nextId = 0;
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (message, type = "info") => {
const id = String(++nextId);
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
setTimeout(() => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 3000);
},
removeToast: (id) => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
},
}));
```
**Step 2: Create ToastContainer component**
Create `src/components/toast/ToastContainer.tsx`:
```typescript
import { AnimatePresence, motion } from "framer-motion";
import { useToastStore } from "@/stores/toast-store";
const TYPE_STYLES = {
success: "bg-pylon-accent/10 text-pylon-accent border-pylon-accent/20",
error: "bg-pylon-danger/10 text-pylon-danger border-pylon-danger/20",
info: "bg-pylon-surface text-pylon-text border-border",
} as const;
export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
<AnimatePresence>
{toasts.map((toast) => (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className={`pointer-events-auto rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
>
{toast.message}
</motion.div>
))}
</AnimatePresence>
</div>
);
}
```
**Step 3: Mount ToastContainer in App.tsx**
In `src/App.tsx`, import and add `<ToastContainer />` after the `<SettingsDialog>`:
```typescript
import { ToastContainer } from "@/components/toast/ToastContainer";
```
Add inside the return, after `<SettingsDialog>`:
```tsx
<ToastContainer />
```
**Step 4: Wire up toasts in ImportExportButtons**
In `ImportExportButtons.tsx`, import the toast store:
```typescript
import { useToastStore } from "@/stores/toast-store";
```
Add at top of component:
```typescript
const addToast = useToastStore((s) => s.addToast);
```
After successful import (after `addRecentBoard`):
```typescript
addToast("Board imported successfully", "success");
```
In the catch block:
```typescript
addToast("Import failed — check file format", "error");
```
After export calls (`handleExportJson`, `handleExportCsv`), add:
```typescript
addToast("Board exported", "success");
```
**Step 5: Wire up toasts in BoardCard delete**
In `BoardCard.tsx`, import and use toast:
```typescript
import { useToastStore } from "@/stores/toast-store";
```
Add in component:
```typescript
const addToast = useToastStore((s) => s.addToast);
```
After `handleDelete` succeeds:
```typescript
addToast(`"${board.title}" deleted`, "info");
```
**Step 6: Verify toasts appear**
Delete a board, export a board, import a board — verify toast pills appear bottom-right and auto-dismiss.
**Step 7: Commit**
```bash
git add src/stores/toast-store.ts src/components/toast/ToastContainer.tsx src/App.tsx src/components/import-export/ImportExportButtons.tsx src/components/boards/BoardCard.tsx
git commit -m "feat: add toast notification system with success, error, and info variants"
```
---
### Task 12: Undo/Redo Buttons in TopBar
**Files:**
- Modify: `src/components/layout/TopBar.tsx`
**Step 1: Add undo/redo buttons**
Import additional icons:
```typescript
import { ArrowLeft, Settings, Search, Undo2, Redo2 } from "lucide-react";
```
Import temporal store access:
```typescript
import { useBoardStore } from "@/stores/board-store";
```
In the right section div (before the saving status span), add:
```tsx
{isBoardView && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text disabled:opacity-30"
onClick={() => useBoardStore.temporal.getState().undo()}
disabled={useBoardStore.temporal.getState().pastStates.length === 0}
>
<Undo2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Undo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Z</kbd>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text disabled:opacity-30"
onClick={() => useBoardStore.temporal.getState().redo()}
disabled={useBoardStore.temporal.getState().futureStates.length === 0}
>
<Redo2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Redo <kbd className="ml-1 font-mono text-[10px] opacity-60">Ctrl+Shift+Z</kbd>
</TooltipContent>
</Tooltip>
</>
)}
```
Note: The disabled state won't reactively update since we're reading temporal state imperatively. For reactive disabled state, subscribe to the temporal store. If that's too complex, skip the disabled state for now and always show the buttons enabled.
**Step 2: Verify undo/redo buttons appear and work**
Open a board, make a change, click the undo button, verify it reverts.
**Step 3: Commit**
```bash
git add src/components/layout/TopBar.tsx
git commit -m "feat: add undo/redo buttons to TopBar with tooltips"
```
---
### Task 13: Keyboard Shortcut Help Modal
**Files:**
- Create: `src/components/shortcuts/ShortcutHelpModal.tsx`
- Modify: `src/hooks/useKeyboardShortcuts.ts`
- Modify: `src/App.tsx`
**Step 1: Create ShortcutHelpModal**
Create `src/components/shortcuts/ShortcutHelpModal.tsx`:
```typescript
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
interface ShortcutHelpModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const SHORTCUT_GROUPS = [
{
category: "Navigation",
shortcuts: [
{ key: "Ctrl+K", description: "Open command palette" },
{ key: "?", description: "Show keyboard shortcuts" },
{ key: "Escape", description: "Close modal / cancel" },
],
},
{
category: "Board",
shortcuts: [
{ key: "Ctrl+Z", description: "Undo" },
{ key: "Ctrl+Shift+Z", description: "Redo" },
],
},
];
export function ShortcutHelpModal({ open, onOpenChange }: ShortcutHelpModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-pylon-surface sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-pylon-text">
Keyboard Shortcuts
</DialogTitle>
<DialogDescription className="text-pylon-text-secondary">
Quick reference for all keyboard shortcuts.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.category}>
<h4 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-pylon-text-secondary">
{group.category}
</h4>
<div className="flex flex-col gap-1">
{group.shortcuts.map(({ key, description }) => (
<div key={key} className="flex items-center justify-between py-1">
<span className="text-sm text-pylon-text">{description}</span>
<kbd className="rounded bg-pylon-column px-2 py-0.5 font-mono text-xs text-pylon-text-secondary">
{key}
</kbd>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
```
**Step 2: Add `?` key handler to useKeyboardShortcuts**
In `useKeyboardShortcuts.ts`, after the Escape handler, add:
```typescript
// ? : open keyboard shortcut help
if (e.key === "?" || (e.shiftKey && e.key === "/")) {
e.preventDefault();
document.dispatchEvent(new CustomEvent("open-shortcut-help"));
return;
}
```
**Step 3: Wire up in App.tsx**
In `App.tsx`, add state and listener:
```typescript
import { ShortcutHelpModal } from "@/components/shortcuts/ShortcutHelpModal";
```
Add state:
```typescript
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);
```
Add event listener (in the existing useEffect pattern or a new one):
```typescript
useEffect(() => {
function handleOpenShortcutHelp() {
setShortcutHelpOpen(true);
}
document.addEventListener("open-shortcut-help", handleOpenShortcutHelp);
return () => {
document.removeEventListener("open-shortcut-help", handleOpenShortcutHelp);
};
}, []);
```
Add the modal to the return:
```tsx
<ShortcutHelpModal open={shortcutHelpOpen} onOpenChange={setShortcutHelpOpen} />
```
**Step 4: Verify ? key opens the modal**
Press `?` on a board view (not in an input) — verify the shortcuts modal appears.
**Step 5: Commit**
```bash
git add src/components/shortcuts/ShortcutHelpModal.tsx src/hooks/useKeyboardShortcuts.ts src/App.tsx
git commit -m "feat: add keyboard shortcut help modal triggered by ? key"
```
---
### Task 14: Board Backgrounds
**Files:**
- Modify: `src/components/board/BoardView.tsx`
- Modify: `src/components/layout/TopBar.tsx`
**Step 1: Add background patterns to BoardView**
In `BoardView.tsx`, compute the background CSS based on board settings:
Add a helper function before the component:
```typescript
function getBoardBackground(board: Board): React.CSSProperties {
const bg = board.settings.background;
if (bg === "dots") {
return {
backgroundImage: `radial-gradient(circle, currentColor 1px, transparent 1px)`,
backgroundSize: "20px 20px",
color: "oklch(50% 0 0 / 5%)",
};
}
if (bg === "grid") {
return {
backgroundImage: `linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)`,
backgroundSize: "24px 24px",
color: "oklch(50% 0 0 / 5%)",
};
}
if (bg === "gradient") {
return {
background: `linear-gradient(135deg, ${board.color}08, ${board.color}03, transparent)`,
};
}
return {};
}
```
Apply it to the board container div:
```tsx
<div
className="flex h-full overflow-x-auto"
style={{
gap: `calc(1.5rem * var(--density-factor))`,
padding: `calc(1.5rem * var(--density-factor))`,
...getBoardBackground(board),
}}
>
```
**Step 2: Add board settings gear button in TopBar**
In `TopBar.tsx`, import `Settings2` from lucide-react (or use a gear icon) and add a board settings dropdown.
For simplicity, add a button that dispatches a custom event to open a board settings popover. Or implement it directly as a dropdown in TopBar:
```tsx
{isBoardView && board && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-pylon-text-secondary hover:text-pylon-text"
>
<Sliders className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Background</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{(["none", "dots", "grid", "gradient"] as const).map((bg) => (
<DropdownMenuItem
key={bg}
onClick={() => useBoardStore.getState().updateBoardSettings({ ...board.settings, background: bg })}
>
{bg.charAt(0).toUpperCase() + bg.slice(1)}
{board.settings.background === bg && (
<span className="ml-auto text-pylon-accent">*</span>
)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)}
```
Import necessary dropdown components and `Sliders` icon from lucide-react in TopBar.
**Step 3: Verify backgrounds render**
Open board settings > Background > pick "Dots". Verify the subtle dot pattern appears behind columns.
**Step 4: Commit**
```bash
git add src/components/board/BoardView.tsx src/components/layout/TopBar.tsx
git commit -m "feat: add board background patterns (dots, grid, gradient) with settings dropdown"
```
---
### Task 15: Onboarding / Empty States
**Files:**
- Modify: `src/components/boards/BoardList.tsx`
- Modify: `src/components/board/KanbanColumn.tsx`
**Step 1: Upgrade empty board list state**
In `BoardList.tsx`, replace the empty state block:
```tsx
if (boards.length === 0) {
return (
<>
<div className="flex h-full flex-col items-center justify-center gap-6">
<div className="text-center">
<h2 className="font-heading text-2xl text-pylon-text">
Welcome to OpenPylon
</h2>
<p className="mt-2 max-w-sm text-sm text-pylon-text-secondary">
A local-first Kanban board that keeps your data on your machine.
Create your first board to get started.
</p>
</div>
<div className="flex items-center gap-3">
<Button size="lg" onClick={() => setDialogOpen(true)}>
<Plus className="size-4" />
Create Board
</Button>
<ImportExportButtons />
</div>
</div>
<NewBoardDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}
```
**Step 2: Add empty column state**
In `KanbanColumn.tsx`, when there are no cards, show a dashed placeholder:
After the card list `</ul>`, but still inside the `<ScrollArea>`, check for empty:
Actually, the best approach is inside the `<ul>` — when `column.cardIds.length === 0`, render a placeholder `<li>`:
After `{column.cardIds.map(...)}`, add:
```tsx
{column.cardIds.length === 0 && (
<li className="flex min-h-[60px] items-center justify-center rounded-md border border-dashed border-pylon-text-secondary/20 text-xs text-pylon-text-secondary/50">
Drop or add a card
</li>
)}
```
**Step 3: Verify empty states**
Delete all boards — verify the welcome message. Create a board with blank template — verify empty columns show "Drop or add a card".
**Step 4: Commit**
```bash
git add src/components/boards/BoardList.tsx src/components/board/KanbanColumn.tsx
git commit -m "feat: upgrade empty states with welcome message and column placeholders"
```
---
### Task 16: Polish Pass
**Files:**
- Modify: `src/index.css`
- Various touch-ups
**Step 1: Add themed scrollbar styles**
In `src/index.css`, add inside `@layer base`:
```css
/* Thin themed scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: oklch(50% 0 0 / 20%) transparent;
}
.dark * {
scrollbar-color: oklch(80% 0 0 / 15%) transparent;
}
```
**Step 2: Verify dark mode works with all new features**
Toggle to dark mode, verify:
- Accent colors look correct
- Column color borders visible
- Card cover bars visible
- Background patterns visible (dots, grid, gradient)
- Toasts styled correctly
**Step 3: Verify zoom works at extremes**
Set zoom to 75% and 150%, verify:
- No layout breakage
- Columns still have proper widths
- Card thumbnails still readable
**Step 4: Commit**
```bash
git add src/index.css
git commit -m "feat: add themed scrollbar styling and verify polish across modes"
```
---
### Task 17: Fix Capabilities & Final Verification
**Files:**
- Modify: `src-tauri/capabilities/default.json` (if not already fixed)
**Step 1: Ensure capabilities use Tauri v2 named permissions**
Verify the file contains:
```json
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default permissions for OpenPylon",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default",
"shell:default",
"fs:default",
"fs:allow-appdata-read-recursive",
"fs:allow-appdata-write-recursive",
"fs:allow-appdata-meta-recursive"
]
}
```
**Step 2: Run the full app**
Run: `npm run tauri dev`
Verify end-to-end:
1. Create a board
2. Add columns and cards
3. Set column colors
4. Set card cover colors
5. Change board background
6. Toggle density and zoom in settings
7. Change accent color
8. Press `?` for shortcut help
9. Use undo/redo buttons
10. Export and import a board — verify toasts
11. Delete a board — verify toast
12. Toggle light/dark mode
**Step 3: Final commit**
```bash
git add -A
git commit -m "feat: complete visual glow-up — settings, zoom, accent, density, colors, toasts, backgrounds"
```
---
## Summary
| Task | Description | Files |
|------|-------------|-------|
| 1 | Expand settings type & schema | settings.ts, schemas.ts |
| 2 | Wire app store with appearance | app-store.ts |
| 3 | Add density CSS variable | index.css |
| 4 | Rewrite settings dialog (tabbed) | SettingsDialog.tsx |
| 5 | Apply density to cards/columns | KanbanColumn, CardThumbnail, BoardView |
| 6 | Board color in UI | TopBar, ColumnHeader, KanbanColumn |
| 7 | Expand board types | board.ts, schemas.ts, board-factory.ts, board-store.ts |
| 8 | Column color UI | ColumnHeader |
| 9 | Card cover color | CardThumbnail, CardDetailModal |
| 10 | Richer card thumbnails | CardThumbnail |
| 11 | Toast notification system | toast-store.ts, ToastContainer, App, ImportExport, BoardCard |
| 12 | Undo/redo buttons | TopBar |
| 13 | Keyboard shortcut help modal | ShortcutHelpModal, useKeyboardShortcuts, App |
| 14 | Board backgrounds | BoardView, TopBar |
| 15 | Onboarding / empty states | BoardList, KanbanColumn |
| 16 | Polish pass | index.css |
| 17 | Fix capabilities & final test | capabilities/default.json |