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/board-state.ts`: `useReducer` board state — owns the `Board` snapshot, `loading`, `error`, and `toasts`. Pure action creators/reducers for add/update/remove list & card and optimistic transforms, plus rollback support.
|
||||
- `web/src/drag.ts`: native HTML5 drag-and-drop helpers — `cardDragType` MIME, `beginCardDrag`/`readCardDrag` payloads, and `movePosition` (computes the new fractional position between drop neighbours).
|
||||
- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer, wires optimistic mutations with rollback + toasts, and renders `Header` + `BoardCanvas` + `Toasts`.
|
||||
- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, and reload button.
|
||||
- `web/src/components/BoardCanvas.tsx`: horizontally-scrolling list rail, inline "add list" affordance, and the empty/loading states. Maps lists to `ListColumn`.
|
||||
- `web/src/components/ListColumn.tsx`: one column — inline list-name edit, card count, drop zones (one before each card + one at the end), inline add-card input, and per-list delete confirmation.
|
||||
- `web/src/components/CardItem.tsx`: one draggable card — click-to-edit title, done toggle, expand to `CardEditor`, and delete confirmation.
|
||||
- `web/src/components/CardEditor.tsx`: description editor and destructive (delete) action for a card.
|
||||
- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer and selected-card/list-management UI state, wires optimistic mutations with rollback + toasts, and renders `Header`, `BoardCanvas`, the selected card's `CardDetailModal`, and `Toasts`.
|
||||
- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, reload button, and the active/inactive Edit toggle for list management.
|
||||
- `web/src/components/BoardCanvas.tsx`: responsive horizontally-scrolling list rail, management-mode-only add-list affordance, and board empty state. Maps lists to `ListColumn`.
|
||||
- `web/src/components/ListColumn.tsx`: one column — card count, native drop zones (one before each card + one at the end), inline add-card input, empty-column state, and management-mode-only list rename/delete controls with delete confirmation.
|
||||
- `web/src/components/CardItem.tsx`: one draggable card tile. A genuine click opens the board-level detail dialog; drag events are suppressed from opening it. It retains the done toggle, title/description preview, and a visible drag grip.
|
||||
- `web/src/components/CardDetailModal.tsx`: accessible Jira-style card dialog with inline title and description editing, list/status properties, timestamps, delete confirmation, outside/Escape/X close controls, and modal focus restoration.
|
||||
- `web/src/components/ConfirmDialog.tsx`: accessible confirmation dialog used before any delete.
|
||||
- `web/src/components/Toast.tsx`: non-blocking toast notifications (auto-dismiss ~4.4s).
|
||||
- `web/src/styles.css`: all styling. Dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), CSS custom properties, rounded surfaces, soft shadows, focus rings, and `prefers-reduced-motion` support. 44px touch targets for mobile.
|
||||
- `web/src/styles.css`: all styling. Elevated dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), responsive horizontal board rail, polished card/list/button states, edit-mode treatment, detail-dialog layout, empty states, focus rings, 44px mobile touch targets, and `prefers-reduced-motion` support.
|
||||
|
||||
### Embedding bridge
|
||||
|
||||
@@ -109,7 +109,9 @@ Error shape: `{ "error": { "code": "validation_error"|"not_found"|..., "message"
|
||||
|
||||
- Loads `/api/board` on mount; no board picker (single fixed board).
|
||||
- Native HTML5 drag-and-drop reorders cards within and across lists; drop computes a fractional position, updates optimistically, and reconciles/rolls back on failure.
|
||||
- Inline add/edit for lists and cards, done toggle, descriptions, delete-with-confirm.
|
||||
- Clicking a card opens a centered, accessible Jira-style detail dialog. Its title and description patch independently, while list, done status, created/updated timestamps, and delete-with-confirm are visible in the dialog. It closes via X, Escape, or outside click, focuses on open, and restores the prior trigger focus when closed.
|
||||
- The Header's Edit toggle enters list-management mode. Only in that mode are list rename/delete controls and the end-of-rail add-list control visible; card add, done, detail editing, delete, and drag-and-drop remain available in either mode.
|
||||
- Inline add for cards, done toggle, descriptions, and delete-with-confirm. There is no inline card expansion/editor; card editing is centralized in the detail dialog.
|
||||
- Optimistic UI: every mutation updates state immediately, then reconciles with the server response; failures restore the prior snapshot and show an error toast. Only the pending resource's controls disable.
|
||||
- Responsive horizontally-scrolling board; usable on phones.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useReducer, useRef } from "preact/hooks";
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from "preact/hooks";
|
||||
import { api, APIError } from "./api";
|
||||
import {
|
||||
addCard,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { movePosition } from "./drag";
|
||||
import type { CardPatch, ListPatch } from "./types";
|
||||
import { BoardCanvas } from "./components/BoardCanvas";
|
||||
import { CardDetailModal } from "./components/CardDetailModal";
|
||||
import { Header } from "./components/Header";
|
||||
import { Toasts } from "./components/Toast";
|
||||
|
||||
@@ -27,9 +28,18 @@ function friendlyError(error: unknown): string {
|
||||
|
||||
export function App() {
|
||||
const [state, dispatch] = useReducer(boardReducer, initialBoardState);
|
||||
const [managingLists, setManagingLists] = useState(false);
|
||||
const [selectedCardID, setSelectedCardID] = useState<number | null>(null);
|
||||
const boardRef = useRef(state.board);
|
||||
boardRef.current = state.board;
|
||||
|
||||
const selectedList = state.board?.lists.find((list) => list.cards.some((card) => card.id === selectedCardID));
|
||||
const selectedCard = selectedList?.cards.find((card) => card.id === selectedCardID);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCardID !== null && !selectedCard) setSelectedCardID(null);
|
||||
}, [selectedCardID, selectedCard]);
|
||||
|
||||
const toast = useCallback((message: string, tone: "success" | "error" | "info" = "info") => {
|
||||
const id = ++toastID;
|
||||
dispatch({ type: "toast", toast: { id, message, tone } });
|
||||
@@ -141,7 +151,12 @@ export function App() {
|
||||
|
||||
return (
|
||||
<main class="app-shell">
|
||||
<Header onReload={load} loading={state.loading} />
|
||||
<Header
|
||||
onReload={load}
|
||||
loading={state.loading}
|
||||
managingLists={managingLists}
|
||||
onToggleManagingLists={() => setManagingLists((active) => !active)}
|
||||
/>
|
||||
{state.loading && !state.board ? <section class="loading-card" aria-live="polite">Loading your Haven board…</section> : null}
|
||||
{state.error ? (
|
||||
<section class="fatal-state">
|
||||
@@ -158,12 +173,21 @@ export function App() {
|
||||
onDeleteList={deleteList}
|
||||
onCreateCard={createCard}
|
||||
onEditCard={editCard}
|
||||
onDeleteCard={deleteCard}
|
||||
onMoveCard={moveCard}
|
||||
onOpenCard={setSelectedCardID}
|
||||
managingLists={managingLists}
|
||||
/>
|
||||
) : null}
|
||||
{selectedCard && selectedList ? (
|
||||
<CardDetailModal
|
||||
card={selectedCard}
|
||||
listName={selectedList.name}
|
||||
onClose={() => setSelectedCardID(null)}
|
||||
onEdit={(patch) => editCard(selectedCard.id, patch)}
|
||||
onDelete={() => deleteCard(selectedCard.id)}
|
||||
/>
|
||||
) : null}
|
||||
<Toasts toasts={state.toasts} onDismiss={(id) => dispatch({ type: "dismiss", id })} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import type { ListWithCards } from "../types";
|
||||
import { ListColumn } from "./ListColumn";
|
||||
|
||||
@@ -9,8 +9,9 @@ interface BoardCanvasProps {
|
||||
onDeleteList: (id: number) => Promise<void>;
|
||||
onCreateCard: (listID: number, title: string) => Promise<void>;
|
||||
onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
|
||||
onDeleteCard: (id: number) => Promise<void>;
|
||||
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
|
||||
onOpenCard: (id: number) => void;
|
||||
managingLists: boolean;
|
||||
}
|
||||
|
||||
export function BoardCanvas(props: BoardCanvasProps) {
|
||||
@@ -18,6 +19,13 @@ export function BoardCanvas(props: BoardCanvasProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.managingLists) {
|
||||
setAdding(false);
|
||||
setName("");
|
||||
}
|
||||
}, [props.managingLists]);
|
||||
|
||||
const addList = async () => {
|
||||
if (!name.trim()) return;
|
||||
setSaving(true);
|
||||
@@ -39,22 +47,26 @@ export function BoardCanvas(props: BoardCanvasProps) {
|
||||
<p class="eyebrow">One calm place for your homelab work</p>
|
||||
<h1>Haven board</h1>
|
||||
</div>
|
||||
<p class="board-hint">Drag tasks between lists, or click a title to edit.</p>
|
||||
<p class="board-hint">
|
||||
{props.managingLists ? "Manage your workflow: rename, remove, or add lists." : "Drag tasks between lists, or open one for the full story."}
|
||||
</p>
|
||||
</div>
|
||||
<div class="board-rail">
|
||||
{props.lists.map((list) => (
|
||||
<ListColumn
|
||||
key={list.id}
|
||||
list={list}
|
||||
onEditList={(patch) => props.onEditList(list.id, patch)}
|
||||
onDeleteList={() => props.onDeleteList(list.id)}
|
||||
onCreateCard={(title) => props.onCreateCard(list.id, title)}
|
||||
onEditCard={props.onEditCard}
|
||||
onDeleteCard={props.onDeleteCard}
|
||||
onMoveCard={props.onMoveCard}
|
||||
/>
|
||||
))}
|
||||
<section class="add-list-column">
|
||||
{props.lists.length ? (
|
||||
<div class={"board-rail " + (props.managingLists ? "is-managing" : "")}>
|
||||
{props.lists.map((list) => (
|
||||
<ListColumn
|
||||
key={list.id}
|
||||
list={list}
|
||||
managingLists={props.managingLists}
|
||||
onEditList={(patch) => props.onEditList(list.id, patch)}
|
||||
onDeleteList={() => props.onDeleteList(list.id)}
|
||||
onCreateCard={(title) => props.onCreateCard(list.id, title)}
|
||||
onEditCard={props.onEditCard}
|
||||
onMoveCard={props.onMoveCard}
|
||||
onOpenCard={props.onOpenCard}
|
||||
/>
|
||||
))}
|
||||
{props.managingLists ? <section class="add-list-column">
|
||||
{adding ? (
|
||||
<form
|
||||
class="quick-add"
|
||||
@@ -85,11 +97,18 @@ export function BoardCanvas(props: BoardCanvasProps) {
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" class="add-list-button" onClick={() => setAdding(true)}>
|
||||
<span aria-hidden="true">+</span> Add another list
|
||||
<span aria-hidden="true">+</span> Add list
|
||||
</button>
|
||||
)}
|
||||
</section> : null}
|
||||
</div>
|
||||
) : (
|
||||
<section class="empty-board" aria-live="polite">
|
||||
<span class="empty-state-mark" aria-hidden="true">✦</span>
|
||||
<h2>Your board is ready for a first list</h2>
|
||||
<p>Turn on Edit to create the first stage in your workflow.</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 type { Card } from "../types";
|
||||
import { CardEditor } from "./CardEditor";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
interface CardItemProps {
|
||||
card: Card;
|
||||
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export function CardItem({ card, onEdit, onDelete }: CardItemProps) {
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [title, setTitle] = useState(card.title);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
export function CardItem({ card, onEdit, onOpen }: CardItemProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const didDrag = useRef(false);
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (!title.trim()) return;
|
||||
const toggleDone = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onEdit({ title });
|
||||
setEditingTitle(false);
|
||||
await onEdit({ done: !card.done });
|
||||
} catch {
|
||||
setTitle(card.title);
|
||||
// The app rolls back the optimistic change and shows an error toast.
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
setConfirming(false);
|
||||
try {
|
||||
await onDelete();
|
||||
} catch {
|
||||
// The global mutation handler reports the failure.
|
||||
const openIfClicked = () => {
|
||||
if (didDrag.current) {
|
||||
didDrag.current = false;
|
||||
return;
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
class={"card " + (card.done ? "card-done" : "")}
|
||||
draggable={!editingTitle}
|
||||
onDragStart={(event) => beginCardDrag(event as unknown as DragEvent, card)}
|
||||
>
|
||||
<div class="card-topline">
|
||||
<button
|
||||
type="button"
|
||||
class={"done-button " + (card.done ? "is-done" : "")}
|
||||
aria-label={card.done ? "Mark card active" : "Mark card done"}
|
||||
title={card.done ? "Mark active" : "Mark done"}
|
||||
disabled={saving}
|
||||
onClick={() => void onEdit({ done: !card.done })}
|
||||
>
|
||||
{card.done ? "✓" : ""}
|
||||
</button>
|
||||
<div class="card-title-wrap">
|
||||
{editingTitle ? (
|
||||
<input
|
||||
class="card-title-input"
|
||||
value={title}
|
||||
maxlength={240}
|
||||
aria-label="Card title"
|
||||
autoFocus
|
||||
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void saveTitle();
|
||||
if (event.key === "Escape") {
|
||||
setTitle(card.title);
|
||||
setEditingTitle(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => void saveTitle()}
|
||||
/>
|
||||
) : (
|
||||
<button type="button" class="card-title" onClick={() => setEditingTitle(true)}>{card.title}</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="card-menu"
|
||||
title="Open card details"
|
||||
aria-label="Open card details"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
•••
|
||||
</button>
|
||||
</div>
|
||||
{card.description && !expanded ? <p class="card-preview">{card.description}</p> : null}
|
||||
{expanded ? (
|
||||
<CardEditor
|
||||
card={card}
|
||||
onSave={(description) => onEdit({ description })}
|
||||
onClose={() => setExpanded(false)}
|
||||
onDelete={() => setConfirming(true)}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
<ConfirmDialog
|
||||
open={confirming}
|
||||
title="Delete this card?"
|
||||
detail={"“" + card.title + "” will be permanently removed."}
|
||||
confirmLabel="Delete card"
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => void remove()}
|
||||
/>
|
||||
</>
|
||||
<article
|
||||
class={"card " + (card.done ? "card-done" : "")}
|
||||
draggable
|
||||
tabIndex={0}
|
||||
onClick={openIfClicked}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openIfClicked();
|
||||
}
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
didDrag.current = true;
|
||||
beginCardDrag(event as unknown as DragEvent, card);
|
||||
}}
|
||||
onDragEnd={() => window.setTimeout(() => { didDrag.current = false; }, 0)}
|
||||
>
|
||||
<div class="card-topline">
|
||||
<button
|
||||
type="button"
|
||||
class={"done-button " + (card.done ? "is-done" : "")}
|
||||
aria-label={card.done ? "Mark card active" : "Mark card done"}
|
||||
title={card.done ? "Mark active" : "Mark done"}
|
||||
disabled={saving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void toggleDone();
|
||||
}}
|
||||
>
|
||||
{card.done ? "✓" : ""}
|
||||
</button>
|
||||
<span class="card-grip" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||||
<h3 class="card-title">{card.title}</h3>
|
||||
</div>
|
||||
{card.description ? <p class="card-preview">{card.description}</p> : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
interface HeaderProps {
|
||||
loading: boolean;
|
||||
onReload: () => void;
|
||||
managingLists: boolean;
|
||||
onToggleManagingLists: () => void;
|
||||
}
|
||||
|
||||
export function Header({ loading, onReload }: HeaderProps) {
|
||||
export function Header({ loading, onReload, managingLists, onToggleManagingLists }: HeaderProps) {
|
||||
return (
|
||||
<header class="topbar">
|
||||
<div class="brand" aria-label="Havenllo">
|
||||
@@ -14,17 +16,26 @@ export function Header({ loading, onReload }: HeaderProps) {
|
||||
<span class="eyebrow">Haven homelab</span>
|
||||
<strong>My board</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button refresh-button"
|
||||
title="Refresh board"
|
||||
aria-label="Refresh board"
|
||||
disabled={loading}
|
||||
onClick={onReload}
|
||||
>
|
||||
<span aria-hidden="true">↻</span>
|
||||
</button>
|
||||
<div class="topbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
class={"header-edit-button " + (managingLists ? "is-active" : "")}
|
||||
aria-pressed={managingLists}
|
||||
onClick={onToggleManagingLists}
|
||||
>
|
||||
<span aria-hidden="true">✦</span> {managingLists ? "Done" : "Edit"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
title="Refresh board"
|
||||
aria-label="Refresh board"
|
||||
disabled={loading}
|
||||
onClick={onReload}
|
||||
>
|
||||
<span aria-hidden="true">↻</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "preact/hooks";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { cardDragType, readCardDrag } from "../drag";
|
||||
import type { Card, ListWithCards } from "../types";
|
||||
import { CardItem } from "./CardItem";
|
||||
@@ -6,12 +6,13 @@ import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
interface ListColumnProps {
|
||||
list: ListWithCards;
|
||||
managingLists: boolean;
|
||||
onEditList: (patch: { name?: string; position?: number }) => Promise<void>;
|
||||
onDeleteList: () => Promise<void>;
|
||||
onCreateCard: (title: string) => Promise<void>;
|
||||
onEditCard: (cardID: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
|
||||
onDeleteCard: (cardID: number) => Promise<void>;
|
||||
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
|
||||
onOpenCard: (id: number) => void;
|
||||
}
|
||||
|
||||
interface DropZoneProps {
|
||||
@@ -49,8 +50,9 @@ export function ListColumn({
|
||||
onDeleteList,
|
||||
onCreateCard,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onMoveCard
|
||||
onMoveCard,
|
||||
onOpenCard,
|
||||
managingLists
|
||||
}: ListColumnProps) {
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [name, setName] = useState(list.name);
|
||||
@@ -59,6 +61,15 @@ export function ListColumn({
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!managingLists && editingName) {
|
||||
setName(list.name);
|
||||
setEditingName(false);
|
||||
} else if (!editingName) {
|
||||
setName(list.name);
|
||||
}
|
||||
}, [editingName, list.name, managingLists]);
|
||||
|
||||
const saveName = async () => {
|
||||
if (!name.trim() || name.trim() === list.name) {
|
||||
setName(list.name);
|
||||
@@ -101,7 +112,7 @@ export function ListColumn({
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class="list-column">
|
||||
<section class={"list-column " + (managingLists ? "is-managing" : "")}>
|
||||
<div class="list-header">
|
||||
<div class="list-heading">
|
||||
{editingName ? (
|
||||
@@ -120,12 +131,14 @@ export function ListColumn({
|
||||
}}
|
||||
onBlur={() => void saveName()}
|
||||
/>
|
||||
) : (
|
||||
) : managingLists ? (
|
||||
<button type="button" class="list-name" onClick={() => setEditingName(true)}>{list.name}</button>
|
||||
) : (
|
||||
<h2 class="list-name-text">{list.name}</h2>
|
||||
)}
|
||||
<span class="count-badge">{list.cards.length}</span>
|
||||
</div>
|
||||
<button
|
||||
{managingLists ? <button
|
||||
type="button"
|
||||
class="icon-button list-delete"
|
||||
aria-label={"Delete " + list.name}
|
||||
@@ -133,7 +146,7 @@ export function ListColumn({
|
||||
onClick={() => setConfirming(true)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</button> : null}
|
||||
</div>
|
||||
<div class="cards-stack">
|
||||
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} />
|
||||
@@ -142,12 +155,12 @@ export function ListColumn({
|
||||
<CardItem
|
||||
card={card}
|
||||
onEdit={(patch) => onEditCard(card.id, patch)}
|
||||
onDelete={() => onDeleteCard(card.id)}
|
||||
onOpen={() => onOpenCard(card.id)}
|
||||
/>
|
||||
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
|
||||
</div>
|
||||
))}
|
||||
{!list.cards.length ? <p class="list-empty">Drop a card here or add the first task.</p> : null}
|
||||
{!list.cards.length ? <p class="list-empty">No cards yet. Drop one here or add the first task.</p> : null}
|
||||
</div>
|
||||
{addingCard ? (
|
||||
<form
|
||||
@@ -193,4 +206,3 @@ export function ListColumn({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,24 @@
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
background: #0a1020;
|
||||
color: #e9effc;
|
||||
--ink: #e9effc;
|
||||
--muted: #97a6c1;
|
||||
--faint: #63728d;
|
||||
color: #edf3ff;
|
||||
--canvas: #0a1020;
|
||||
--canvas-glow: #101d36;
|
||||
--surface: #111b2d;
|
||||
--surface-raised: #17243a;
|
||||
--surface-hover: #1c2c46;
|
||||
--border: rgba(151, 174, 211, 0.16);
|
||||
--border-strong: rgba(109, 211, 200, 0.42);
|
||||
--teal: #62d5c7;
|
||||
--teal-deep: #168c86;
|
||||
--indigo: #8097ff;
|
||||
--danger: #ff7888;
|
||||
--shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
|
||||
--ink: #edf3ff;
|
||||
--muted: #9aaac6;
|
||||
--faint: #687895;
|
||||
--border: rgba(160, 183, 220, 0.15);
|
||||
--border-strong: rgba(112, 220, 207, 0.55);
|
||||
--teal: #65dacb;
|
||||
--teal-soft: rgba(101, 218, 203, 0.13);
|
||||
--indigo: #91a4ff;
|
||||
--danger: #ff7d90;
|
||||
--danger-soft: rgba(255, 125, 144, 0.12);
|
||||
--shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
|
||||
--shadow-lifted: 0 22px 46px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@@ -28,8 +31,8 @@ body {
|
||||
min-width: 320px;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 85% -10%, rgba(72, 115, 195, 0.24), transparent 32rem),
|
||||
radial-gradient(circle at 0% 100%, rgba(24, 119, 116, 0.15), transparent 26rem),
|
||||
radial-gradient(circle at 88% -12%, rgba(80, 120, 211, 0.24), transparent 35rem),
|
||||
radial-gradient(circle at -8% 88%, rgba(24, 132, 127, 0.16), transparent 29rem),
|
||||
var(--canvas);
|
||||
}
|
||||
|
||||
@@ -39,387 +42,550 @@ button { color: inherit; }
|
||||
|
||||
button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; }
|
||||
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
||||
outline: 3px solid rgba(98, 213, 199, 0.65);
|
||||
outline-offset: 2px;
|
||||
button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible {
|
||||
outline: 3px solid rgba(101, 218, 203, 0.7);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
button:disabled { cursor: not-allowed; opacity: 0.58; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.app-shell { min-height: 100vh; }
|
||||
|
||||
.topbar {
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 12px clamp(18px, 4vw, 52px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(10, 16, 32, 0.78);
|
||||
backdrop-filter: blur(14px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
min-height: 74px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 12px clamp(18px, 4vw, 56px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(10, 16, 32, 0.8);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.015);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1.28rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.045em;
|
||||
color: var(--ink);
|
||||
font-size: 1.3rem;
|
||||
font-weight: 790;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #09111e;
|
||||
font-size: 1.06rem;
|
||||
border-radius: 12px;
|
||||
color: #09121f;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, var(--teal), var(--indigo));
|
||||
box-shadow: 0 6px 20px rgba(98, 213, 199, 0.24);
|
||||
box-shadow: 0 7px 22px rgba(101, 218, 203, 0.23);
|
||||
}
|
||||
|
||||
.topbar-board {
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
padding-left: 18px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.topbar-board strong { font-size: 0.92rem; }
|
||||
.topbar-board strong { font-size: 0.91rem; }
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--teal);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 780;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.refresh-button { margin-left: auto; }
|
||||
.header-edit-button, .icon-button {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, color 150ms ease, border-color 150ms ease, background 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.header-edit-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 11px;
|
||||
color: var(--muted);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 750;
|
||||
background: rgba(17, 27, 45, 0.46);
|
||||
}
|
||||
|
||||
.header-edit-button:hover, .header-edit-button.is-active {
|
||||
color: var(--ink);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--teal-soft);
|
||||
}
|
||||
|
||||
.header-edit-button.is-active { color: var(--teal); box-shadow: inset 0 0 0 1px rgba(101, 218, 203, 0.1); }
|
||||
|
||||
.icon-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 42px;
|
||||
padding: 0;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 11px;
|
||||
color: var(--muted);
|
||||
font-size: 1.18rem;
|
||||
background: transparent;
|
||||
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
.icon-button:hover { color: var(--ink); border-color: var(--border-strong); background: var(--surface-hover); }
|
||||
.icon-button:hover:not(:disabled) { color: var(--ink); border-color: var(--border-strong); background: var(--surface-hover); transform: translateY(-1px); }
|
||||
|
||||
.board-area { padding: clamp(24px, 4vw, 50px); }
|
||||
.board-area { padding: clamp(26px, 4.2vw, 58px) clamp(18px, 4vw, 56px) 34px; }
|
||||
|
||||
.board-intro {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto 24px;
|
||||
max-width: 1530px;
|
||||
margin: 0 auto 26px;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.board-intro h1 { margin: 4px 0 0; font-size: clamp(1.65rem, 3vw, 2.2rem); letter-spacing: -0.045em; }
|
||||
.board-hint { max-width: 315px; margin: 0; color: var(--muted); font-size: 0.9rem; text-align: right; }
|
||||
.board-intro h1 {
|
||||
margin: 5px 0 0;
|
||||
font-size: clamp(1.72rem, 3vw, 2.45rem);
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.board-hint { max-width: 335px; margin: 0 2px 2px 0; color: var(--muted); font-size: 0.89rem; line-height: 1.5; text-align: right; }
|
||||
|
||||
.board-rail {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
max-width: 1500px;
|
||||
width: min(100%, 1530px);
|
||||
min-height: 320px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding-bottom: 34px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
padding: 2px 2px 28px;
|
||||
scrollbar-color: rgba(151, 174, 211, 0.32) transparent;
|
||||
}
|
||||
|
||||
.board-rail.is-managing { padding-top: 5px; }
|
||||
|
||||
.list-column, .add-list-column {
|
||||
width: min(310px, calc(100vw - 48px));
|
||||
flex: 0 0 310px;
|
||||
width: min(318px, calc(100vw - 48px));
|
||||
flex: 0 0 318px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: rgba(17, 27, 45, 0.84);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(155deg, rgba(23, 36, 58, 0.9), rgba(14, 24, 41, 0.89));
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.list-column { padding: 14px; }
|
||||
.list-column {
|
||||
padding: 14px;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.list-header, .list-heading, .card-topline, .editor-actions, .quick-add-actions, .dialog-actions {
|
||||
.list-column:hover { border-color: rgba(160, 183, 220, 0.26); box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28); }
|
||||
.list-column.is-managing { border-color: rgba(101, 218, 203, 0.34); box-shadow: 0 0 0 1px rgba(101, 218, 203, 0.07), var(--shadow); }
|
||||
|
||||
.list-header, .list-heading, .card-topline, .quick-add-actions, .dialog-actions, .detail-header, .detail-title-row, .detail-section-heading, .detail-save-row, .detail-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list-header { justify-content: space-between; gap: 8px; padding: 0 1px 9px; }
|
||||
.list-header { justify-content: space-between; gap: 8px; padding: 1px 1px 11px; }
|
||||
.list-heading { min-width: 0; gap: 8px; }
|
||||
|
||||
.list-name, .card-title {
|
||||
.list-name, .list-name-text {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
color: var(--ink);
|
||||
font-weight: 740;
|
||||
font-size: 1rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.018em;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.list-name { font-size: 1rem; }
|
||||
.list-heading input { width: 190px; }
|
||||
.list-name { cursor: text; }
|
||||
.list-name:hover { color: var(--teal); }
|
||||
.list-heading input { width: 194px; }
|
||||
|
||||
.count-badge {
|
||||
min-width: 23px;
|
||||
padding: 2px 7px;
|
||||
min-width: 24px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.73rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
background: rgba(126, 151, 190, 0.12);
|
||||
background: rgba(140, 164, 201, 0.13);
|
||||
}
|
||||
|
||||
.list-delete { width: 30px; height: 30px; border-color: transparent; font-size: 1.25rem; }
|
||||
.list-delete:hover { color: var(--danger); border-color: rgba(255, 120, 136, 0.25); }
|
||||
|
||||
.cards-stack { min-height: 40px; }
|
||||
.list-delete { width: 34px; min-height: 34px; border-color: transparent; color: var(--faint); }
|
||||
.list-delete:hover:not(:disabled) { color: var(--danger); border-color: rgba(255, 125, 144, 0.35); background: var(--danger-soft); }
|
||||
|
||||
.cards-stack { min-height: 48px; }
|
||||
.card-slot { position: relative; }
|
||||
|
||||
.drop-zone {
|
||||
height: 8px;
|
||||
margin: 1px 0;
|
||||
border-radius: 6px;
|
||||
transition: height 120ms ease, background 120ms ease;
|
||||
border-radius: 7px;
|
||||
transition: height 140ms ease, background 140ms ease, outline-color 140ms ease;
|
||||
}
|
||||
|
||||
.drop-zone.is-active { height: 22px; background: rgba(98, 213, 199, 0.2); outline: 1px dashed var(--teal); }
|
||||
.drop-zone.is-active { height: 28px; background: rgba(101, 218, 203, 0.16); outline: 1px dashed var(--teal); }
|
||||
|
||||
.card {
|
||||
padding: 11px;
|
||||
border: 1px solid rgba(161, 185, 220, 0.16);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(145deg, rgba(28, 44, 70, 0.94), rgba(18, 30, 49, 0.94));
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(169, 192, 225, 0.15);
|
||||
border-radius: 13px;
|
||||
color: var(--ink);
|
||||
cursor: grab;
|
||||
background: linear-gradient(145deg, rgba(31, 48, 76, 0.96), rgba(18, 30, 49, 0.97));
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
|
||||
transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, opacity 150ms ease;
|
||||
}
|
||||
|
||||
.card:active { cursor: grabbing; }
|
||||
.card:hover { border-color: rgba(151, 174, 211, 0.32); }
|
||||
|
||||
.card-done { opacity: 0.7; }
|
||||
.card:hover { border-color: rgba(128, 205, 209, 0.43); background: linear-gradient(145deg, rgba(35, 54, 84, 0.98), rgba(20, 34, 55, 0.98)); box-shadow: var(--shadow-lifted); transform: translateY(-2px); }
|
||||
.card:active { cursor: grabbing; transform: scale(0.987); }
|
||||
.card:focus-visible { outline: 3px solid rgba(101, 218, 203, 0.7); outline-offset: 3px; }
|
||||
.card-done { opacity: 0.72; }
|
||||
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
|
||||
|
||||
.card-topline { align-items: flex-start; gap: 8px; }
|
||||
.card-title-wrap { min-width: 0; flex: 1; }
|
||||
.card-title { width: 100%; line-height: 1.35; white-space: normal; }
|
||||
.card-title-input { width: 100%; padding: 2px 4px; }
|
||||
|
||||
.done-button {
|
||||
flex: 0 0 auto;
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
width: 24px;
|
||||
min-height: 24px;
|
||||
margin-top: 1px;
|
||||
padding: 0;
|
||||
border: 1.5px solid var(--faint);
|
||||
border-radius: 7px;
|
||||
color: #081320;
|
||||
border-radius: 8px;
|
||||
color: #07131e;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 900;
|
||||
background: transparent;
|
||||
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
.done-button:hover:not(:disabled) { border-color: var(--teal); transform: scale(1.08); }
|
||||
.done-button.is-done { border-color: var(--teal); background: var(--teal); }
|
||||
.card-menu { padding: 0 2px 4px; border: 0; color: var(--faint); font-weight: 800; letter-spacing: 0.08em; cursor: pointer; background: transparent; }
|
||||
.card-menu:hover { color: var(--ink); }
|
||||
|
||||
.card-grip {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 2px);
|
||||
gap: 2px;
|
||||
margin-top: 7px;
|
||||
opacity: 0.42;
|
||||
transition: opacity 140ms ease;
|
||||
}
|
||||
|
||||
.card:hover .card-grip { opacity: 0.9; }
|
||||
.card-grip i { width: 2px; height: 2px; border-radius: 50%; background: var(--muted); }
|
||||
|
||||
.card-title { flex: 1; min-width: 0; margin: 0; font-size: 0.92rem; font-weight: 720; letter-spacing: -0.012em; line-height: 1.4; }
|
||||
|
||||
.card-preview {
|
||||
display: -webkit-box;
|
||||
margin: 8px 1px 0 29px;
|
||||
margin: 9px 1px 0 34px;
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.38;
|
||||
line-height: 1.45;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.card-editor {
|
||||
margin-top: 11px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.76rem; font-weight: 650; }
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.77rem; font-weight: 680; }
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(151, 174, 211, 0.27);
|
||||
border-radius: 9px;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid rgba(151, 174, 211, 0.28);
|
||||
border-radius: 10px;
|
||||
color: var(--ink);
|
||||
line-height: 1.35;
|
||||
background: rgba(7, 14, 27, 0.6);
|
||||
line-height: 1.4;
|
||||
background: rgba(6, 13, 27, 0.62);
|
||||
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
|
||||
textarea { min-height: 82px; resize: vertical; }
|
||||
|
||||
.editor-actions { flex-wrap: wrap; gap: 7px; margin-top: 9px; }
|
||||
input:hover, textarea:hover { border-color: rgba(151, 174, 211, 0.45); }
|
||||
input:focus, textarea:focus { border-color: var(--teal); background: rgba(7, 15, 29, 0.88); }
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
|
||||
.button {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
min-height: 38px;
|
||||
padding: 8px 13px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
font-weight: 740;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
|
||||
transition: transform 130ms ease, background 130ms ease, border-color 130ms ease, box-shadow 130ms ease;
|
||||
}
|
||||
|
||||
.button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
.button-primary { color: #08131e; background: linear-gradient(135deg, var(--teal), #75c9d8); }
|
||||
.button-primary { color: #07151d; background: linear-gradient(135deg, var(--teal), #84cfdb); box-shadow: 0 7px 18px rgba(67, 191, 183, 0.18); }
|
||||
.button-primary:hover:not(:disabled) { box-shadow: 0 10px 22px rgba(67, 191, 183, 0.28); }
|
||||
.button-secondary { border-color: var(--border); color: var(--ink); background: var(--surface-hover); }
|
||||
.button-danger { color: #fff1f3; background: #b84155; }
|
||||
.button-danger { color: #fff3f5; background: #b64156; }
|
||||
.button-quiet { color: var(--muted); background: transparent; }
|
||||
.button-quiet:hover:not(:disabled) { color: var(--ink); background: rgba(151, 174, 211, 0.09); }
|
||||
|
||||
.text-danger { margin-left: auto; padding: 5px; border: 0; color: var(--danger); font-size: 0.77rem; cursor: pointer; background: transparent; }
|
||||
.text-danger:hover { text-decoration: underline; }
|
||||
.text-danger { padding: 7px; border: 0; color: var(--danger); font-size: 0.8rem; font-weight: 700; cursor: pointer; background: transparent; }
|
||||
.text-danger:hover:not(:disabled) { color: #ffacb7; text-decoration: underline; }
|
||||
|
||||
.quick-add { display: grid; gap: 9px; padding-top: 10px; }
|
||||
.quick-add-actions { gap: 6px; }
|
||||
|
||||
.quick-add { display: grid; gap: 8px; padding-top: 10px; }
|
||||
.quick-add-actions { gap: 5px; }
|
||||
.add-card-button, .add-list-button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
gap: 6px;
|
||||
padding: 9px;
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 9px;
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.83rem;
|
||||
font-weight: 680;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
.add-card-button { margin-top: 8px; }
|
||||
.add-card-button:hover, .add-list-button:hover { border-color: var(--border-strong); color: var(--teal); background: rgba(98, 213, 199, 0.06); }
|
||||
.add-card-button:hover, .add-list-button:hover { border-color: var(--border-strong); color: var(--teal); background: var(--teal-soft); }
|
||||
|
||||
.add-list-column { padding: 8px; border-style: dashed; box-shadow: none; background: rgba(17, 27, 45, 0.42); }
|
||||
.add-list-button { min-height: 46px; }
|
||||
.add-list-column {
|
||||
min-height: 76px;
|
||||
padding: 8px;
|
||||
border-style: dashed;
|
||||
border-color: rgba(160, 183, 220, 0.22);
|
||||
box-shadow: none;
|
||||
background: rgba(17, 27, 45, 0.38);
|
||||
}
|
||||
|
||||
.list-empty { margin: 12px 5px; color: var(--faint); font-size: 0.78rem; line-height: 1.45; text-align: center; }
|
||||
.add-list-column .quick-add { padding-top: 0; }
|
||||
.add-list-button { min-height: 58px; justify-content: center; text-align: center; }
|
||||
.add-list-button span, .add-card-button span { color: var(--teal); font-size: 1.12rem; line-height: 1; }
|
||||
|
||||
.loading-card, .fatal-state {
|
||||
max-width: 520px;
|
||||
margin: 80px auto;
|
||||
padding: 28px;
|
||||
.list-empty { margin: 14px 6px; color: var(--faint); font-size: 0.79rem; line-height: 1.5; text-align: center; }
|
||||
|
||||
.empty-board, .loading-card, .fatal-state {
|
||||
max-width: 550px;
|
||||
margin: 86px auto;
|
||||
padding: 30px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
border-radius: 20px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
background: linear-gradient(145deg, rgba(23, 36, 58, 0.94), rgba(14, 24, 41, 0.94));
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.loading-card::after { content: ""; display: block; width: 52%; height: 6px; margin: 17px auto 0; border-radius: 99px; background: linear-gradient(90deg, transparent, var(--teal), transparent); animation: shimmer 1.25s infinite; }
|
||||
.fatal-state h1 { margin-top: 0; color: var(--ink); font-size: 1.25rem; }
|
||||
.empty-board h2, .fatal-state h1 { margin: 12px 0 7px; color: var(--ink); font-size: 1.28rem; letter-spacing: -0.025em; }
|
||||
.empty-board p, .fatal-state p { margin: 0; line-height: 1.5; }
|
||||
.empty-state-mark { display: grid; width: 42px; height: 42px; margin: 0 auto; place-items: center; border-radius: 14px; color: #07131e; background: linear-gradient(135deg, var(--teal), var(--indigo)); }
|
||||
|
||||
.dialog-backdrop {
|
||||
.loading-card::after { content: ""; display: block; width: 52%; height: 6px; margin: 18px auto 0; border-radius: 99px; background: linear-gradient(90deg, transparent, var(--teal), transparent); animation: shimmer 1.25s infinite; }
|
||||
|
||||
.detail-backdrop, .dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 18px;
|
||||
background: rgba(2, 7, 15, 0.7);
|
||||
backdrop-filter: blur(3px);
|
||||
background: rgba(2, 7, 15, 0.72);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.detail-backdrop { z-index: 30; animation: overlay-in 160ms ease-out; }
|
||||
|
||||
.card-detail {
|
||||
width: min(100%, 760px);
|
||||
max-height: min(800px, calc(100vh - 36px));
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(163, 190, 231, 0.27);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(150deg, #192842, #111d32);
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.58);
|
||||
animation: modal-in 180ms ease-out;
|
||||
}
|
||||
|
||||
.detail-header { justify-content: space-between; gap: 14px; padding: 17px 18px 0; }
|
||||
|
||||
.detail-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; color: var(--faint); font-size: 0.77rem; font-weight: 680; }
|
||||
.detail-breadcrumb strong { color: var(--teal); font-weight: 760; }
|
||||
.detail-close { flex: 0 0 auto; width: 38px; min-height: 38px; border-color: transparent; font-size: 1.38rem; }
|
||||
|
||||
.detail-content { padding: 8px clamp(19px, 4vw, 34px) 26px; }
|
||||
.detail-title-row { align-items: flex-start; gap: 10px; }
|
||||
.detail-grip { padding-top: 13px; color: var(--faint); font-size: 1.1rem; letter-spacing: -0.2em; }
|
||||
|
||||
.detail-title-input {
|
||||
padding: 5px 0 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
color: var(--ink);
|
||||
font-size: clamp(1.48rem, 3.3vw, 2rem);
|
||||
font-weight: 770;
|
||||
letter-spacing: -0.048em;
|
||||
line-height: 1.16;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.detail-title-input:hover { border-color: rgba(151, 174, 211, 0.24); background: rgba(7, 14, 27, 0.2); }
|
||||
.detail-title-input:focus { padding-right: 8px; padding-left: 8px; border-color: var(--teal); background: rgba(7, 14, 27, 0.48); }
|
||||
|
||||
.detail-properties {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 18px 0 26px 27px;
|
||||
}
|
||||
|
||||
.detail-property { min-height: 66px; display: grid; align-content: center; gap: 5px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: rgba(5, 12, 26, 0.22); }
|
||||
.detail-property > span { color: var(--faint); font-size: 0.69rem; font-weight: 750; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||
.detail-property strong { overflow: hidden; color: var(--ink); font-size: 0.87rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.detail-status { display: flex; align-content: initial; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
|
||||
.status-pill {
|
||||
min-height: 32px;
|
||||
padding: 5px 9px;
|
||||
border: 1px solid rgba(145, 164, 255, 0.32);
|
||||
border-radius: 999px;
|
||||
color: #cdd5ff;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 740;
|
||||
background: rgba(145, 164, 255, 0.1);
|
||||
transition: transform 140ms ease, border-color 140ms ease, color 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
.status-pill:hover:not(:disabled) { transform: translateY(-1px); border-color: var(--teal); color: var(--teal); }
|
||||
.status-pill.is-done { border-color: rgba(101, 218, 203, 0.45); color: #07161d; background: var(--teal); }
|
||||
|
||||
.detail-description { margin-left: 27px; }
|
||||
.detail-section-heading { justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.detail-section-heading h2 { margin: 3px 0 0; color: var(--ink); font-size: 1.03rem; letter-spacing: -0.02em; }
|
||||
|
||||
.unsaved-chip { padding: 4px 8px; border: 1px solid rgba(145, 164, 255, 0.34); border-radius: 999px; color: #cbd4ff; font-size: 0.69rem; font-weight: 720; background: rgba(145, 164, 255, 0.11); }
|
||||
.detail-description textarea { min-height: 150px; }
|
||||
|
||||
.detail-save-row { justify-content: space-between; gap: 16px; margin-top: 9px; color: var(--faint); font-size: 0.74rem; line-height: 1.4; }
|
||||
.detail-save-row .button { flex: 0 0 auto; }
|
||||
|
||||
.detail-footer { justify-content: space-between; gap: 18px; margin: 28px 0 0 27px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
|
||||
.detail-timestamps { display: flex; flex-wrap: wrap; gap: 16px; margin: 0; }
|
||||
.detail-timestamps div { display: grid; gap: 3px; }
|
||||
.detail-timestamps dt { color: var(--faint); font-size: 0.67rem; font-weight: 740; letter-spacing: 0.075em; text-transform: uppercase; }
|
||||
.detail-timestamps dd { margin: 0; color: var(--muted); font-size: 0.75rem; }
|
||||
.detail-delete { flex: 0 0 auto; }
|
||||
|
||||
.dialog-backdrop { z-index: 45; }
|
||||
|
||||
.confirm-dialog {
|
||||
width: min(100%, 410px);
|
||||
width: min(100%, 420px);
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(151, 174, 211, 0.28);
|
||||
border-radius: 16px;
|
||||
background: #14213a;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(163, 190, 231, 0.3);
|
||||
border-radius: 17px;
|
||||
background: #172640;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.55);
|
||||
animation: modal-in 160ms ease-out;
|
||||
}
|
||||
|
||||
.confirm-dialog h2 { margin: 0; font-size: 1.15rem; }
|
||||
.confirm-dialog p { margin: 10px 0 22px; color: var(--muted); line-height: 1.45; }
|
||||
.confirm-dialog h2 { margin: 0; color: var(--ink); font-size: 1.16rem; letter-spacing: -0.02em; }
|
||||
.confirm-dialog p { margin: 10px 0 23px; color: var(--muted); line-height: 1.5; }
|
||||
.dialog-actions { justify-content: flex-end; gap: 8px; }
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
z-index: 40;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
width: min(350px, calc(100vw - 36px));
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 12px 11px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--teal);
|
||||
border-radius: 11px;
|
||||
color: var(--ink);
|
||||
background: #17243a;
|
||||
box-shadow: var(--shadow);
|
||||
animation: toast-in 180ms ease-out;
|
||||
width: min(360px, calc(100vw - 36px));
|
||||
}
|
||||
|
||||
.toast { display: flex; align-items: center; gap: 12px; padding: 12px 12px 12px 14px; border: 1px solid var(--border); border-left: 3px solid var(--teal); border-radius: 12px; color: var(--ink); background: #182741; box-shadow: var(--shadow); animation: toast-in 180ms ease-out; }
|
||||
.toast-error { border-left-color: var(--danger); }
|
||||
.toast-info { border-left-color: var(--indigo); }
|
||||
.toast span { flex: 1; font-size: 0.86rem; }
|
||||
.toast button { width: 26px; height: 26px; border: 0; border-radius: 6px; color: var(--muted); font-size: 1.2rem; cursor: pointer; background: transparent; }
|
||||
.toast button:hover { color: var(--ink); background: rgba(255,255,255,0.07); }
|
||||
.toast span { flex: 1; font-size: 0.86rem; line-height: 1.35; }
|
||||
.toast button { width: 28px; min-height: 28px; padding: 0; border: 0; border-radius: 7px; color: var(--muted); font-size: 1.2rem; cursor: pointer; background: transparent; }
|
||||
.toast button:hover { color: var(--ink); background: rgba(255, 255, 255, 0.07); }
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(9px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
|
||||
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes modal-in { from { opacity: 0; transform: translateY(9px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.topbar { min-height: 66px; padding: 10px 16px; gap: 12px; }
|
||||
.topbar-board { display: none; }
|
||||
.header-edit-button { min-width: 44px; padding: 8px 10px; }
|
||||
.board-area { padding: 25px 16px 26px; overflow: hidden; }
|
||||
.board-intro { align-items: flex-start; margin-bottom: 19px; }
|
||||
.board-hint { display: none; }
|
||||
.board-rail { width: calc(100% + 16px); margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
|
||||
.list-column, .add-list-column { scroll-snap-align: start; }
|
||||
.card-detail { max-height: calc(100vh - 20px); border-radius: 18px; }
|
||||
.detail-backdrop, .dialog-backdrop { padding: 10px; }
|
||||
.detail-header { padding: 13px 13px 0; }
|
||||
.detail-content { padding: 6px 18px 20px; }
|
||||
.detail-properties { grid-template-columns: 1fr; margin-left: 0; }
|
||||
.detail-description, .detail-footer { margin-left: 0; }
|
||||
.detail-footer { align-items: flex-start; flex-direction: column; gap: 12px; }
|
||||
.detail-delete { padding-left: 0; }
|
||||
.detail-save-row { align-items: flex-start; flex-direction: column; gap: 9px; }
|
||||
.detail-save-row .button { width: 100%; min-height: 44px; }
|
||||
}
|
||||
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topbar { min-height: 64px; padding: 10px 16px; }
|
||||
.topbar-board { display: none; }
|
||||
.board-area { padding: 24px 16px; overflow: hidden; }
|
||||
.board-intro { align-items: flex-start; margin-bottom: 18px; }
|
||||
.board-hint { display: none; }
|
||||
.board-rail { overflow-x: auto; margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
|
||||
.list-column, .add-list-column { scroll-snap-align: start; }
|
||||
.button, .icon-button, .add-card-button { min-height: 44px; }
|
||||
.icon-button { width: 44px; }
|
||||
@media (max-width: 430px) {
|
||||
.brand { font-size: 1.18rem; }
|
||||
.header-edit-button { font-size: 0; }
|
||||
.header-edit-button span { font-size: 1rem; }
|
||||
.list-column, .add-list-column { width: calc(100vw - 48px); flex-basis: calc(100vw - 48px); }
|
||||
.detail-timestamps { display: grid; gap: 9px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user