Files
openpylon/docs/plans/2026-02-15-visual-glow-up-implementation.md
Your Name 1d99473a22 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

50 KiB

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:

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:

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

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:

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

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:

--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

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.

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

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

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:

<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:

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:

<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

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:

export interface Column {
  id: string;
  title: string;
  cardIds: string[];
  width: ColumnWidth;
  color: string | null;
}

Add coverColor to Card:

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:

export interface BoardSettings {
  attachmentMode: "link" | "copy";
  background: "none" | "dots" | "grid" | "gradient";
}

Step 2: Update Zod schemas

In src/lib/schemas.ts:

Update columnSchema:

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:

coverColor: z.string().nullable().default(null),

Update boardSettingsSchema:

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:

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:

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:

{
  id: ulid(),
  title,
  cardIds: [],
  width: "standard" as ColumnWidth,
  color: null,
}

In addCard, add coverColor: null to the card object:

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:

setColumnColor: (columnId: string, color: string | null) => void;

Implementation:

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

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:

const setColumnColor = useBoardStore((s) => s.setColumnColor);

Add the color swatches data (same as settings accent presets):

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:

<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:

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

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>:

{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:

{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:

<CoverColorPicker cardId={cardId} coverColor={card.coverColor} />
<Separator />

Create the CoverColorPicker component inline in the same file:

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

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:

import { Paperclip, AlignLeft } from "lucide-react";

Expand the footer condition to also check for description and attachments:

Replace:

{(hasDueDate || card.checklist.length > 0) && (

With:

{(hasDueDate || card.checklist.length > 0 || card.attachments.length > 0 || card.description) && (

Add the indicators inside the footer div, after the checklist bar:

{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

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:

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:

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>:

import { ToastContainer } from "@/components/toast/ToastContainer";

Add inside the return, after <SettingsDialog>:

<ToastContainer />

Step 4: Wire up toasts in ImportExportButtons

In ImportExportButtons.tsx, import the toast store:

import { useToastStore } from "@/stores/toast-store";

Add at top of component:

const addToast = useToastStore((s) => s.addToast);

After successful import (after addRecentBoard):

addToast("Board imported successfully", "success");

In the catch block:

addToast("Import failed — check file format", "error");

After export calls (handleExportJson, handleExportCsv), add:

addToast("Board exported", "success");

Step 5: Wire up toasts in BoardCard delete

In BoardCard.tsx, import and use toast:

import { useToastStore } from "@/stores/toast-store";

Add in component:

const addToast = useToastStore((s) => s.addToast);

After handleDelete succeeds:

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

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:

import { ArrowLeft, Settings, Search, Undo2, Redo2 } from "lucide-react";

Import temporal store access:

import { useBoardStore } from "@/stores/board-store";

In the right section div (before the saving status span), add:

{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

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:

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:

// ? : 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:

import { ShortcutHelpModal } from "@/components/shortcuts/ShortcutHelpModal";

Add state:

const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false);

Add event listener (in the existing useEffect pattern or a new one):

useEffect(() => {
  function handleOpenShortcutHelp() {
    setShortcutHelpOpen(true);
  }
  document.addEventListener("open-shortcut-help", handleOpenShortcutHelp);
  return () => {
    document.removeEventListener("open-shortcut-help", handleOpenShortcutHelp);
  };
}, []);

Add the modal to the return:

<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

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:

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:

<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:

{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

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:

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:

{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

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:

/* 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

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:

{
  "$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

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