CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
624 lines
19 KiB
Vue
624 lines
19 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from "vue";
|
|
import { RouterLink } from "vue-router";
|
|
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
|
import { useToastStore } from "@/stores/toast";
|
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
|
import { renderMarkdown } from "@/utils/markdown";
|
|
import { Trash2, X } from "lucide-vue-next";
|
|
|
|
const props = defineProps<{ projectId: number }>();
|
|
|
|
const toast = useToastStore();
|
|
|
|
interface Milestone {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
order_index: number;
|
|
}
|
|
|
|
interface Task {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
priority: string;
|
|
milestone_id: number | null;
|
|
due_date: string | null;
|
|
updated_at: string;
|
|
body?: string;
|
|
}
|
|
|
|
const tasks = ref<Task[]>([]);
|
|
const milestones = ref<Milestone[]>([]);
|
|
const loading = ref(false);
|
|
|
|
// Detail slide-over
|
|
const activeTask = ref<Task | null>(null);
|
|
const taskBody = ref("");
|
|
const loadingBody = ref(false);
|
|
|
|
// Collapsed milestone groups (set of milestone IDs, null = "No Milestone" group)
|
|
const collapsedGroups = ref<Set<number | null>>(new Set());
|
|
|
|
// New task quick-add
|
|
const newTaskTitle = ref("");
|
|
const addingTask = ref(false);
|
|
|
|
const STATUS_CYCLE: Record<string, string> = {
|
|
todo: "in_progress",
|
|
in_progress: "done",
|
|
done: "todo",
|
|
};
|
|
|
|
const STATUS_ICON: Record<string, string> = {
|
|
todo: "○",
|
|
in_progress: "▶",
|
|
done: "✓",
|
|
};
|
|
|
|
const PRIORITY_CLASS: Record<string, string> = {
|
|
high: "priority-high",
|
|
medium: "priority-medium",
|
|
low: "priority-low",
|
|
};
|
|
|
|
function isRowOverdue(task: Task): boolean {
|
|
if (!task.due_date || task.status === "done") return false;
|
|
return task.due_date < new Date().toISOString().slice(0, 10);
|
|
}
|
|
|
|
// Group tasks by milestone, "No Milestone" first
|
|
const groupedTasks = computed(() => {
|
|
const noMs = tasks.value.filter((t) => t.milestone_id === null);
|
|
const msGroups = milestones.value.map((ms) => ({
|
|
milestone: ms,
|
|
tasks: tasks.value.filter((t) => t.milestone_id === ms.id),
|
|
}));
|
|
return { noMilestone: noMs, milestoneGroups: msGroups };
|
|
});
|
|
|
|
async function loadAll() {
|
|
loading.value = true;
|
|
try {
|
|
const [tasksRes, msRes] = await Promise.all([
|
|
apiGet<{ notes: Task[] }>(`/api/projects/${props.projectId}/notes?type=task&limit=200`),
|
|
apiGet<{ milestones: Milestone[] }>(`/api/projects/${props.projectId}/milestones`),
|
|
]);
|
|
tasks.value = tasksRes.notes ?? [];
|
|
milestones.value = msRes.milestones ?? [];
|
|
} catch {
|
|
toast.show("Failed to load tasks", "error");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function cycleStatus(task: Task, e: Event) {
|
|
e.stopPropagation();
|
|
const next = STATUS_CYCLE[task.status] ?? "todo";
|
|
try {
|
|
await apiPatch(`/api/tasks/${task.id}/status`, { status: next });
|
|
task.status = next;
|
|
if (activeTask.value?.id === task.id) activeTask.value.status = next;
|
|
} catch {
|
|
toast.show("Failed to update status", "error");
|
|
}
|
|
}
|
|
|
|
function toggleGroup(key: number | null) {
|
|
if (collapsedGroups.value.has(key)) {
|
|
collapsedGroups.value.delete(key);
|
|
} else {
|
|
collapsedGroups.value.add(key);
|
|
}
|
|
}
|
|
|
|
async function openTask(task: Task) {
|
|
activeTask.value = task;
|
|
taskBody.value = task.body ?? "";
|
|
loadingBody.value = true;
|
|
try {
|
|
const full = await apiGet<Task>(`/api/tasks/${task.id}`);
|
|
taskBody.value = full.body ?? "";
|
|
// Update cached task so re-opens don't re-fetch if unchanged
|
|
task.body = full.body;
|
|
} catch {
|
|
// Non-critical — body just won't show
|
|
} finally {
|
|
loadingBody.value = false;
|
|
}
|
|
}
|
|
|
|
function closeTask() {
|
|
activeTask.value = null;
|
|
taskBody.value = "";
|
|
deleteConfirmPending.value = false;
|
|
}
|
|
|
|
async function addTask() {
|
|
const title = newTaskTitle.value.trim();
|
|
if (!title) return;
|
|
addingTask.value = true;
|
|
try {
|
|
const task = await apiPost<Task>("/api/tasks", {
|
|
title,
|
|
project_id: props.projectId,
|
|
status: "todo",
|
|
});
|
|
tasks.value.unshift(task);
|
|
newTaskTitle.value = "";
|
|
} catch {
|
|
toast.show("Failed to create task", "error");
|
|
} finally {
|
|
addingTask.value = false;
|
|
}
|
|
}
|
|
|
|
const deletingTask = ref(false);
|
|
const deleteConfirmPending = ref(false);
|
|
const changingMilestone = ref(false);
|
|
|
|
async function setMilestone(milestoneId: number | null) {
|
|
if (!activeTask.value) return;
|
|
changingMilestone.value = true;
|
|
try {
|
|
await apiPatch(`/api/notes/${activeTask.value.id}`, { milestone_id: milestoneId });
|
|
activeTask.value.milestone_id = milestoneId;
|
|
// Update in tasks list too
|
|
const t = tasks.value.find((x) => x.id === activeTask.value!.id);
|
|
if (t) t.milestone_id = milestoneId;
|
|
} catch {
|
|
toast.show("Failed to update milestone", "error");
|
|
} finally {
|
|
changingMilestone.value = false;
|
|
}
|
|
}
|
|
|
|
async function deleteActiveTask() {
|
|
if (!activeTask.value) return;
|
|
if (!deleteConfirmPending.value) {
|
|
deleteConfirmPending.value = true;
|
|
return;
|
|
}
|
|
deletingTask.value = true;
|
|
try {
|
|
await apiDelete(`/api/tasks/${activeTask.value.id}`);
|
|
tasks.value = tasks.value.filter((t) => t.id !== activeTask.value!.id);
|
|
activeTask.value = null;
|
|
deleteConfirmPending.value = false;
|
|
} catch {
|
|
toast.show("Failed to delete task", "error");
|
|
} finally {
|
|
deletingTask.value = false;
|
|
}
|
|
}
|
|
|
|
function cancelDeleteTask() {
|
|
deleteConfirmPending.value = false;
|
|
}
|
|
|
|
function formatDate(iso: string): string {
|
|
const d = new Date(iso);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - d.getTime();
|
|
const diffMin = Math.floor(diffMs / 60_000);
|
|
const diffHrs = Math.floor(diffMs / 3_600_000);
|
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
if (diffMin < 1) return "just now";
|
|
if (diffMin < 60) return `${diffMin}m ago`;
|
|
if (diffHrs < 24) return `${diffHrs}h ago`;
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
}
|
|
|
|
onMounted(loadAll);
|
|
defineExpose({ reload: loadAll });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="ws-task-panel">
|
|
|
|
<!-- Task list — always visible, shrinks when detail is open -->
|
|
<div :class="['task-list-view', { 'has-detail': !!activeTask }]">
|
|
<div class="panel-header">
|
|
<span class="panel-title">Tasks</span>
|
|
</div>
|
|
|
|
<div class="task-add">
|
|
<input
|
|
v-model="newTaskTitle"
|
|
class="task-add-input"
|
|
placeholder="New task..."
|
|
@keydown.enter="addTask"
|
|
/>
|
|
<button class="btn-primary btn-inline btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
|
</div>
|
|
|
|
<div v-if="loading" class="state-msg">Loading...</div>
|
|
|
|
<div v-else class="groups-scroll">
|
|
<!-- No Milestone group (always first) -->
|
|
<div class="ms-group">
|
|
<button class="ms-group-header" @click="toggleGroup(null)">
|
|
<span class="ms-chevron">{{ collapsedGroups.has(null) ? '▶' : '▼' }}</span>
|
|
<span class="ms-name">No Milestone</span>
|
|
<span class="ms-count">{{ groupedTasks.noMilestone.length }}</span>
|
|
</button>
|
|
<ul v-show="!collapsedGroups.has(null)" class="task-items">
|
|
<li
|
|
v-for="task in groupedTasks.noMilestone"
|
|
:key="task.id"
|
|
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
|
|
@click="openTask(task)"
|
|
>
|
|
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
|
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
|
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
|
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
|
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
|
</li>
|
|
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Milestone groups -->
|
|
<div v-for="{ milestone, tasks: msTasks } in groupedTasks.milestoneGroups" :key="milestone.id" class="ms-group">
|
|
<button class="ms-group-header" @click="toggleGroup(milestone.id)">
|
|
<span class="ms-chevron">{{ collapsedGroups.has(milestone.id) ? '▶' : '▼' }}</span>
|
|
<span class="ms-name">{{ milestone.title }}</span>
|
|
<span :class="['ms-status', `ms-status-${milestone.status}`]">{{ milestone.status.replace('_',' ') }}</span>
|
|
<span class="ms-count">{{ msTasks.length }}</span>
|
|
</button>
|
|
<ul v-show="!collapsedGroups.has(milestone.id)" class="task-items">
|
|
<li
|
|
v-for="task in msTasks"
|
|
:key="task.id"
|
|
:class="['task-row', { 'task-active': activeTask?.id === task.id }]"
|
|
@click="openTask(task)"
|
|
>
|
|
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
|
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
|
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
|
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
|
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
|
</li>
|
|
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail pane — bottom split when a task is selected -->
|
|
<Transition name="detail-fade">
|
|
<div v-if="activeTask" class="task-detail">
|
|
<div class="detail-header">
|
|
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
|
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
|
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
|
</span>
|
|
<template v-if="deleteConfirmPending">
|
|
<button class="btn-danger-outline btn-inline btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
|
<button class="btn-text" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
|
</template>
|
|
<button v-else class="btn-text btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
|
<Trash2 :size="16" />
|
|
</button>
|
|
<button class="btn-text btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
|
</div>
|
|
|
|
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
|
|
|
<div class="detail-meta">
|
|
<span v-if="activeTask.priority && activeTask.priority !== 'none'" class="meta-chip priority">{{ activeTask.priority }}</span>
|
|
<span v-if="activeTask.due_date" class="meta-chip due">Due {{ activeTask.due_date }}</span>
|
|
<select
|
|
class="milestone-select"
|
|
:value="activeTask.milestone_id ?? ''"
|
|
:disabled="changingMilestone"
|
|
@change="setMilestone(($event.target as HTMLSelectElement).value === '' ? null : Number(($event.target as HTMLSelectElement).value))"
|
|
>
|
|
<option value="">No Milestone</option>
|
|
<option v-for="ms in milestones" :key="ms.id" :value="ms.id">{{ ms.title }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="taskBody || loadingBody" class="detail-body">
|
|
<div v-if="loadingBody" class="body-loading">…</div>
|
|
<div v-else class="prose" v-html="renderMarkdown(taskBody)" />
|
|
</div>
|
|
|
|
<div class="detail-log">
|
|
<TaskLogSection :task-id="activeTask.id" />
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.ws-task-panel {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
background: var(--fs-surface-hover);
|
|
border-right: 1px solid var(--fs-border-color);
|
|
}
|
|
|
|
/* ── List view ── */
|
|
.task-list-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
.task-list-view.has-detail {
|
|
flex: 0 0 44%;
|
|
}
|
|
.task-active {
|
|
background: color-mix(in srgb, var(--fs-accent) 6%, var(--fs-surface-hover)) !important;
|
|
}
|
|
|
|
.panel-header {
|
|
padding: 0.6rem 0.75rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.panel-title {
|
|
color: var(--fs-text-tertiary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.task-add {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
padding: 0.45rem 0.6rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.task-add-input {
|
|
flex: 1;
|
|
background: var(--fs-surface-page);
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: 5px;
|
|
padding: 0.28rem 0.5rem;
|
|
font-size: 0.83rem;
|
|
color: var(--fs-text-primary);
|
|
}
|
|
.task-add-input:focus { outline: none; border-color: var(--fs-accent); }
|
|
|
|
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
|
|
|
.groups-scroll {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.ms-group {
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
}
|
|
|
|
.ms-group-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
width: 100%;
|
|
padding: 0.4rem 0.65rem;
|
|
background: var(--fs-surface-raised);
|
|
border: none;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
font-size: 0.8rem;
|
|
color: var(--fs-text-primary);
|
|
}
|
|
.ms-group-header:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover)); }
|
|
|
|
.ms-chevron { font-size: 0.6rem; color: var(--fs-text-tertiary); width: 0.8rem; }
|
|
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
|
.ms-count { font-size: 0.72rem; color: var(--fs-text-tertiary); background: var(--fs-surface-page); border-radius: 10px; padding: 0 0.4rem; }
|
|
|
|
.ms-status {
|
|
font-size: 0.68rem;
|
|
padding: 0.1rem 0.4rem;
|
|
border-radius: 10px;
|
|
text-transform: capitalize;
|
|
}
|
|
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
|
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
|
|
|
|
.task-items {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.task-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 50%, transparent);
|
|
}
|
|
.task-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
|
.task-row:last-child { border-bottom: none; }
|
|
|
|
.status-dot {
|
|
flex-shrink: 0;
|
|
width: 1.35rem;
|
|
height: 1.35rem;
|
|
border-radius: 50%;
|
|
border: 1.5px solid var(--fs-border-color);
|
|
background: none;
|
|
cursor: pointer;
|
|
font-size: 0.62rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
|
|
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
|
|
|
|
.task-title {
|
|
flex: 1;
|
|
font-size: 0.83rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--fs-text-primary);
|
|
}
|
|
.task-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
|
|
|
|
.task-age {
|
|
font-size: 0.68rem;
|
|
color: var(--fs-text-tertiary);
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
|
|
|
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--fs-text-tertiary); }
|
|
|
|
/* ── Detail pane (bottom split) ── */
|
|
.task-detail {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--fs-surface-hover);
|
|
border-top: 2px solid var(--fs-border-color);
|
|
}
|
|
|
|
.detail-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.6rem 0.75rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 0.2rem 0.55rem;
|
|
border-radius: 12px;
|
|
font-size: 0.75rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
border: 1.5px solid var(--fs-border-color);
|
|
background: none;
|
|
text-transform: capitalize;
|
|
user-select: none;
|
|
margin-left: auto;
|
|
}
|
|
.status-badge.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
|
.status-badge.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
|
|
|
.btn-edit-task { margin-left: 0.25rem; }
|
|
.btn-edit-task:hover { text-decoration: underline; }
|
|
|
|
.detail-body {
|
|
padding: 0.5rem 0.75rem 0.5rem;
|
|
border-bottom: 1px solid var(--fs-border-color);
|
|
flex-shrink: 0;
|
|
max-height: 40%;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.body-loading {
|
|
font-size: 0.8rem;
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
|
|
.detail-body .prose {
|
|
font-size: 0.83rem;
|
|
line-height: 1.5;
|
|
color: var(--fs-text-primary);
|
|
}
|
|
|
|
.btn-delete-task { margin-left: 0.25rem; }
|
|
.btn-delete-task:hover { color: var(--fs-action-destructive); }
|
|
|
|
.btn-delete-confirm { margin-left: 0.25rem; }
|
|
|
|
|
|
.detail-meta {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
padding: 0 0.75rem 0.5rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.meta-chip {
|
|
font-size: 0.72rem;
|
|
padding: 0.15rem 0.5rem;
|
|
border-radius: 10px;
|
|
background: var(--fs-surface-page);
|
|
border: 1px solid var(--fs-border-color);
|
|
color: var(--fs-text-tertiary);
|
|
text-transform: capitalize;
|
|
}
|
|
|
|
.milestone-select {
|
|
font-size: 0.72rem;
|
|
padding: 0.15rem 0.4rem;
|
|
border-radius: 10px;
|
|
background: var(--fs-surface-page);
|
|
border: 1px solid var(--fs-border-color);
|
|
color: var(--fs-text-tertiary);
|
|
cursor: pointer;
|
|
max-width: 140px;
|
|
}
|
|
.milestone-select:disabled { opacity: 0.5; cursor: default; }
|
|
.milestone-select:focus { outline: none; border-color: var(--fs-accent); }
|
|
|
|
.detail-log {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0 0.6rem 0.6rem;
|
|
border-top: 1px solid var(--fs-border-color);
|
|
}
|
|
|
|
/* Detail fade transition */
|
|
.detail-fade-enter-active,
|
|
.detail-fade-leave-active { transition: opacity 0.15s; }
|
|
.detail-fade-enter-from,
|
|
.detail-fade-leave-to { opacity: 0; }
|
|
|
|
/* Priority dots on task rows */
|
|
.priority-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
.priority-high { background: #ef4444; }
|
|
.priority-medium { background: #f59e0b; }
|
|
.priority-low { background: #3b82f6; }
|
|
|
|
/* Due date on task rows */
|
|
.task-due {
|
|
font-size: 0.65rem;
|
|
color: var(--fs-text-tertiary);
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.task-due.overdue {
|
|
color: var(--fs-error);
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Close detail button */
|
|
.btn-close-detail { margin-left: 0.2rem; }
|
|
</style>
|