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/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/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/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/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, and reload button. - `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`: horizontally-scrolling list rail, inline "add list" affordance, and the empty/loading states. Maps lists to `ListColumn`. - `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 — 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/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 — click-to-edit title, done toggle, expand to `CardEditor`, and 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/CardEditor.tsx`: description editor and destructive (delete) action for a card. - `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/ConfirmDialog.tsx`: accessible confirmation dialog used before any delete.
- `web/src/components/Toast.tsx`: non-blocking toast notifications (auto-dismiss ~4.4s). - `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 ### 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). - 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. - 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. - 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. - 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 { api, APIError } from "./api";
import { import {
addCard, addCard,
@@ -14,6 +14,7 @@ import {
import { movePosition } from "./drag"; import { movePosition } from "./drag";
import type { CardPatch, ListPatch } from "./types"; import type { CardPatch, ListPatch } from "./types";
import { BoardCanvas } from "./components/BoardCanvas"; import { BoardCanvas } from "./components/BoardCanvas";
import { CardDetailModal } from "./components/CardDetailModal";
import { Header } from "./components/Header"; import { Header } from "./components/Header";
import { Toasts } from "./components/Toast"; import { Toasts } from "./components/Toast";
@@ -27,9 +28,18 @@ function friendlyError(error: unknown): string {
export function App() { export function App() {
const [state, dispatch] = useReducer(boardReducer, initialBoardState); const [state, dispatch] = useReducer(boardReducer, initialBoardState);
const [managingLists, setManagingLists] = useState(false);
const [selectedCardID, setSelectedCardID] = useState<number | null>(null);
const boardRef = useRef(state.board); const boardRef = useRef(state.board);
boardRef.current = 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 toast = useCallback((message: string, tone: "success" | "error" | "info" = "info") => {
const id = ++toastID; const id = ++toastID;
dispatch({ type: "toast", toast: { id, message, tone } }); dispatch({ type: "toast", toast: { id, message, tone } });
@@ -141,7 +151,12 @@ export function App() {
return ( return (
<main class="app-shell"> <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.loading && !state.board ? <section class="loading-card" aria-live="polite">Loading your Haven board</section> : null}
{state.error ? ( {state.error ? (
<section class="fatal-state"> <section class="fatal-state">
@@ -158,12 +173,21 @@ export function App() {
onDeleteList={deleteList} onDeleteList={deleteList}
onCreateCard={createCard} onCreateCard={createCard}
onEditCard={editCard} onEditCard={editCard}
onDeleteCard={deleteCard}
onMoveCard={moveCard} 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} ) : null}
<Toasts toasts={state.toasts} onDismiss={(id) => dispatch({ type: "dismiss", id })} /> <Toasts toasts={state.toasts} onDismiss={(id) => dispatch({ type: "dismiss", id })} />
</main> </main>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import type { ListWithCards } from "../types"; import type { ListWithCards } from "../types";
import { ListColumn } from "./ListColumn"; import { ListColumn } from "./ListColumn";
@@ -9,8 +9,9 @@ interface BoardCanvasProps {
onDeleteList: (id: number) => Promise<void>; onDeleteList: (id: number) => Promise<void>;
onCreateCard: (listID: number, title: string) => Promise<void>; onCreateCard: (listID: number, title: string) => Promise<void>;
onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => 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>; onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void;
managingLists: boolean;
} }
export function BoardCanvas(props: BoardCanvasProps) { export function BoardCanvas(props: BoardCanvasProps) {
@@ -18,6 +19,13 @@ export function BoardCanvas(props: BoardCanvasProps) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
useEffect(() => {
if (!props.managingLists) {
setAdding(false);
setName("");
}
}, [props.managingLists]);
const addList = async () => { const addList = async () => {
if (!name.trim()) return; if (!name.trim()) return;
setSaving(true); setSaving(true);
@@ -39,22 +47,26 @@ export function BoardCanvas(props: BoardCanvasProps) {
<p class="eyebrow">One calm place for your homelab work</p> <p class="eyebrow">One calm place for your homelab work</p>
<h1>Haven board</h1> <h1>Haven board</h1>
</div> </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>
<div class="board-rail"> {props.lists.length ? (
{props.lists.map((list) => ( <div class={"board-rail " + (props.managingLists ? "is-managing" : "")}>
<ListColumn {props.lists.map((list) => (
key={list.id} <ListColumn
list={list} key={list.id}
onEditList={(patch) => props.onEditList(list.id, patch)} list={list}
onDeleteList={() => props.onDeleteList(list.id)} managingLists={props.managingLists}
onCreateCard={(title) => props.onCreateCard(list.id, title)} onEditList={(patch) => props.onEditList(list.id, patch)}
onEditCard={props.onEditCard} onDeleteList={() => props.onDeleteList(list.id)}
onDeleteCard={props.onDeleteCard} onCreateCard={(title) => props.onCreateCard(list.id, title)}
onMoveCard={props.onMoveCard} onEditCard={props.onEditCard}
/> onMoveCard={props.onMoveCard}
))} onOpenCard={props.onOpenCard}
<section class="add-list-column"> />
))}
{props.managingLists ? <section class="add-list-column">
{adding ? ( {adding ? (
<form <form
class="quick-add" class="quick-add"
@@ -85,11 +97,18 @@ export function BoardCanvas(props: BoardCanvasProps) {
</form> </form>
) : ( ) : (
<button type="button" class="add-list-button" onClick={() => setAdding(true)}> <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> </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> </section>
</div> )}
</section> </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 { beginCardDrag } from "../drag";
import type { Card } from "../types"; import type { Card } from "../types";
import { CardEditor } from "./CardEditor";
import { ConfirmDialog } from "./ConfirmDialog";
interface CardItemProps { interface CardItemProps {
card: Card; card: Card;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>; onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onDelete: () => Promise<void>; onOpen: () => void;
} }
export function CardItem({ card, onEdit, onDelete }: CardItemProps) { export function CardItem({ card, onEdit, onOpen }: CardItemProps) {
const [editingTitle, setEditingTitle] = useState(false);
const [title, setTitle] = useState(card.title);
const [expanded, setExpanded] = useState(false);
const [confirming, setConfirming] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const didDrag = useRef(false);
const saveTitle = async () => { const toggleDone = async () => {
if (!title.trim()) return;
setSaving(true); setSaving(true);
try { try {
await onEdit({ title }); await onEdit({ done: !card.done });
setEditingTitle(false);
} catch { } catch {
setTitle(card.title); // The app rolls back the optimistic change and shows an error toast.
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const remove = async () => { const openIfClicked = () => {
setConfirming(false); if (didDrag.current) {
try { didDrag.current = false;
await onDelete(); return;
} catch {
// The global mutation handler reports the failure.
} }
onOpen();
}; };
return ( return (
<> <article
<article class={"card " + (card.done ? "card-done" : "")}
class={"card " + (card.done ? "card-done" : "")} draggable
draggable={!editingTitle} tabIndex={0}
onDragStart={(event) => beginCardDrag(event as unknown as DragEvent, card)} onClick={openIfClicked}
> onKeyDown={(event) => {
<div class="card-topline"> if (event.target !== event.currentTarget) return;
<button if (event.key === "Enter" || event.key === " ") {
type="button" event.preventDefault();
class={"done-button " + (card.done ? "is-done" : "")} openIfClicked();
aria-label={card.done ? "Mark card active" : "Mark card done"} }
title={card.done ? "Mark active" : "Mark done"} }}
disabled={saving} onDragStart={(event) => {
onClick={() => void onEdit({ done: !card.done })} didDrag.current = true;
> beginCardDrag(event as unknown as DragEvent, card);
{card.done ? "✓" : ""} }}
</button> onDragEnd={() => window.setTimeout(() => { didDrag.current = false; }, 0)}
<div class="card-title-wrap"> >
{editingTitle ? ( <div class="card-topline">
<input <button
class="card-title-input" type="button"
value={title} class={"done-button " + (card.done ? "is-done" : "")}
maxlength={240} aria-label={card.done ? "Mark card active" : "Mark card done"}
aria-label="Card title" title={card.done ? "Mark active" : "Mark done"}
autoFocus disabled={saving}
onInput={(event) => setTitle(event.currentTarget.value)} onClick={(event) => {
onKeyDown={(event) => { event.stopPropagation();
if (event.key === "Enter") void saveTitle(); void toggleDone();
if (event.key === "Escape") { }}
setTitle(card.title); >
setEditingTitle(false); {card.done ? "✓" : ""}
} </button>
}} <span class="card-grip" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></span>
onBlur={() => void saveTitle()} <h3 class="card-title">{card.title}</h3>
/> </div>
) : ( {card.description ? <p class="card-preview">{card.description}</p> : null}
<button type="button" class="card-title" onClick={() => setEditingTitle(true)}>{card.title}</button> </article>
)}
</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()}
/>
</>
); );
} }

View File

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

View File

@@ -1,4 +1,4 @@
import { useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { cardDragType, readCardDrag } from "../drag"; import { cardDragType, readCardDrag } from "../drag";
import type { Card, ListWithCards } from "../types"; import type { Card, ListWithCards } from "../types";
import { CardItem } from "./CardItem"; import { CardItem } from "./CardItem";
@@ -6,12 +6,13 @@ import { ConfirmDialog } from "./ConfirmDialog";
interface ListColumnProps { interface ListColumnProps {
list: ListWithCards; list: ListWithCards;
managingLists: boolean;
onEditList: (patch: { name?: string; position?: number }) => Promise<void>; onEditList: (patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: () => Promise<void>; onDeleteList: () => Promise<void>;
onCreateCard: (title: string) => Promise<void>; onCreateCard: (title: string) => Promise<void>;
onEditCard: (cardID: number, patch: { title?: string; description?: string; done?: boolean }) => 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>; onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void;
} }
interface DropZoneProps { interface DropZoneProps {
@@ -49,8 +50,9 @@ export function ListColumn({
onDeleteList, onDeleteList,
onCreateCard, onCreateCard,
onEditCard, onEditCard,
onDeleteCard, onMoveCard,
onMoveCard onOpenCard,
managingLists
}: ListColumnProps) { }: ListColumnProps) {
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [name, setName] = useState(list.name); const [name, setName] = useState(list.name);
@@ -59,6 +61,15 @@ export function ListColumn({
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [saving, setSaving] = 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 () => { const saveName = async () => {
if (!name.trim() || name.trim() === list.name) { if (!name.trim() || name.trim() === list.name) {
setName(list.name); setName(list.name);
@@ -101,7 +112,7 @@ export function ListColumn({
return ( return (
<> <>
<section class="list-column"> <section class={"list-column " + (managingLists ? "is-managing" : "")}>
<div class="list-header"> <div class="list-header">
<div class="list-heading"> <div class="list-heading">
{editingName ? ( {editingName ? (
@@ -120,12 +131,14 @@ export function ListColumn({
}} }}
onBlur={() => void saveName()} onBlur={() => void saveName()}
/> />
) : ( ) : managingLists ? (
<button type="button" class="list-name" onClick={() => setEditingName(true)}>{list.name}</button> <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> <span class="count-badge">{list.cards.length}</span>
</div> </div>
<button {managingLists ? <button
type="button" type="button"
class="icon-button list-delete" class="icon-button list-delete"
aria-label={"Delete " + list.name} aria-label={"Delete " + list.name}
@@ -133,7 +146,7 @@ export function ListColumn({
onClick={() => setConfirming(true)} onClick={() => setConfirming(true)}
> >
× ×
</button> </button> : null}
</div> </div>
<div class="cards-stack"> <div class="cards-stack">
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} /> <DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} />
@@ -142,12 +155,12 @@ export function ListColumn({
<CardItem <CardItem
card={card} card={card}
onEdit={(patch) => onEditCard(card.id, patch)} 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)} /> <DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
</div> </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> </div>
{addingCard ? ( {addingCard ? (
<form <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-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none; font-synthesis: none;
background: #0a1020; background: #0a1020;
color: #e9effc; color: #edf3ff;
--ink: #e9effc;
--muted: #97a6c1;
--faint: #63728d;
--canvas: #0a1020; --canvas: #0a1020;
--canvas-glow: #101d36;
--surface: #111b2d; --surface: #111b2d;
--surface-raised: #17243a; --surface-raised: #17243a;
--surface-hover: #1c2c46; --surface-hover: #1c2c46;
--border: rgba(151, 174, 211, 0.16); --ink: #edf3ff;
--border-strong: rgba(109, 211, 200, 0.42); --muted: #9aaac6;
--teal: #62d5c7; --faint: #687895;
--teal-deep: #168c86; --border: rgba(160, 183, 220, 0.15);
--indigo: #8097ff; --border-strong: rgba(112, 220, 207, 0.55);
--danger: #ff7888; --teal: #65dacb;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.22); --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; } * { box-sizing: border-box; }
@@ -28,8 +31,8 @@ body {
min-width: 320px; min-width: 320px;
margin: 0; margin: 0;
background: background:
radial-gradient(circle at 85% -10%, rgba(72, 115, 195, 0.24), transparent 32rem), radial-gradient(circle at 88% -12%, rgba(80, 120, 211, 0.24), transparent 35rem),
radial-gradient(circle at 0% 100%, rgba(24, 119, 116, 0.15), transparent 26rem), radial-gradient(circle at -8% 88%, rgba(24, 132, 127, 0.16), transparent 29rem),
var(--canvas); var(--canvas);
} }
@@ -39,387 +42,550 @@ button { color: inherit; }
button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; } button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; }
button:focus-visible, input:focus-visible, textarea:focus-visible { button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible {
outline: 3px solid rgba(98, 213, 199, 0.65); outline: 3px solid rgba(101, 218, 203, 0.7);
outline-offset: 2px; outline-offset: 3px;
} }
button:disabled { cursor: not-allowed; opacity: 0.58; } button:disabled { cursor: not-allowed; opacity: 0.55; }
.app-shell { min-height: 100vh; } .app-shell { min-height: 100vh; }
.topbar { .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; position: sticky;
top: 0; top: 0;
z-index: 10; 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 { .brand {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
font-size: 1.28rem; color: var(--ink);
font-weight: 760; font-size: 1.3rem;
letter-spacing: -0.045em; font-weight: 790;
letter-spacing: -0.05em;
} }
.brand-mark { .brand-mark {
width: 36px;
height: 36px;
display: grid; display: grid;
width: 34px;
height: 34px;
place-items: center; place-items: center;
border-radius: 11px; border-radius: 12px;
color: #09111e; color: #09121f;
font-size: 1.06rem; font-size: 1.1rem;
font-weight: 900; font-weight: 900;
background: linear-gradient(135deg, var(--teal), var(--indigo)); 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 { .topbar-board {
display: grid; display: grid;
gap: 1px; gap: 2px;
padding-left: 18px; padding-left: 18px;
border-left: 1px solid var(--border); 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 { .eyebrow {
margin: 0; margin: 0;
color: var(--teal); color: var(--teal);
font-size: 0.69rem; font-size: 0.68rem;
font-weight: 760; font-weight: 780;
letter-spacing: 0.1em; letter-spacing: 0.11em;
text-transform: uppercase; 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 { .icon-button {
width: 40px; width: 42px;
height: 40px;
padding: 0; padding: 0;
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;
border: 1px solid var(--border); border-radius: 11px;
border-radius: 10px;
cursor: pointer;
color: var(--muted); color: var(--muted);
font-size: 1.18rem;
background: transparent; 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 { .board-intro {
max-width: 1500px; max-width: 1530px;
margin: 0 auto 24px; margin: 0 auto 26px;
display: flex; display: flex;
align-items: end; align-items: end;
justify-content: space-between; 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-intro h1 {
.board-hint { max-width: 315px; margin: 0; color: var(--muted); font-size: 0.9rem; text-align: right; } 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 { .board-rail {
width: max-content; width: min(100%, 1530px);
min-width: 100%; min-height: 320px;
max-width: 1500px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 16px; 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 { .list-column, .add-list-column {
width: min(310px, calc(100vw - 48px)); width: min(318px, calc(100vw - 48px));
flex: 0 0 310px; flex: 0 0 318px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 18px;
background: rgba(17, 27, 45, 0.84); background: linear-gradient(155deg, rgba(23, 36, 58, 0.9), rgba(14, 24, 41, 0.89));
box-shadow: var(--shadow); 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; display: flex;
align-items: center; 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-heading { min-width: 0; gap: 8px; }
.list-name, .card-title { .list-name, .list-name-text {
min-width: 0; min-width: 0;
margin: 0;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
border: 0; border: 0;
color: var(--ink); color: var(--ink);
font-weight: 740; font-size: 1rem;
font-weight: 760;
letter-spacing: -0.018em;
line-height: 1.35;
text-align: left; text-align: left;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
cursor: text;
background: transparent; background: transparent;
} }
.list-name { font-size: 1rem; } .list-name { cursor: text; }
.list-heading input { width: 190px; } .list-name:hover { color: var(--teal); }
.list-heading input { width: 194px; }
.count-badge { .count-badge {
min-width: 23px; min-width: 24px;
padding: 2px 7px; padding: 3px 7px;
border-radius: 999px; border-radius: 999px;
color: var(--muted); color: var(--muted);
font-size: 0.75rem; font-size: 0.73rem;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-align: center; 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 { width: 34px; min-height: 34px; border-color: transparent; color: var(--faint); }
.list-delete:hover { color: var(--danger); border-color: rgba(255, 120, 136, 0.25); } .list-delete:hover:not(:disabled) { color: var(--danger); border-color: rgba(255, 125, 144, 0.35); background: var(--danger-soft); }
.cards-stack { min-height: 40px; }
.cards-stack { min-height: 48px; }
.card-slot { position: relative; } .card-slot { position: relative; }
.drop-zone { .drop-zone {
height: 8px; height: 8px;
margin: 1px 0; margin: 1px 0;
border-radius: 6px; border-radius: 7px;
transition: height 120ms ease, background 120ms ease; 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 { .card {
padding: 11px; padding: 12px;
border: 1px solid rgba(161, 185, 220, 0.16); border: 1px solid rgba(169, 192, 225, 0.15);
border-radius: 12px; border-radius: 13px;
background: linear-gradient(145deg, rgba(28, 44, 70, 0.94), rgba(18, 30, 49, 0.94)); color: var(--ink);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
cursor: grab; 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(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:hover { border-color: rgba(151, 174, 211, 0.32); } .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.7; } .card-done { opacity: 0.72; }
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; } .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-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 { .done-button {
flex: 0 0 auto; flex: 0 0 auto;
width: 21px; width: 24px;
height: 21px; min-height: 24px;
margin-top: 1px; margin-top: 1px;
padding: 0; padding: 0;
border: 1.5px solid var(--faint); border: 1.5px solid var(--faint);
border-radius: 7px; border-radius: 8px;
color: #081320; color: #07131e;
cursor: pointer; cursor: pointer;
font-size: 0.82rem;
font-weight: 900;
background: transparent; 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); } .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 { .card-preview {
display: -webkit-box; display: -webkit-box;
margin: 8px 1px 0 29px; margin: 9px 1px 0 34px;
overflow: hidden; overflow: hidden;
color: var(--muted); color: var(--muted);
font-size: 0.78rem; font-size: 0.78rem;
line-height: 1.38; line-height: 1.45;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.card-editor { label { display: grid; gap: 6px; color: var(--muted); font-size: 0.77rem; font-weight: 680; }
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; }
input, textarea { input, textarea {
width: 100%; width: 100%;
padding: 9px 10px; padding: 10px 11px;
border: 1px solid rgba(151, 174, 211, 0.27); border: 1px solid rgba(151, 174, 211, 0.28);
border-radius: 9px; border-radius: 10px;
color: var(--ink); color: var(--ink);
line-height: 1.35; line-height: 1.4;
background: rgba(7, 14, 27, 0.6); 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; } 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); }
.editor-actions { flex-wrap: wrap; gap: 7px; margin-top: 9px; } textarea { min-height: 96px; resize: vertical; }
.button { .button {
min-height: 36px; min-height: 38px;
padding: 7px 12px; padding: 8px 13px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 9px; border-radius: 10px;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 720; font-weight: 740;
cursor: pointer; 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: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-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 { color: var(--muted); background: transparent; }
.button-quiet:hover:not(:disabled) { color: var(--ink); background: rgba(151, 174, 211, 0.09); } .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 { padding: 7px; border: 0; color: var(--danger); font-size: 0.8rem; font-weight: 700; cursor: pointer; background: transparent; }
.text-danger:hover { text-decoration: underline; } .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 { .add-card-button, .add-list-button {
width: 100%; width: 100%;
min-height: 42px; min-height: 44px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 6px;
padding: 8px; padding: 9px;
border: 1px dashed transparent; border: 1px dashed transparent;
border-radius: 9px; border-radius: 10px;
color: var(--muted); color: var(--muted);
font-size: 0.83rem;
font-weight: 680;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
background: transparent; background: transparent;
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
} }
.add-card-button { margin-top: 8px; } .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-column {
.add-list-button { min-height: 46px; } 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 { .list-empty { margin: 14px 6px; color: var(--faint); font-size: 0.79rem; line-height: 1.5; text-align: center; }
max-width: 520px;
margin: 80px auto; .empty-board, .loading-card, .fatal-state {
padding: 28px; max-width: 550px;
margin: 86px auto;
padding: 30px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 20px;
color: var(--muted); color: var(--muted);
text-align: center; 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); 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; } .empty-board h2, .fatal-state h1 { margin: 12px 0 7px; color: var(--ink); font-size: 1.28rem; letter-spacing: -0.025em; }
.fatal-state h1 { margin-top: 0; color: var(--ink); font-size: 1.25rem; } .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; position: fixed;
inset: 0; inset: 0;
z-index: 30;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 18px; padding: 18px;
background: rgba(2, 7, 15, 0.7); background: rgba(2, 7, 15, 0.72);
backdrop-filter: blur(3px); 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 { .confirm-dialog {
width: min(100%, 410px); width: min(100%, 420px);
padding: 24px; padding: 24px;
border: 1px solid rgba(151, 174, 211, 0.28); border: 1px solid rgba(163, 190, 231, 0.3);
border-radius: 16px; border-radius: 17px;
background: #14213a; background: #172640;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5); 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 h2 { margin: 0; color: var(--ink); font-size: 1.16rem; letter-spacing: -0.02em; }
.confirm-dialog p { margin: 10px 0 22px; color: var(--muted); line-height: 1.45; } .confirm-dialog p { margin: 10px 0 23px; color: var(--muted); line-height: 1.5; }
.dialog-actions { justify-content: flex-end; gap: 8px; } .dialog-actions { justify-content: flex-end; gap: 8px; }
.toast-stack { .toast-stack {
position: fixed; position: fixed;
right: 18px; right: 18px;
bottom: 18px; bottom: 18px;
z-index: 40; z-index: 50;
display: grid; display: grid;
gap: 9px; gap: 9px;
width: min(350px, calc(100vw - 36px)); width: min(360px, 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;
} }
.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-error { border-left-color: var(--danger); }
.toast-info { border-left-color: var(--indigo); } .toast-info { border-left-color: var(--indigo); }
.toast span { flex: 1; font-size: 0.86rem; } .toast span { flex: 1; font-size: 0.86rem; line-height: 1.35; }
.toast button { width: 26px; height: 26px; border: 0; border-radius: 6px; color: var(--muted); font-size: 1.2rem; cursor: pointer; background: transparent; } .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); } .toast button:hover { color: var(--ink); background: rgba(255, 255, 255, 0.07); }
.sr-only { .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
position: absolute;
width: 1px; @keyframes toast-in { from { opacity: 0; transform: translateY(9px); } to { opacity: 1; transform: translateY(0); } }
height: 1px; @keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
padding: 0; @keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
overflow: hidden; @keyframes modal-in { from { opacity: 0; transform: translateY(9px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
clip: rect(0, 0, 0, 0);
white-space: nowrap; @media (max-width: 700px) {
border: 0; .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); } } @media (max-width: 430px) {
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } } .brand { font-size: 1.18rem; }
.header-edit-button { font-size: 0; }
@media (max-width: 640px) { .header-edit-button span { font-size: 1rem; }
.topbar { min-height: 64px; padding: 10px 16px; } .list-column, .add-list-column { width: calc(100vw - 48px); flex-basis: calc(100vw - 48px); }
.topbar-board { display: none; } .detail-timestamps { display: grid; gap: 9px; }
.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 (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
} }