Replace 60/40 split layout with a 2x3 CSS grid where all sections get equal weight. Cover color header, progress bar on checklist, compact markdown editor, scroll containment on long lists.
468 lines
14 KiB
Markdown
468 lines
14 KiB
Markdown
# Card Detail Modal Redesign — Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace the 60/40 split card detail modal with a 2-column dashboard grid where every section gets equal weight.
|
|
|
|
**Architecture:** Full rewrite of `CardDetailModal.tsx` to use CSS Grid (2 cols, 3 rows) under a cover-color header. Sub-components (`MarkdownEditor`, `ChecklistSection`) get minor tweaks for cell sizing. `CoverColorPicker` moves from inline private component to its own grid cell. Framer Motion stagger preserved.
|
|
|
|
**Tech Stack:** React 19, TypeScript, Framer Motion 12, Tailwind 4, Zustand
|
|
|
|
---
|
|
|
|
### Task 1: Rewrite CardDetailModal — header + grid shell
|
|
|
|
**Files:**
|
|
- Modify: `src/components/card-detail/CardDetailModal.tsx` (full rewrite, lines 1-245)
|
|
|
|
**Step 1: Replace the entire file with the new layout**
|
|
|
|
Replace the full contents of `CardDetailModal.tsx` with:
|
|
|
|
```tsx
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { X } from "lucide-react";
|
|
import { useBoardStore } from "@/stores/board-store";
|
|
import { MarkdownEditor } from "@/components/card-detail/MarkdownEditor";
|
|
import { ChecklistSection } from "@/components/card-detail/ChecklistSection";
|
|
import { LabelPicker } from "@/components/card-detail/LabelPicker";
|
|
import { DueDatePicker } from "@/components/card-detail/DueDatePicker";
|
|
import { AttachmentSection } from "@/components/card-detail/AttachmentSection";
|
|
import { springs, staggerContainer, fadeSlideUp } from "@/lib/motion";
|
|
|
|
interface CardDetailModalProps {
|
|
cardId: string | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function CardDetailModal({ cardId, onClose }: CardDetailModalProps) {
|
|
const card = useBoardStore((s) =>
|
|
cardId ? s.board?.cards[cardId] ?? null : null
|
|
);
|
|
const boardLabels = useBoardStore((s) => s.board?.labels ?? []);
|
|
const updateCard = useBoardStore((s) => s.updateCard);
|
|
|
|
const open = cardId != null && card != null;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && card && cardId && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<motion.div
|
|
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Modal */}
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
layoutId={`card-${cardId}`}
|
|
className="relative w-full max-w-4xl overflow-hidden rounded-xl bg-pylon-surface shadow-2xl"
|
|
transition={springs.gentle}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<EscapeHandler onClose={onClose} />
|
|
<span className="sr-only">Card detail editor</span>
|
|
|
|
{/* Header: cover color background + title + close */}
|
|
<div
|
|
className="relative flex items-center gap-3 px-6 py-4"
|
|
style={{
|
|
backgroundColor: card.coverColor
|
|
? `oklch(55% 0.12 ${card.coverColor})`
|
|
: undefined,
|
|
}}
|
|
>
|
|
<InlineTitle
|
|
cardId={cardId}
|
|
title={card.title}
|
|
updateCard={updateCard}
|
|
hasColor={card.coverColor != null}
|
|
/>
|
|
<button
|
|
onClick={onClose}
|
|
className={`absolute right-4 top-1/2 -translate-y-1/2 rounded-md p-1 transition-colors ${
|
|
card.coverColor
|
|
? "text-white/70 hover:bg-white/20 hover:text-white"
|
|
: "text-pylon-text-secondary hover:bg-pylon-column hover:text-pylon-text"
|
|
}`}
|
|
aria-label="Close"
|
|
>
|
|
<X className="size-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Dashboard grid body */}
|
|
<motion.div
|
|
className="grid max-h-[calc(85vh-4rem)] grid-cols-2 gap-4 overflow-y-auto p-5"
|
|
variants={staggerContainer(0.05)}
|
|
initial="hidden"
|
|
animate="visible"
|
|
>
|
|
{/* Row 1: Labels + Due Date */}
|
|
<motion.div
|
|
className="rounded-lg bg-pylon-column/50 p-4"
|
|
variants={fadeSlideUp}
|
|
transition={springs.bouncy}
|
|
>
|
|
<LabelPicker
|
|
cardId={cardId}
|
|
cardLabelIds={card.labels}
|
|
boardLabels={boardLabels}
|
|
/>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="rounded-lg bg-pylon-column/50 p-4"
|
|
variants={fadeSlideUp}
|
|
transition={springs.bouncy}
|
|
>
|
|
<DueDatePicker cardId={cardId} dueDate={card.dueDate} />
|
|
</motion.div>
|
|
|
|
{/* Row 2: Checklist + Description */}
|
|
<motion.div
|
|
className="rounded-lg bg-pylon-column/50 p-4"
|
|
variants={fadeSlideUp}
|
|
transition={springs.bouncy}
|
|
>
|
|
<ChecklistSection
|
|
cardId={cardId}
|
|
checklist={card.checklist}
|
|
/>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="rounded-lg bg-pylon-column/50 p-4"
|
|
variants={fadeSlideUp}
|
|
transition={springs.bouncy}
|
|
>
|
|
<MarkdownEditor cardId={cardId} value={card.description} />
|
|
</motion.div>
|
|
|
|
{/* Row 3: Cover + Attachments */}
|
|
<motion.div
|
|
className="rounded-lg bg-pylon-column/50 p-4"
|
|
variants={fadeSlideUp}
|
|
transition={springs.bouncy}
|
|
>
|
|
<CoverColorPicker
|
|
cardId={cardId}
|
|
coverColor={card.coverColor}
|
|
/>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="rounded-lg bg-pylon-column/50 p-4"
|
|
variants={fadeSlideUp}
|
|
transition={springs.bouncy}
|
|
>
|
|
<AttachmentSection
|
|
cardId={cardId}
|
|
attachments={card.attachments}
|
|
/>
|
|
</motion.div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
/* ---------- Escape key handler ---------- */
|
|
|
|
function EscapeHandler({ onClose }: { onClose: () => void }) {
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === "Escape") onClose();
|
|
}
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [onClose]);
|
|
return null;
|
|
}
|
|
|
|
/* ---------- Inline editable title ---------- */
|
|
|
|
interface InlineTitleProps {
|
|
cardId: string;
|
|
title: string;
|
|
updateCard: (cardId: string, updates: { title: string }) => void;
|
|
hasColor: boolean;
|
|
}
|
|
|
|
function InlineTitle({ cardId, title, updateCard, hasColor }: InlineTitleProps) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(title);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
setDraft(title);
|
|
}, [title]);
|
|
|
|
useEffect(() => {
|
|
if (editing && inputRef.current) {
|
|
inputRef.current.focus();
|
|
inputRef.current.select();
|
|
}
|
|
}, [editing]);
|
|
|
|
function handleSave() {
|
|
const trimmed = draft.trim();
|
|
if (trimmed && trimmed !== title) {
|
|
updateCard(cardId, { title: trimmed });
|
|
} else {
|
|
setDraft(title);
|
|
}
|
|
setEditing(false);
|
|
}
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent) {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleSave();
|
|
} else if (e.key === "Escape") {
|
|
setDraft(title);
|
|
setEditing(false);
|
|
}
|
|
}
|
|
|
|
const textColor = hasColor ? "text-white" : "text-pylon-text";
|
|
const hoverColor = hasColor ? "hover:text-white/80" : "hover:text-pylon-accent";
|
|
|
|
if (editing) {
|
|
return (
|
|
<input
|
|
ref={inputRef}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={handleSave}
|
|
onKeyDown={handleKeyDown}
|
|
className={`flex-1 font-heading text-xl bg-transparent outline-none border-b pb-0.5 ${
|
|
hasColor ? "text-white border-white/50" : "text-pylon-text border-pylon-accent"
|
|
}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<h2
|
|
onClick={() => setEditing(true)}
|
|
className={`flex-1 cursor-pointer font-heading text-xl transition-colors ${textColor} ${hoverColor}`}
|
|
>
|
|
{title}
|
|
</h2>
|
|
);
|
|
}
|
|
|
|
/* ---------- Cover color picker ---------- */
|
|
|
|
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"
|
|
>
|
|
×
|
|
</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 2: Verify TypeScript compiles**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: No errors
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/card-detail/CardDetailModal.tsx
|
|
git commit -m "feat: rewrite card detail modal as 2-column dashboard grid"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Adapt MarkdownEditor for grid cell
|
|
|
|
**Files:**
|
|
- Modify: `src/components/card-detail/MarkdownEditor.tsx` (lines 93-103)
|
|
|
|
**Step 1: Replace `min-h-[200px]` with cell-friendly sizing**
|
|
|
|
In `MarkdownEditor.tsx`, change the textarea className (line 99):
|
|
|
|
```
|
|
Old: className="min-h-[200px] w-full resize-y rounded-md ...
|
|
New: className="min-h-[100px] max-h-[160px] w-full resize-y rounded-md ...
|
|
```
|
|
|
|
And change the preview container className (line 103):
|
|
|
|
```
|
|
Old: className="min-h-[200px] cursor-pointer rounded-md ...
|
|
New: className="min-h-[100px] max-h-[160px] overflow-y-auto cursor-pointer rounded-md ...
|
|
```
|
|
|
|
**Step 2: Verify TypeScript compiles**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: No errors
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/card-detail/MarkdownEditor.tsx
|
|
git commit -m "feat: adapt markdown editor sizing for dashboard grid cell"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add scroll containment to ChecklistSection
|
|
|
|
**Files:**
|
|
- Modify: `src/components/card-detail/ChecklistSection.tsx` (lines 52-64)
|
|
|
|
**Step 1: Add max-height + overflow to the checklist items container**
|
|
|
|
In `ChecklistSection.tsx`, change the items container (line 53):
|
|
|
|
```
|
|
Old: <div className="flex flex-col gap-1">
|
|
New: <div className="flex max-h-[160px] flex-col gap-1 overflow-y-auto">
|
|
```
|
|
|
|
Also add a small progress bar under the header. Change lines 39-50 (the header section) to:
|
|
|
|
```tsx
|
|
{/* Header + progress */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
|
Checklist
|
|
</h4>
|
|
{checklist.length > 0 && (
|
|
<span className="font-mono text-xs text-pylon-text-secondary">
|
|
{checked}/{checklist.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{checklist.length > 0 && (
|
|
<div className="h-1 w-full overflow-hidden rounded-full bg-pylon-column">
|
|
<div
|
|
className="h-full rounded-full bg-pylon-accent transition-all duration-300"
|
|
style={{ width: `${(checked / checklist.length) * 100}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
```
|
|
|
|
**Step 2: Verify TypeScript compiles**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: No errors
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/card-detail/ChecklistSection.tsx
|
|
git commit -m "feat: add scroll containment and progress bar to checklist"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Remove unused Separator import + visual verification
|
|
|
|
**Files:**
|
|
- Modify: `src/components/card-detail/CardDetailModal.tsx` (verify no stale imports)
|
|
|
|
**Step 1: Verify the file has no unused imports**
|
|
|
|
The rewrite in Task 1 already removed the `Separator` import. Confirm the import block does NOT include:
|
|
- `import { Separator } from "@/components/ui/separator";`
|
|
|
|
If it's still there, delete it.
|
|
|
|
**Step 2: Run TypeScript check**
|
|
|
|
Run: `npx tsc --noEmit`
|
|
Expected: No errors
|
|
|
|
**Step 3: Run the dev server and visually verify**
|
|
|
|
Run: `npm run dev` (or `npx tauri dev` if checking in Tauri)
|
|
|
|
Verify:
|
|
- Card detail modal opens on card click
|
|
- Full-width header shows cover color (or neutral bg)
|
|
- Title is editable (click to edit, Enter/Escape)
|
|
- Close button [x] works
|
|
- 2x3 grid: Labels | Due Date / Checklist | Description / Cover | Attachments
|
|
- Each cell has rounded-lg background
|
|
- Checklist scrolls when > ~6 items
|
|
- Description shows compact preview
|
|
- All animations stagger in
|
|
- Escape closes modal
|
|
- Click outside closes modal
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat: card detail modal dashboard grid redesign complete"
|
|
```
|