feat(ui): Jira-style card detail, edit-mode column management, design polish
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
237
web/src/components/CardDetailModal.tsx
Normal file
237
web/src/components/CardDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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()}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user