feat(ui): Jira-style card detail, edit-mode column management, design polish
All checks were successful
Build and deploy Havenllo / Verify Go and frontend (push) Successful in 32s
Build and deploy Havenllo / Build and push image (push) Successful in 33s
Build and deploy Havenllo / Apply and restart Havenllo (push) Successful in 7s

This commit is contained in:
Hermes
2026-07-14 10:37:10 -03:00
parent a082764e8a
commit 240f73b229
9 changed files with 769 additions and 389 deletions

View File

@@ -45,15 +45,15 @@ The product is intentionally small: one Go binary serves both the JSON API and t
- `web/src/api.ts`: typed fetch client (`APIError` class, `request<T>` helper). All calls are same-origin under `/api`.
- `web/src/board-state.ts`: `useReducer` board state — owns the `Board` snapshot, `loading`, `error`, and `toasts`. Pure action creators/reducers for add/update/remove list & card and optimistic transforms, plus rollback support.
- `web/src/drag.ts`: native HTML5 drag-and-drop helpers — `cardDragType` MIME, `beginCardDrag`/`readCardDrag` payloads, and `movePosition` (computes the new fractional position between drop neighbours).
- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer, wires optimistic mutations with rollback + toasts, and renders `Header` + `BoardCanvas` + `Toasts`.
- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, and reload button.
- `web/src/components/BoardCanvas.tsx`: horizontally-scrolling list rail, inline "add list" affordance, and the empty/loading states. Maps lists to `ListColumn`.
- `web/src/components/ListColumn.tsx`: one column — inline list-name edit, card count, drop zones (one before each card + one at the end), inline add-card input, and per-list delete confirmation.
- `web/src/components/CardItem.tsx`: one draggable card — click-to-edit title, done toggle, expand to `CardEditor`, and delete confirmation.
- `web/src/components/CardEditor.tsx`: description editor and destructive (delete) action for a card.
- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer and selected-card/list-management UI state, wires optimistic mutations with rollback + toasts, and renders `Header`, `BoardCanvas`, the selected card's `CardDetailModal`, and `Toasts`.
- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, reload button, and the active/inactive Edit toggle for list management.
- `web/src/components/BoardCanvas.tsx`: responsive horizontally-scrolling list rail, management-mode-only add-list affordance, and board empty state. Maps lists to `ListColumn`.
- `web/src/components/ListColumn.tsx`: one column — card count, native drop zones (one before each card + one at the end), inline add-card input, empty-column state, and management-mode-only list rename/delete controls with delete confirmation.
- `web/src/components/CardItem.tsx`: one draggable card tile. A genuine click opens the board-level detail dialog; drag events are suppressed from opening it. It retains the done toggle, title/description preview, and a visible drag grip.
- `web/src/components/CardDetailModal.tsx`: accessible Jira-style card dialog with inline title and description editing, list/status properties, timestamps, delete confirmation, outside/Escape/X close controls, and modal focus restoration.
- `web/src/components/ConfirmDialog.tsx`: accessible confirmation dialog used before any delete.
- `web/src/components/Toast.tsx`: non-blocking toast notifications (auto-dismiss ~4.4s).
- `web/src/styles.css`: all styling. Dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), CSS custom properties, rounded surfaces, soft shadows, focus rings, and `prefers-reduced-motion` support. 44px touch targets for mobile.
- `web/src/styles.css`: all styling. Elevated dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), responsive horizontal board rail, polished card/list/button states, edit-mode treatment, detail-dialog layout, empty states, focus rings, 44px mobile touch targets, and `prefers-reduced-motion` support.
### Embedding bridge
@@ -109,7 +109,9 @@ Error shape: `{ "error": { "code": "validation_error"|"not_found"|..., "message"
- Loads `/api/board` on mount; no board picker (single fixed board).
- Native HTML5 drag-and-drop reorders cards within and across lists; drop computes a fractional position, updates optimistically, and reconciles/rolls back on failure.
- Inline add/edit for lists and cards, done toggle, descriptions, delete-with-confirm.
- Clicking a card opens a centered, accessible Jira-style detail dialog. Its title and description patch independently, while list, done status, created/updated timestamps, and delete-with-confirm are visible in the dialog. It closes via X, Escape, or outside click, focuses on open, and restores the prior trigger focus when closed.
- The Header's Edit toggle enters list-management mode. Only in that mode are list rename/delete controls and the end-of-rail add-list control visible; card add, done, detail editing, delete, and drag-and-drop remain available in either mode.
- Inline add for cards, done toggle, descriptions, and delete-with-confirm. There is no inline card expansion/editor; card editing is centralized in the detail dialog.
- Optimistic UI: every mutation updates state immediately, then reconciles with the server response; failures restore the prior snapshot and show an error toast. Only the pending resource's controls disable.
- Responsive horizontally-scrolling board; usable on phones.

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useReducer, useRef } from "preact/hooks";
import { useCallback, useEffect, useReducer, useRef, useState } from "preact/hooks";
import { api, APIError } from "./api";
import {
addCard,
@@ -14,6 +14,7 @@ import {
import { movePosition } from "./drag";
import type { CardPatch, ListPatch } from "./types";
import { BoardCanvas } from "./components/BoardCanvas";
import { CardDetailModal } from "./components/CardDetailModal";
import { Header } from "./components/Header";
import { Toasts } from "./components/Toast";
@@ -27,9 +28,18 @@ function friendlyError(error: unknown): string {
export function App() {
const [state, dispatch] = useReducer(boardReducer, initialBoardState);
const [managingLists, setManagingLists] = useState(false);
const [selectedCardID, setSelectedCardID] = useState<number | null>(null);
const boardRef = useRef(state.board);
boardRef.current = state.board;
const selectedList = state.board?.lists.find((list) => list.cards.some((card) => card.id === selectedCardID));
const selectedCard = selectedList?.cards.find((card) => card.id === selectedCardID);
useEffect(() => {
if (selectedCardID !== null && !selectedCard) setSelectedCardID(null);
}, [selectedCardID, selectedCard]);
const toast = useCallback((message: string, tone: "success" | "error" | "info" = "info") => {
const id = ++toastID;
dispatch({ type: "toast", toast: { id, message, tone } });
@@ -141,7 +151,12 @@ export function App() {
return (
<main class="app-shell">
<Header onReload={load} loading={state.loading} />
<Header
onReload={load}
loading={state.loading}
managingLists={managingLists}
onToggleManagingLists={() => setManagingLists((active) => !active)}
/>
{state.loading && !state.board ? <section class="loading-card" aria-live="polite">Loading your Haven board</section> : null}
{state.error ? (
<section class="fatal-state">
@@ -158,12 +173,21 @@ export function App() {
onDeleteList={deleteList}
onCreateCard={createCard}
onEditCard={editCard}
onDeleteCard={deleteCard}
onMoveCard={moveCard}
onOpenCard={setSelectedCardID}
managingLists={managingLists}
/>
) : null}
{selectedCard && selectedList ? (
<CardDetailModal
card={selectedCard}
listName={selectedList.name}
onClose={() => setSelectedCardID(null)}
onEdit={(patch) => editCard(selectedCard.id, patch)}
onDelete={() => deleteCard(selectedCard.id)}
/>
) : null}
<Toasts toasts={state.toasts} onDismiss={(id) => dispatch({ type: "dismiss", id })} />
</main>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import type { ListWithCards } from "../types";
import { ListColumn } from "./ListColumn";
@@ -9,8 +9,9 @@ interface BoardCanvasProps {
onDeleteList: (id: number) => Promise<void>;
onCreateCard: (listID: number, title: string) => Promise<void>;
onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onDeleteCard: (id: number) => Promise<void>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void;
managingLists: boolean;
}
export function BoardCanvas(props: BoardCanvasProps) {
@@ -18,6 +19,13 @@ export function BoardCanvas(props: BoardCanvasProps) {
const [name, setName] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!props.managingLists) {
setAdding(false);
setName("");
}
}, [props.managingLists]);
const addList = async () => {
if (!name.trim()) return;
setSaving(true);
@@ -39,22 +47,26 @@ export function BoardCanvas(props: BoardCanvasProps) {
<p class="eyebrow">One calm place for your homelab work</p>
<h1>Haven board</h1>
</div>
<p class="board-hint">Drag tasks between lists, or click a title to edit.</p>
<p class="board-hint">
{props.managingLists ? "Manage your workflow: rename, remove, or add lists." : "Drag tasks between lists, or open one for the full story."}
</p>
</div>
<div class="board-rail">
{props.lists.map((list) => (
<ListColumn
key={list.id}
list={list}
onEditList={(patch) => props.onEditList(list.id, patch)}
onDeleteList={() => props.onDeleteList(list.id)}
onCreateCard={(title) => props.onCreateCard(list.id, title)}
onEditCard={props.onEditCard}
onDeleteCard={props.onDeleteCard}
onMoveCard={props.onMoveCard}
/>
))}
<section class="add-list-column">
{props.lists.length ? (
<div class={"board-rail " + (props.managingLists ? "is-managing" : "")}>
{props.lists.map((list) => (
<ListColumn
key={list.id}
list={list}
managingLists={props.managingLists}
onEditList={(patch) => props.onEditList(list.id, patch)}
onDeleteList={() => props.onDeleteList(list.id)}
onCreateCard={(title) => props.onCreateCard(list.id, title)}
onEditCard={props.onEditCard}
onMoveCard={props.onMoveCard}
onOpenCard={props.onOpenCard}
/>
))}
{props.managingLists ? <section class="add-list-column">
{adding ? (
<form
class="quick-add"
@@ -85,11 +97,18 @@ export function BoardCanvas(props: BoardCanvasProps) {
</form>
) : (
<button type="button" class="add-list-button" onClick={() => setAdding(true)}>
<span aria-hidden="true"></span> Add another list
<span aria-hidden="true"></span> Add list
</button>
)}
</section> : null}
</div>
) : (
<section class="empty-board" aria-live="polite">
<span class="empty-state-mark" aria-hidden="true"></span>
<h2>Your board is ready for a first list</h2>
<p>Turn on Edit to create the first stage in your workflow.</p>
</section>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,237 @@
import { useEffect, useRef, useState } from "preact/hooks";
import type { Card } from "../types";
import { ConfirmDialog } from "./ConfirmDialog";
interface CardDetailModalProps {
card: Card;
listName: string;
onClose: () => void;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onDelete: () => Promise<void>;
}
function formatTimestamp(value: string): string {
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) return "Unavailable";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(timestamp);
}
export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: CardDetailModalProps) {
const [title, setTitle] = useState(card.title);
const [description, setDescription] = useState(card.description);
const [savingTitle, setSavingTitle] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const [savingStatus, setSavingStatus] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const dialogRef = useRef<HTMLElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const confirmingDeleteRef = useRef(false);
const discardTitleSaveRef = useRef(false);
const titleSavingRef = useRef(false);
const closeRef = useRef(onClose);
confirmingDeleteRef.current = confirmingDelete;
closeRef.current = onClose;
useEffect(() => {
setTitle(card.title);
setDescription(card.description);
}, [card.id]);
useEffect(() => {
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusDialog = window.requestAnimationFrame(() => dialogRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape" && !confirmingDeleteRef.current) closeRef.current();
};
window.addEventListener("keydown", closeOnEscape);
return () => {
window.cancelAnimationFrame(focusDialog);
window.removeEventListener("keydown", closeOnEscape);
returnFocusRef.current?.focus();
};
}, []);
const saveTitle = async () => {
const nextTitle = title.trim();
if (!nextTitle) {
setTitle(card.title);
return;
}
if (nextTitle === card.title || titleSavingRef.current) return;
titleSavingRef.current = true;
setSavingTitle(true);
try {
await onEdit({ title: nextTitle });
setTitle(nextTitle);
} catch {
setTitle(card.title);
} finally {
titleSavingRef.current = false;
setSavingTitle(false);
}
};
const saveDescription = async () => {
if (description === card.description) return;
setSavingDescription(true);
try {
await onEdit({ description });
} catch {
setDescription(card.description);
} finally {
setSavingDescription(false);
}
};
const close = () => {
const nextTitle = title.trim();
if (!discardTitleSaveRef.current && nextTitle && nextTitle !== card.title) void saveTitle();
onClose();
};
closeRef.current = close;
const toggleDone = async () => {
setSavingStatus(true);
try {
await onEdit({ done: !card.done });
} catch {
// The app restores the card and shows the request error as a toast.
} finally {
setSavingStatus(false);
}
};
const deleteCard = async () => {
setConfirmingDelete(false);
try {
await onDelete();
onClose();
} catch {
// The app restores the card and shows the request error as a toast.
}
};
const descriptionChanged = description !== card.description;
return (
<div
class="detail-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) close();
}}
>
<section
class="card-detail"
ref={dialogRef}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="card-detail-heading"
onMouseDown={(event) => event.stopPropagation()}
>
<h1 id="card-detail-heading" class="sr-only">Card details: {card.title}</h1>
<header class="detail-header">
<div class="detail-breadcrumb"><span>Board</span><span aria-hidden="true">/</span><strong>{listName}</strong></div>
<button type="button" class="icon-button detail-close" aria-label="Close card details" title="Close" onClick={close}>×</button>
</header>
<div class="detail-content">
<div class="detail-title-row">
<span class="detail-grip" aria-hidden="true"></span>
<input
id="card-detail-title"
class="detail-title-input"
value={title}
maxlength={240}
aria-label="Card title"
disabled={savingTitle}
onInput={(event) => setTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void saveTitle();
}
if (event.key === "Escape") {
event.stopPropagation();
discardTitleSaveRef.current = true;
setTitle(card.title);
event.currentTarget.blur();
}
}}
onBlur={() => {
if (discardTitleSaveRef.current) {
discardTitleSaveRef.current = false;
return;
}
void saveTitle();
}}
/>
</div>
<div class="detail-properties" aria-label="Card properties">
<div class="detail-property"><span>List</span><strong>{listName}</strong></div>
<div class="detail-property detail-status">
<span>Status</span>
<button
type="button"
class={"status-pill " + (card.done ? "is-done" : "")}
disabled={savingStatus}
onClick={() => void toggleDone()}
>
<span aria-hidden="true">{card.done ? "✓" : "○"}</span> {card.done ? "Done" : "In progress"}
</button>
</div>
</div>
<section class="detail-description" aria-labelledby="detail-description-heading">
<div class="detail-section-heading">
<div>
<p class="eyebrow">Details</p>
<h2 id="detail-description-heading">Description</h2>
</div>
{descriptionChanged ? <span class="unsaved-chip">Unsaved changes</span> : null}
</div>
<textarea
value={description}
maxlength={10000}
rows={7}
placeholder="Add useful context, acceptance criteria, or a helpful note…"
aria-label="Card description"
disabled={savingDescription}
onInput={(event) => setDescription(event.currentTarget.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void saveDescription();
}}
/>
<div class="detail-save-row">
<span>Changes save independently. Press Ctrl + Enter to save.</span>
<button type="button" class="button button-primary" disabled={!descriptionChanged || savingDescription} onClick={() => void saveDescription()}>
{savingDescription ? "Saving…" : "Save description"}
</button>
</div>
</section>
<footer class="detail-footer">
<dl class="detail-timestamps">
<div><dt>Created</dt><dd>{formatTimestamp(card.created_at)}</dd></div>
<div><dt>Last updated</dt><dd>{formatTimestamp(card.updated_at)}</dd></div>
</dl>
<button type="button" class="text-danger detail-delete" onClick={() => setConfirmingDelete(true)}>Delete card</button>
</footer>
</div>
</section>
<ConfirmDialog
open={confirmingDelete}
title="Delete this card?"
detail={"“" + card.title + "” will be permanently removed."}
confirmLabel="Delete card"
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => void deleteCard()}
/>
</div>
);
}

View File

@@ -1,51 +0,0 @@
import { useEffect, useState } from "preact/hooks";
import type { Card } from "../types";
interface CardEditorProps {
card: Card;
onSave: (description: string) => Promise<void>;
onClose: () => void;
onDelete: () => void;
}
export function CardEditor({ card, onSave, onClose, onDelete }: CardEditorProps) {
const [description, setDescription] = useState(card.description);
const [saving, setSaving] = useState(false);
useEffect(() => setDescription(card.description), [card.id, card.description]);
const save = async () => {
setSaving(true);
try {
await onSave(description);
onClose();
} catch {
// App already surfaced an actionable toast and has rolled the state back.
} finally {
setSaving(false);
}
};
return (
<section class="card-editor" aria-label={"Edit " + card.title}>
<label>
Notes
<textarea
value={description}
maxlength={10000}
rows={4}
placeholder="Add useful context…"
onInput={(event) => setDescription(event.currentTarget.value)}
/>
</label>
<div class="editor-actions">
<button type="button" class="button button-primary" disabled={saving} onClick={() => void save()}>
{saving ? "Saving…" : "Save notes"}
</button>
<button type="button" class="button button-quiet" disabled={saving} onClick={onClose}>Close</button>
<button type="button" class="text-danger" disabled={saving} onClick={onDelete}>Delete card</button>
</div>
</section>
);
}

View File

@@ -1,113 +1,73 @@
import { useState } from "preact/hooks";
import { useRef, useState } from "preact/hooks";
import { beginCardDrag } from "../drag";
import type { Card } from "../types";
import { CardEditor } from "./CardEditor";
import { ConfirmDialog } from "./ConfirmDialog";
interface CardItemProps {
card: Card;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onDelete: () => Promise<void>;
onOpen: () => void;
}
export function CardItem({ card, onEdit, onDelete }: CardItemProps) {
const [editingTitle, setEditingTitle] = useState(false);
const [title, setTitle] = useState(card.title);
const [expanded, setExpanded] = useState(false);
const [confirming, setConfirming] = useState(false);
export function CardItem({ card, onEdit, onOpen }: CardItemProps) {
const [saving, setSaving] = useState(false);
const didDrag = useRef(false);
const saveTitle = async () => {
if (!title.trim()) return;
const toggleDone = async () => {
setSaving(true);
try {
await onEdit({ title });
setEditingTitle(false);
await onEdit({ done: !card.done });
} catch {
setTitle(card.title);
// The app rolls back the optimistic change and shows an error toast.
} finally {
setSaving(false);
}
};
const remove = async () => {
setConfirming(false);
try {
await onDelete();
} catch {
// The global mutation handler reports the failure.
const openIfClicked = () => {
if (didDrag.current) {
didDrag.current = false;
return;
}
onOpen();
};
return (
<>
<article
class={"card " + (card.done ? "card-done" : "")}
draggable={!editingTitle}
onDragStart={(event) => beginCardDrag(event as unknown as DragEvent, card)}
>
<div class="card-topline">
<button
type="button"
class={"done-button " + (card.done ? "is-done" : "")}
aria-label={card.done ? "Mark card active" : "Mark card done"}
title={card.done ? "Mark active" : "Mark done"}
disabled={saving}
onClick={() => void onEdit({ done: !card.done })}
>
{card.done ? "✓" : ""}
</button>
<div class="card-title-wrap">
{editingTitle ? (
<input
class="card-title-input"
value={title}
maxlength={240}
aria-label="Card title"
autoFocus
onInput={(event) => setTitle(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void saveTitle();
if (event.key === "Escape") {
setTitle(card.title);
setEditingTitle(false);
}
}}
onBlur={() => void saveTitle()}
/>
) : (
<button type="button" class="card-title" onClick={() => setEditingTitle(true)}>{card.title}</button>
)}
</div>
<button
type="button"
class="card-menu"
title="Open card details"
aria-label="Open card details"
onClick={() => setExpanded((value) => !value)}
>
</button>
</div>
{card.description && !expanded ? <p class="card-preview">{card.description}</p> : null}
{expanded ? (
<CardEditor
card={card}
onSave={(description) => onEdit({ description })}
onClose={() => setExpanded(false)}
onDelete={() => setConfirming(true)}
/>
) : null}
</article>
<ConfirmDialog
open={confirming}
title="Delete this card?"
detail={"“" + card.title + "” will be permanently removed."}
confirmLabel="Delete card"
onCancel={() => setConfirming(false)}
onConfirm={() => void remove()}
/>
</>
<article
class={"card " + (card.done ? "card-done" : "")}
draggable
tabIndex={0}
onClick={openIfClicked}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openIfClicked();
}
}}
onDragStart={(event) => {
didDrag.current = true;
beginCardDrag(event as unknown as DragEvent, card);
}}
onDragEnd={() => window.setTimeout(() => { didDrag.current = false; }, 0)}
>
<div class="card-topline">
<button
type="button"
class={"done-button " + (card.done ? "is-done" : "")}
aria-label={card.done ? "Mark card active" : "Mark card done"}
title={card.done ? "Mark active" : "Mark done"}
disabled={saving}
onClick={(event) => {
event.stopPropagation();
void toggleDone();
}}
>
{card.done ? "✓" : ""}
</button>
<span class="card-grip" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></span>
<h3 class="card-title">{card.title}</h3>
</div>
{card.description ? <p class="card-preview">{card.description}</p> : null}
</article>
);
}

View File

@@ -1,9 +1,11 @@
interface HeaderProps {
loading: boolean;
onReload: () => void;
managingLists: boolean;
onToggleManagingLists: () => void;
}
export function Header({ loading, onReload }: HeaderProps) {
export function Header({ loading, onReload, managingLists, onToggleManagingLists }: HeaderProps) {
return (
<header class="topbar">
<div class="brand" aria-label="Havenllo">
@@ -14,17 +16,26 @@ export function Header({ loading, onReload }: HeaderProps) {
<span class="eyebrow">Haven homelab</span>
<strong>My board</strong>
</div>
<button
type="button"
class="icon-button refresh-button"
title="Refresh board"
aria-label="Refresh board"
disabled={loading}
onClick={onReload}
>
<span aria-hidden="true"></span>
</button>
<div class="topbar-actions">
<button
type="button"
class={"header-edit-button " + (managingLists ? "is-active" : "")}
aria-pressed={managingLists}
onClick={onToggleManagingLists}
>
<span aria-hidden="true"></span> {managingLists ? "Done" : "Edit"}
</button>
<button
type="button"
class="icon-button"
title="Refresh board"
aria-label="Refresh board"
disabled={loading}
onClick={onReload}
>
<span aria-hidden="true"></span>
</button>
</div>
</header>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { cardDragType, readCardDrag } from "../drag";
import type { Card, ListWithCards } from "../types";
import { CardItem } from "./CardItem";
@@ -6,12 +6,13 @@ import { ConfirmDialog } from "./ConfirmDialog";
interface ListColumnProps {
list: ListWithCards;
managingLists: boolean;
onEditList: (patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: () => Promise<void>;
onCreateCard: (title: string) => Promise<void>;
onEditCard: (cardID: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onDeleteCard: (cardID: number) => Promise<void>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void;
}
interface DropZoneProps {
@@ -49,8 +50,9 @@ export function ListColumn({
onDeleteList,
onCreateCard,
onEditCard,
onDeleteCard,
onMoveCard
onMoveCard,
onOpenCard,
managingLists
}: ListColumnProps) {
const [editingName, setEditingName] = useState(false);
const [name, setName] = useState(list.name);
@@ -59,6 +61,15 @@ export function ListColumn({
const [confirming, setConfirming] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!managingLists && editingName) {
setName(list.name);
setEditingName(false);
} else if (!editingName) {
setName(list.name);
}
}, [editingName, list.name, managingLists]);
const saveName = async () => {
if (!name.trim() || name.trim() === list.name) {
setName(list.name);
@@ -101,7 +112,7 @@ export function ListColumn({
return (
<>
<section class="list-column">
<section class={"list-column " + (managingLists ? "is-managing" : "")}>
<div class="list-header">
<div class="list-heading">
{editingName ? (
@@ -120,12 +131,14 @@ export function ListColumn({
}}
onBlur={() => void saveName()}
/>
) : (
) : managingLists ? (
<button type="button" class="list-name" onClick={() => setEditingName(true)}>{list.name}</button>
) : (
<h2 class="list-name-text">{list.name}</h2>
)}
<span class="count-badge">{list.cards.length}</span>
</div>
<button
{managingLists ? <button
type="button"
class="icon-button list-delete"
aria-label={"Delete " + list.name}
@@ -133,7 +146,7 @@ export function ListColumn({
onClick={() => setConfirming(true)}
>
×
</button>
</button> : null}
</div>
<div class="cards-stack">
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} />
@@ -142,12 +155,12 @@ export function ListColumn({
<CardItem
card={card}
onEdit={(patch) => onEditCard(card.id, patch)}
onDelete={() => onDeleteCard(card.id)}
onOpen={() => onOpenCard(card.id)}
/>
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
</div>
))}
{!list.cards.length ? <p class="list-empty">Drop a card here or add the first task.</p> : null}
{!list.cards.length ? <p class="list-empty">No cards yet. Drop one here or add the first task.</p> : null}
</div>
{addingCard ? (
<form
@@ -193,4 +206,3 @@ export function ListColumn({
</>
);
}

View File

@@ -3,21 +3,24 @@
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
background: #0a1020;
color: #e9effc;
--ink: #e9effc;
--muted: #97a6c1;
--faint: #63728d;
color: #edf3ff;
--canvas: #0a1020;
--canvas-glow: #101d36;
--surface: #111b2d;
--surface-raised: #17243a;
--surface-hover: #1c2c46;
--border: rgba(151, 174, 211, 0.16);
--border-strong: rgba(109, 211, 200, 0.42);
--teal: #62d5c7;
--teal-deep: #168c86;
--indigo: #8097ff;
--danger: #ff7888;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
--ink: #edf3ff;
--muted: #9aaac6;
--faint: #687895;
--border: rgba(160, 183, 220, 0.15);
--border-strong: rgba(112, 220, 207, 0.55);
--teal: #65dacb;
--teal-soft: rgba(101, 218, 203, 0.13);
--indigo: #91a4ff;
--danger: #ff7d90;
--danger-soft: rgba(255, 125, 144, 0.12);
--shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
--shadow-lifted: 0 22px 46px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
@@ -28,8 +31,8 @@ body {
min-width: 320px;
margin: 0;
background:
radial-gradient(circle at 85% -10%, rgba(72, 115, 195, 0.24), transparent 32rem),
radial-gradient(circle at 0% 100%, rgba(24, 119, 116, 0.15), transparent 26rem),
radial-gradient(circle at 88% -12%, rgba(80, 120, 211, 0.24), transparent 35rem),
radial-gradient(circle at -8% 88%, rgba(24, 132, 127, 0.16), transparent 29rem),
var(--canvas);
}
@@ -39,387 +42,550 @@ button { color: inherit; }
button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; }
button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 3px solid rgba(98, 213, 199, 0.65);
outline-offset: 2px;
button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible {
outline: 3px solid rgba(101, 218, 203, 0.7);
outline-offset: 3px;
}
button:disabled { cursor: not-allowed; opacity: 0.58; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.app-shell { min-height: 100vh; }
.topbar {
min-height: 72px;
display: flex;
align-items: center;
gap: 18px;
padding: 12px clamp(18px, 4vw, 52px);
border-bottom: 1px solid var(--border);
background: rgba(10, 16, 32, 0.78);
backdrop-filter: blur(14px);
position: sticky;
top: 0;
z-index: 10;
min-height: 74px;
display: flex;
align-items: center;
gap: 18px;
padding: 12px clamp(18px, 4vw, 56px);
border-bottom: 1px solid var(--border);
background: rgba(10, 16, 32, 0.8);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.015);
backdrop-filter: blur(16px);
}
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 1.28rem;
font-weight: 760;
letter-spacing: -0.045em;
color: var(--ink);
font-size: 1.3rem;
font-weight: 790;
letter-spacing: -0.05em;
}
.brand-mark {
width: 36px;
height: 36px;
display: grid;
width: 34px;
height: 34px;
place-items: center;
border-radius: 11px;
color: #09111e;
font-size: 1.06rem;
border-radius: 12px;
color: #09121f;
font-size: 1.1rem;
font-weight: 900;
background: linear-gradient(135deg, var(--teal), var(--indigo));
box-shadow: 0 6px 20px rgba(98, 213, 199, 0.24);
box-shadow: 0 7px 22px rgba(101, 218, 203, 0.23);
}
.topbar-board {
display: grid;
gap: 1px;
gap: 2px;
padding-left: 18px;
border-left: 1px solid var(--border);
}
.topbar-board strong { font-size: 0.92rem; }
.topbar-board strong { font-size: 0.91rem; }
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.eyebrow {
margin: 0;
color: var(--teal);
font-size: 0.69rem;
font-weight: 760;
letter-spacing: 0.1em;
font-size: 0.68rem;
font-weight: 780;
letter-spacing: 0.11em;
text-transform: uppercase;
}
.refresh-button { margin-left: auto; }
.header-edit-button, .icon-button {
min-height: 42px;
border: 1px solid var(--border);
cursor: pointer;
transition: transform 150ms ease, color 150ms ease, border-color 150ms ease, background 150ms ease, box-shadow 150ms ease;
}
.header-edit-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 12px;
border-radius: 11px;
color: var(--muted);
font-size: 0.83rem;
font-weight: 750;
background: rgba(17, 27, 45, 0.46);
}
.header-edit-button:hover, .header-edit-button.is-active {
color: var(--ink);
border-color: var(--border-strong);
background: var(--teal-soft);
}
.header-edit-button.is-active { color: var(--teal); box-shadow: inset 0 0 0 1px rgba(101, 218, 203, 0.1); }
.icon-button {
width: 40px;
height: 40px;
width: 42px;
padding: 0;
display: inline-grid;
place-items: center;
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
border-radius: 11px;
color: var(--muted);
font-size: 1.18rem;
background: transparent;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.icon-button:hover { color: var(--ink); border-color: var(--border-strong); background: var(--surface-hover); }
.icon-button:hover:not(:disabled) { color: var(--ink); border-color: var(--border-strong); background: var(--surface-hover); transform: translateY(-1px); }
.board-area { padding: clamp(24px, 4vw, 50px); }
.board-area { padding: clamp(26px, 4.2vw, 58px) clamp(18px, 4vw, 56px) 34px; }
.board-intro {
max-width: 1500px;
margin: 0 auto 24px;
max-width: 1530px;
margin: 0 auto 26px;
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
gap: 20px;
}
.board-intro h1 { margin: 4px 0 0; font-size: clamp(1.65rem, 3vw, 2.2rem); letter-spacing: -0.045em; }
.board-hint { max-width: 315px; margin: 0; color: var(--muted); font-size: 0.9rem; text-align: right; }
.board-intro h1 {
margin: 5px 0 0;
font-size: clamp(1.72rem, 3vw, 2.45rem);
letter-spacing: -0.055em;
line-height: 1;
}
.board-hint { max-width: 335px; margin: 0 2px 2px 0; color: var(--muted); font-size: 0.89rem; line-height: 1.5; text-align: right; }
.board-rail {
width: max-content;
min-width: 100%;
max-width: 1500px;
width: min(100%, 1530px);
min-height: 320px;
margin: 0 auto;
display: flex;
align-items: flex-start;
gap: 16px;
padding-bottom: 34px;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding: 2px 2px 28px;
scrollbar-color: rgba(151, 174, 211, 0.32) transparent;
}
.board-rail.is-managing { padding-top: 5px; }
.list-column, .add-list-column {
width: min(310px, calc(100vw - 48px));
flex: 0 0 310px;
width: min(318px, calc(100vw - 48px));
flex: 0 0 318px;
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(17, 27, 45, 0.84);
border-radius: 18px;
background: linear-gradient(155deg, rgba(23, 36, 58, 0.9), rgba(14, 24, 41, 0.89));
box-shadow: var(--shadow);
}
.list-column { padding: 14px; }
.list-column {
padding: 14px;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.list-header, .list-heading, .card-topline, .editor-actions, .quick-add-actions, .dialog-actions {
.list-column:hover { border-color: rgba(160, 183, 220, 0.26); box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28); }
.list-column.is-managing { border-color: rgba(101, 218, 203, 0.34); box-shadow: 0 0 0 1px rgba(101, 218, 203, 0.07), var(--shadow); }
.list-header, .list-heading, .card-topline, .quick-add-actions, .dialog-actions, .detail-header, .detail-title-row, .detail-section-heading, .detail-save-row, .detail-footer {
display: flex;
align-items: center;
}
.list-header { justify-content: space-between; gap: 8px; padding: 0 1px 9px; }
.list-header { justify-content: space-between; gap: 8px; padding: 1px 1px 11px; }
.list-heading { min-width: 0; gap: 8px; }
.list-name, .card-title {
.list-name, .list-name-text {
min-width: 0;
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
color: var(--ink);
font-weight: 740;
font-size: 1rem;
font-weight: 760;
letter-spacing: -0.018em;
line-height: 1.35;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
background: transparent;
}
.list-name { font-size: 1rem; }
.list-heading input { width: 190px; }
.list-name { cursor: text; }
.list-name:hover { color: var(--teal); }
.list-heading input { width: 194px; }
.count-badge {
min-width: 23px;
padding: 2px 7px;
min-width: 24px;
padding: 3px 7px;
border-radius: 999px;
color: var(--muted);
font-size: 0.75rem;
font-size: 0.73rem;
font-variant-numeric: tabular-nums;
text-align: center;
background: rgba(126, 151, 190, 0.12);
background: rgba(140, 164, 201, 0.13);
}
.list-delete { width: 30px; height: 30px; border-color: transparent; font-size: 1.25rem; }
.list-delete:hover { color: var(--danger); border-color: rgba(255, 120, 136, 0.25); }
.cards-stack { min-height: 40px; }
.list-delete { width: 34px; min-height: 34px; border-color: transparent; color: var(--faint); }
.list-delete:hover:not(:disabled) { color: var(--danger); border-color: rgba(255, 125, 144, 0.35); background: var(--danger-soft); }
.cards-stack { min-height: 48px; }
.card-slot { position: relative; }
.drop-zone {
height: 8px;
margin: 1px 0;
border-radius: 6px;
transition: height 120ms ease, background 120ms ease;
border-radius: 7px;
transition: height 140ms ease, background 140ms ease, outline-color 140ms ease;
}
.drop-zone.is-active { height: 22px; background: rgba(98, 213, 199, 0.2); outline: 1px dashed var(--teal); }
.drop-zone.is-active { height: 28px; background: rgba(101, 218, 203, 0.16); outline: 1px dashed var(--teal); }
.card {
padding: 11px;
border: 1px solid rgba(161, 185, 220, 0.16);
border-radius: 12px;
background: linear-gradient(145deg, rgba(28, 44, 70, 0.94), rgba(18, 30, 49, 0.94));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
padding: 12px;
border: 1px solid rgba(169, 192, 225, 0.15);
border-radius: 13px;
color: var(--ink);
cursor: grab;
background: linear-gradient(145deg, rgba(31, 48, 76, 0.96), rgba(18, 30, 49, 0.97));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, opacity 150ms ease;
}
.card:active { cursor: grabbing; }
.card:hover { border-color: rgba(151, 174, 211, 0.32); }
.card-done { opacity: 0.7; }
.card:hover { border-color: rgba(128, 205, 209, 0.43); background: linear-gradient(145deg, rgba(35, 54, 84, 0.98), rgba(20, 34, 55, 0.98)); box-shadow: var(--shadow-lifted); transform: translateY(-2px); }
.card:active { cursor: grabbing; transform: scale(0.987); }
.card:focus-visible { outline: 3px solid rgba(101, 218, 203, 0.7); outline-offset: 3px; }
.card-done { opacity: 0.72; }
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
.card-topline { align-items: flex-start; gap: 8px; }
.card-title-wrap { min-width: 0; flex: 1; }
.card-title { width: 100%; line-height: 1.35; white-space: normal; }
.card-title-input { width: 100%; padding: 2px 4px; }
.done-button {
flex: 0 0 auto;
width: 21px;
height: 21px;
width: 24px;
min-height: 24px;
margin-top: 1px;
padding: 0;
border: 1.5px solid var(--faint);
border-radius: 7px;
color: #081320;
border-radius: 8px;
color: #07131e;
cursor: pointer;
font-size: 0.82rem;
font-weight: 900;
background: transparent;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
}
.done-button:hover:not(:disabled) { border-color: var(--teal); transform: scale(1.08); }
.done-button.is-done { border-color: var(--teal); background: var(--teal); }
.card-menu { padding: 0 2px 4px; border: 0; color: var(--faint); font-weight: 800; letter-spacing: 0.08em; cursor: pointer; background: transparent; }
.card-menu:hover { color: var(--ink); }
.card-grip {
flex: 0 0 auto;
display: grid;
grid-template-columns: repeat(2, 2px);
gap: 2px;
margin-top: 7px;
opacity: 0.42;
transition: opacity 140ms ease;
}
.card:hover .card-grip { opacity: 0.9; }
.card-grip i { width: 2px; height: 2px; border-radius: 50%; background: var(--muted); }
.card-title { flex: 1; min-width: 0; margin: 0; font-size: 0.92rem; font-weight: 720; letter-spacing: -0.012em; line-height: 1.4; }
.card-preview {
display: -webkit-box;
margin: 8px 1px 0 29px;
margin: 9px 1px 0 34px;
overflow: hidden;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.38;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.card-editor {
margin-top: 11px;
padding-top: 10px;
border-top: 1px solid var(--border);
}
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.76rem; font-weight: 650; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.77rem; font-weight: 680; }
input, textarea {
width: 100%;
padding: 9px 10px;
border: 1px solid rgba(151, 174, 211, 0.27);
border-radius: 9px;
padding: 10px 11px;
border: 1px solid rgba(151, 174, 211, 0.28);
border-radius: 10px;
color: var(--ink);
line-height: 1.35;
background: rgba(7, 14, 27, 0.6);
line-height: 1.4;
background: rgba(6, 13, 27, 0.62);
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
}
textarea { min-height: 82px; resize: vertical; }
.editor-actions { flex-wrap: wrap; gap: 7px; margin-top: 9px; }
input:hover, textarea:hover { border-color: rgba(151, 174, 211, 0.45); }
input:focus, textarea:focus { border-color: var(--teal); background: rgba(7, 15, 29, 0.88); }
textarea { min-height: 96px; resize: vertical; }
.button {
min-height: 36px;
padding: 7px 12px;
min-height: 38px;
padding: 8px 13px;
border: 1px solid transparent;
border-radius: 9px;
border-radius: 10px;
font-size: 0.82rem;
font-weight: 720;
font-weight: 740;
cursor: pointer;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
transition: transform 130ms ease, background 130ms ease, border-color 130ms ease, box-shadow 130ms ease;
}
.button:hover:not(:disabled) { transform: translateY(-1px); }
.button-primary { color: #08131e; background: linear-gradient(135deg, var(--teal), #75c9d8); }
.button-primary { color: #07151d; background: linear-gradient(135deg, var(--teal), #84cfdb); box-shadow: 0 7px 18px rgba(67, 191, 183, 0.18); }
.button-primary:hover:not(:disabled) { box-shadow: 0 10px 22px rgba(67, 191, 183, 0.28); }
.button-secondary { border-color: var(--border); color: var(--ink); background: var(--surface-hover); }
.button-danger { color: #fff1f3; background: #b84155; }
.button-danger { color: #fff3f5; background: #b64156; }
.button-quiet { color: var(--muted); background: transparent; }
.button-quiet:hover:not(:disabled) { color: var(--ink); background: rgba(151, 174, 211, 0.09); }
.text-danger { margin-left: auto; padding: 5px; border: 0; color: var(--danger); font-size: 0.77rem; cursor: pointer; background: transparent; }
.text-danger:hover { text-decoration: underline; }
.text-danger { padding: 7px; border: 0; color: var(--danger); font-size: 0.8rem; font-weight: 700; cursor: pointer; background: transparent; }
.text-danger:hover:not(:disabled) { color: #ffacb7; text-decoration: underline; }
.quick-add { display: grid; gap: 9px; padding-top: 10px; }
.quick-add-actions { gap: 6px; }
.quick-add { display: grid; gap: 8px; padding-top: 10px; }
.quick-add-actions { gap: 5px; }
.add-card-button, .add-list-button {
width: 100%;
min-height: 42px;
min-height: 44px;
display: flex;
align-items: center;
gap: 5px;
padding: 8px;
gap: 6px;
padding: 9px;
border: 1px dashed transparent;
border-radius: 9px;
border-radius: 10px;
color: var(--muted);
font-size: 0.83rem;
font-weight: 680;
text-align: left;
cursor: pointer;
background: transparent;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
}
.add-card-button { margin-top: 8px; }
.add-card-button:hover, .add-list-button:hover { border-color: var(--border-strong); color: var(--teal); background: rgba(98, 213, 199, 0.06); }
.add-card-button:hover, .add-list-button:hover { border-color: var(--border-strong); color: var(--teal); background: var(--teal-soft); }
.add-list-column { padding: 8px; border-style: dashed; box-shadow: none; background: rgba(17, 27, 45, 0.42); }
.add-list-button { min-height: 46px; }
.add-list-column {
min-height: 76px;
padding: 8px;
border-style: dashed;
border-color: rgba(160, 183, 220, 0.22);
box-shadow: none;
background: rgba(17, 27, 45, 0.38);
}
.list-empty { margin: 12px 5px; color: var(--faint); font-size: 0.78rem; line-height: 1.45; text-align: center; }
.add-list-column .quick-add { padding-top: 0; }
.add-list-button { min-height: 58px; justify-content: center; text-align: center; }
.add-list-button span, .add-card-button span { color: var(--teal); font-size: 1.12rem; line-height: 1; }
.loading-card, .fatal-state {
max-width: 520px;
margin: 80px auto;
padding: 28px;
.list-empty { margin: 14px 6px; color: var(--faint); font-size: 0.79rem; line-height: 1.5; text-align: center; }
.empty-board, .loading-card, .fatal-state {
max-width: 550px;
margin: 86px auto;
padding: 30px;
border: 1px solid var(--border);
border-radius: 16px;
border-radius: 20px;
color: var(--muted);
text-align: center;
background: var(--surface);
background: linear-gradient(145deg, rgba(23, 36, 58, 0.94), rgba(14, 24, 41, 0.94));
box-shadow: var(--shadow);
}
.loading-card::after { content: ""; display: block; width: 52%; height: 6px; margin: 17px auto 0; border-radius: 99px; background: linear-gradient(90deg, transparent, var(--teal), transparent); animation: shimmer 1.25s infinite; }
.fatal-state h1 { margin-top: 0; color: var(--ink); font-size: 1.25rem; }
.empty-board h2, .fatal-state h1 { margin: 12px 0 7px; color: var(--ink); font-size: 1.28rem; letter-spacing: -0.025em; }
.empty-board p, .fatal-state p { margin: 0; line-height: 1.5; }
.empty-state-mark { display: grid; width: 42px; height: 42px; margin: 0 auto; place-items: center; border-radius: 14px; color: #07131e; background: linear-gradient(135deg, var(--teal), var(--indigo)); }
.dialog-backdrop {
.loading-card::after { content: ""; display: block; width: 52%; height: 6px; margin: 18px auto 0; border-radius: 99px; background: linear-gradient(90deg, transparent, var(--teal), transparent); animation: shimmer 1.25s infinite; }
.detail-backdrop, .dialog-backdrop {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 18px;
background: rgba(2, 7, 15, 0.7);
backdrop-filter: blur(3px);
background: rgba(2, 7, 15, 0.72);
backdrop-filter: blur(5px);
}
.detail-backdrop { z-index: 30; animation: overlay-in 160ms ease-out; }
.card-detail {
width: min(100%, 760px);
max-height: min(800px, calc(100vh - 36px));
overflow: auto;
border: 1px solid rgba(163, 190, 231, 0.27);
border-radius: 22px;
background: linear-gradient(150deg, #192842, #111d32);
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.58);
animation: modal-in 180ms ease-out;
}
.detail-header { justify-content: space-between; gap: 14px; padding: 17px 18px 0; }
.detail-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--faint); font-size: 0.77rem; font-weight: 680; }
.detail-breadcrumb strong { color: var(--teal); font-weight: 760; }
.detail-close { flex: 0 0 auto; width: 38px; min-height: 38px; border-color: transparent; font-size: 1.38rem; }
.detail-content { padding: 8px clamp(19px, 4vw, 34px) 26px; }
.detail-title-row { align-items: flex-start; gap: 10px; }
.detail-grip { padding-top: 13px; color: var(--faint); font-size: 1.1rem; letter-spacing: -0.2em; }
.detail-title-input {
padding: 5px 0 8px;
border: 1px solid transparent;
border-radius: 9px;
color: var(--ink);
font-size: clamp(1.48rem, 3.3vw, 2rem);
font-weight: 770;
letter-spacing: -0.048em;
line-height: 1.16;
background: transparent;
}
.detail-title-input:hover { border-color: rgba(151, 174, 211, 0.24); background: rgba(7, 14, 27, 0.2); }
.detail-title-input:focus { padding-right: 8px; padding-left: 8px; border-color: var(--teal); background: rgba(7, 14, 27, 0.48); }
.detail-properties {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin: 18px 0 26px 27px;
}
.detail-property { min-height: 66px; display: grid; align-content: center; gap: 5px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: rgba(5, 12, 26, 0.22); }
.detail-property > span { color: var(--faint); font-size: 0.69rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; }
.detail-property strong { overflow: hidden; color: var(--ink); font-size: 0.87rem; text-overflow: ellipsis; white-space: nowrap; }
.detail-status { display: flex; align-content: initial; align-items: center; justify-content: space-between; gap: 8px; }
.status-pill {
min-height: 32px;
padding: 5px 9px;
border: 1px solid rgba(145, 164, 255, 0.32);
border-radius: 999px;
color: #cdd5ff;
cursor: pointer;
font-size: 0.75rem;
font-weight: 740;
background: rgba(145, 164, 255, 0.1);
transition: transform 140ms ease, border-color 140ms ease, color 140ms ease, background 140ms ease;
}
.status-pill:hover:not(:disabled) { transform: translateY(-1px); border-color: var(--teal); color: var(--teal); }
.status-pill.is-done { border-color: rgba(101, 218, 203, 0.45); color: #07161d; background: var(--teal); }
.detail-description { margin-left: 27px; }
.detail-section-heading { justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.detail-section-heading h2 { margin: 3px 0 0; color: var(--ink); font-size: 1.03rem; letter-spacing: -0.02em; }
.unsaved-chip { padding: 4px 8px; border: 1px solid rgba(145, 164, 255, 0.34); border-radius: 999px; color: #cbd4ff; font-size: 0.69rem; font-weight: 720; background: rgba(145, 164, 255, 0.11); }
.detail-description textarea { min-height: 150px; }
.detail-save-row { justify-content: space-between; gap: 16px; margin-top: 9px; color: var(--faint); font-size: 0.74rem; line-height: 1.4; }
.detail-save-row .button { flex: 0 0 auto; }
.detail-footer { justify-content: space-between; gap: 18px; margin: 28px 0 0 27px; padding-top: 18px; border-top: 1px solid var(--border); }
.detail-timestamps { display: flex; flex-wrap: wrap; gap: 16px; margin: 0; }
.detail-timestamps div { display: grid; gap: 3px; }
.detail-timestamps dt { color: var(--faint); font-size: 0.67rem; font-weight: 740; letter-spacing: 0.075em; text-transform: uppercase; }
.detail-timestamps dd { margin: 0; color: var(--muted); font-size: 0.75rem; }
.detail-delete { flex: 0 0 auto; }
.dialog-backdrop { z-index: 45; }
.confirm-dialog {
width: min(100%, 410px);
width: min(100%, 420px);
padding: 24px;
border: 1px solid rgba(151, 174, 211, 0.28);
border-radius: 16px;
background: #14213a;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(163, 190, 231, 0.3);
border-radius: 17px;
background: #172640;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.55);
animation: modal-in 160ms ease-out;
}
.confirm-dialog h2 { margin: 0; font-size: 1.15rem; }
.confirm-dialog p { margin: 10px 0 22px; color: var(--muted); line-height: 1.45; }
.confirm-dialog h2 { margin: 0; color: var(--ink); font-size: 1.16rem; letter-spacing: -0.02em; }
.confirm-dialog p { margin: 10px 0 23px; color: var(--muted); line-height: 1.5; }
.dialog-actions { justify-content: flex-end; gap: 8px; }
.toast-stack {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 40;
z-index: 50;
display: grid;
gap: 9px;
width: min(350px, calc(100vw - 36px));
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 12px 11px 14px;
border: 1px solid var(--border);
border-left: 3px solid var(--teal);
border-radius: 11px;
color: var(--ink);
background: #17243a;
box-shadow: var(--shadow);
animation: toast-in 180ms ease-out;
width: min(360px, calc(100vw - 36px));
}
.toast { display: flex; align-items: center; gap: 12px; padding: 12px 12px 12px 14px; border: 1px solid var(--border); border-left: 3px solid var(--teal); border-radius: 12px; color: var(--ink); background: #182741; box-shadow: var(--shadow); animation: toast-in 180ms ease-out; }
.toast-error { border-left-color: var(--danger); }
.toast-info { border-left-color: var(--indigo); }
.toast span { flex: 1; font-size: 0.86rem; }
.toast button { width: 26px; height: 26px; border: 0; border-radius: 6px; color: var(--muted); font-size: 1.2rem; cursor: pointer; background: transparent; }
.toast button:hover { color: var(--ink); background: rgba(255,255,255,0.07); }
.toast span { flex: 1; font-size: 0.86rem; line-height: 1.35; }
.toast button { width: 28px; min-height: 28px; padding: 0; border: 0; border-radius: 7px; color: var(--muted); font-size: 1.2rem; cursor: pointer; background: transparent; }
.toast button:hover { color: var(--ink); background: rgba(255, 255, 255, 0.07); }
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@keyframes toast-in { from { opacity: 0; transform: translateY(9px); } to { opacity: 1; transform: translateY(0); } }
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in { from { opacity: 0; transform: translateY(9px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@media (max-width: 700px) {
.topbar { min-height: 66px; padding: 10px 16px; gap: 12px; }
.topbar-board { display: none; }
.header-edit-button { min-width: 44px; padding: 8px 10px; }
.board-area { padding: 25px 16px 26px; overflow: hidden; }
.board-intro { align-items: flex-start; margin-bottom: 19px; }
.board-hint { display: none; }
.board-rail { width: calc(100% + 16px); margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
.list-column, .add-list-column { scroll-snap-align: start; }
.card-detail { max-height: calc(100vh - 20px); border-radius: 18px; }
.detail-backdrop, .dialog-backdrop { padding: 10px; }
.detail-header { padding: 13px 13px 0; }
.detail-content { padding: 6px 18px 20px; }
.detail-properties { grid-template-columns: 1fr; margin-left: 0; }
.detail-description, .detail-footer { margin-left: 0; }
.detail-footer { align-items: flex-start; flex-direction: column; gap: 12px; }
.detail-delete { padding-left: 0; }
.detail-save-row { align-items: flex-start; flex-direction: column; gap: 9px; }
.detail-save-row .button { width: 100%; min-height: 44px; }
}
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
@media (max-width: 640px) {
.topbar { min-height: 64px; padding: 10px 16px; }
.topbar-board { display: none; }
.board-area { padding: 24px 16px; overflow: hidden; }
.board-intro { align-items: flex-start; margin-bottom: 18px; }
.board-hint { display: none; }
.board-rail { overflow-x: auto; margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
.list-column, .add-list-column { scroll-snap-align: start; }
.button, .icon-button, .add-card-button { min-height: 44px; }
.icon-button { width: 44px; }
@media (max-width: 430px) {
.brand { font-size: 1.18rem; }
.header-edit-button { font-size: 0; }
.header-edit-button span { font-size: 1rem; }
.list-column, .add-list-column { width: calc(100vw - 48px); flex-basis: calc(100vw - 48px); }
.detail-timestamps { display: grid; gap: 9px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
}