Files
FabledScribe/frontend/src/views/TaskEditorView.vue
T
bvandeusen 659c08def5 Keyboard navigation improvements across all editors
TiptapEditor.vue (central — applies to all three editors):
- Escape inside editor blurs it and emits 'escape' event to parent

TagInput.vue (central — applies everywhere tags are used):
- ArrowUp/ArrowDown navigate autocomplete suggestion list with visual highlight
- Enter confirms the keyboard-selected suggestion instead of typed text

NoteEditorView, TaskEditorView, WorkspaceNoteEditor (per-editor wiring):
- @escape on TiptapEditor returns focus to the title input (ref="titleRef")
- Ctrl+E from title input or editor main column jumps focus into editor body
- Ctrl+S on title input saves (was already on editor area; now consistent)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 18:46:08 -05:00

856 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { useAutoSave } from "@/composables/useAutoSave";
import { useEditorGuards } from "@/composables/useEditorGuards";
import { useTagSuggestions } from "@/composables/useTagSuggestions";
import { useFloatingAssist } from "@/composables/useFloatingAssist";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
import TaskLogSection from "@/components/TaskLogSection.vue";
import InlineAssistPanel from "@/components/InlineAssistPanel.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const toast = useToastStore();
const title = ref("");
const body = ref("");
const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const parentId = ref<number | null>(null);
const parentTitle = ref("");
const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
const parentSearchLoading = ref(false);
const showParentDropdown = ref(false);
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
const dirty = ref(false);
const saving = ref(false);
// Sub-tasks
interface SubTask { id: number; title: string; status: string }
const subTasks = ref<SubTask[]>([]);
const subTasksLoading = ref(false);
const addingSubTask = ref(false);
const newSubTaskTitle = ref("");
async function loadSubTasks() {
if (!taskId.value) return;
subTasksLoading.value = true;
try {
const data = await apiGet<{ notes: SubTask[] }>(
`/api/notes?parent_id=${taskId.value}&is_task=true&limit=100`
);
subTasks.value = data.notes;
} catch {
// silent
} finally {
subTasksLoading.value = false;
}
}
async function createSubTask() {
const title = newSubTaskTitle.value.trim();
if (!title || !taskId.value) return;
try {
const created = await apiPost<SubTask>("/api/notes", {
title,
status: "todo",
priority: "none",
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: taskId.value,
});
subTasks.value = [...subTasks.value, created];
newSubTaskTitle.value = "";
addingSubTask.value = false;
} catch {
toast.show("Failed to create sub-task", "error");
}
}
async function toggleSubTask(sub: SubTask) {
const newStatus = sub.status === "done" ? "todo" : "done";
try {
await apiPatch(`/api/notes/${sub.id}`, { status: newStatus });
sub.status = newStatus;
} catch {
toast.show("Failed to update sub-task", "error");
}
}
const showPreview = ref(true);
const sidebarOpen = ref(true);
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
return (editorRef.value?.editor as Editor | undefined) ?? null;
});
const taskId = computed(() =>
route.params.id ? Number(route.params.id) : null
);
const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(body.value));
// AI Assist
const assist = useAssist(body, taskId, projectId);
const assistLabel = computed(() => {
if (assist.isProofreading.value) return "Proofreading document...";
const t = assist.target.value;
if (!t) return "Generating...";
const heading = t.text.split("\n")[0];
return `Revising: "${heading.length > 50 ? heading.slice(0, 50) + "..." : heading}"`;
});
// Assist panel toggle (persisted)
const ASSIST_KEY = 'fa-assist-open';
const assistOpen = ref(localStorage.getItem(ASSIST_KEY) !== 'false');
function toggleAssist() {
assistOpen.value = !assistOpen.value;
localStorage.setItem(ASSIST_KEY, String(assistOpen.value));
}
// Floating inline assist button
const instructionRef = ref<HTMLTextAreaElement | null>(null);
const { floatingAssist, onSelectionChange, handleInlineAssist } = useFloatingAssist(
({ start, end }) => {
assist.selectTextRange(start, end);
assistOpen.value = true;
localStorage.setItem(ASSIST_KEY, 'true');
nextTick(() => instructionRef.value?.focus());
}
);
function handleAssistAccept() {
const newBody = assist.accept();
if (newBody !== body.value) {
body.value = newBody;
markDirty();
toast.show("Section updated");
}
}
function truncateTarget(text: string, max = 60): string {
const first = text.split("\n")[0];
return first.length > max ? first.slice(0, max) + "..." : first;
}
// Tag suggestions
const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTagSuggestion, dismissTagSuggestions } =
useTagSuggestions(title, body, tags, markDirty);
let savedTitle = "";
let savedBody = "";
let savedTags: string[] = [];
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedParentId: number | null = null;
function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId ||
parentId.value !== savedParentId;
}
function onBodyUpdate(newVal: string) {
body.value = newVal;
markDirty();
}
function onParentSearchInput() {
if (parentSearchTimer) clearTimeout(parentSearchTimer);
if (!parentSearchQuery.value.trim()) {
parentSearchResults.value = [];
showParentDropdown.value = false;
return;
}
parentSearchTimer = setTimeout(async () => {
const q = parentSearchQuery.value.trim();
if (!q) return;
parentSearchLoading.value = true;
showParentDropdown.value = true;
try {
const data = await apiGet<{ notes: Array<{ id: number; title: string }> }>(
`/api/notes?q=${encodeURIComponent(q)}&type=task&limit=8`
);
parentSearchResults.value = data.notes.filter((t) =>
!taskId.value || t.id !== taskId.value
);
} catch {
parentSearchResults.value = [];
} finally {
parentSearchLoading.value = false;
}
}, 250);
}
function selectParentTask(task: { id: number; title: string }) {
parentId.value = task.id;
parentTitle.value = task.title;
parentSearchQuery.value = task.title;
showParentDropdown.value = false;
markDirty();
}
function onParentFocus() {
if (parentSearchQuery.value) showParentDropdown.value = true;
}
function hideParentDropdown() {
setTimeout(() => { showParentDropdown.value = false; }, 200);
}
function clearParentTask() {
parentId.value = null;
parentTitle.value = "";
parentSearchQuery.value = "";
parentSearchResults.value = [];
showParentDropdown.value = false;
markDirty();
}
onMounted(async () => {
if (taskId.value) {
await store.fetchTask(taskId.value);
if (store.currentTask) {
title.value = store.currentTask.title;
body.value = store.currentTask.body;
tags.value = [...(store.currentTask.tags || [])];
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || "";
const taskRec = store.currentTask as Record<string, unknown>;
projectId.value = (taskRec.project_id as number | null) ?? null;
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
parentId.value = (taskRec.parent_id as number | null) ?? null;
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
parentSearchQuery.value = parentTitle.value;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
}
loadSubTasks();
} else {
// Pre-fill from query params when creating a new task
if (route.query.projectId) projectId.value = Number(route.query.projectId);
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
if (route.query.parentId) parentId.value = Number(route.query.parentId);
}
});
async function save() {
if (saving.value) return;
saving.value = true;
try {
const data = {
title: title.value,
body: body.value,
tags: tags.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
dirty.value = false;
toast.show("Task saved");
} else {
const task = await store.createTask(data);
dirty.value = false;
toast.show("Task created");
router.push(`/tasks/${task.id}`);
}
} catch {
toast.show("Failed to save task", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
function remove() {
if (taskId.value) {
showDeleteConfirm.value = true;
}
}
async function confirmDelete() {
showDeleteConfirm.value = false;
if (!taskId.value) return;
try {
await store.deleteTask(taskId.value);
dirty.value = false;
toast.show("Task deleted");
router.push("/tasks");
} catch {
toast.show("Failed to delete task", "error");
}
}
// Auto-save every 5 minutes when editing an existing task
async function doAutoSave() {
if (!isEditing.value || saving.value) return;
saving.value = true;
try {
await store.updateTask(taskId.value!, {
title: title.value,
body: body.value,
tags: tags.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
} as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
// Silent — user can still save manually
} finally {
saving.value = false;
}
}
useAutoSave(dirty, saving, doAutoSave);
useEditorGuards(dirty, save);
</script>
<template>
<main class="editor-page task-editor-page">
<div class="editor-header">
<div class="toolbar">
<router-link to="/tasks" class="btn-back">Back</router-link>
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
Delete
</button>
<button class="btn-assist-toggle" :class="{ active: assistOpen }" @click="toggleAssist">
Assist
</button>
</div>
<input
v-model="title"
type="text"
placeholder="Task title"
ref="titleRef"
class="title-input"
@input="markDirty"
@keydown.ctrl.s.prevent="save"
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
/>
</div><!-- /editor-header: title only -->
<!-- Two-column body: main (editor+log) | sidebar (metadata) -->
<div class="task-body">
<!-- ── Main column ─────────────────────────────────────────── -->
<div class="task-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<!-- Write / Preview tabs + toolbar sit above the editor -->
<div class="body-tabs-row">
<div class="editor-tabs">
<button :class="['tab', { active: !showPreview }]" @click="showPreview = false">Write</button>
<button :class="['tab', { active: showPreview }]" @click="showPreview = true">Preview</button>
</div>
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
</div>
<!-- Inline assist output -->
<InlineAssistPanel
v-if="assist.state.value !== 'idle'"
:phase="assist.state.value as 'streaming' | 'review'"
:label="assistLabel"
:streaming-text="assist.streamingText.value"
:diff="assist.diff.value"
:proposed-text="assist.proposedText.value"
@accept="handleAssistAccept"
@reject="assist.reject()"
@cancel="assist.clearSelection()"
/>
<!-- Editor / preview (hidden during review) -->
<div v-show="!showPreview && assist.state.value !== 'review'" class="body-editor-wrap">
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Describe this task..."
@update:modelValue="onBodyUpdate"
@selectionChange="onSelectionChange"
@escape="titleRef?.focus()"
/>
</div>
<div
v-show="showPreview && assist.state.value !== 'review'"
class="preview-pane prose"
v-html="renderedPreview"
></div>
<!-- Work log -->
<TaskLogSection v-if="taskId" :task-id="taskId" class="body-log" />
</div>
<!-- ── Sidebar ──────────────────────────────────────────────── -->
<aside class="task-sidebar">
<!-- Mobile accordion toggle -->
<button class="sidebar-toggle" @click="sidebarOpen = !sidebarOpen">
Details {{ sidebarOpen ? '▴' : '▾' }}
</button>
<div :class="['sidebar-content', { 'sidebar-open': sidebarOpen }]">
<!-- Status / Priority / Due date -->
<div class="sb-field">
<label class="sb-label">Status</label>
<select v-model="status" @change="markDirty" class="sb-select">
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div class="sb-field">
<label class="sb-label">Priority</label>
<select v-model="priority" @change="markDirty" class="sb-select">
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div class="sb-field">
<label class="sb-label">Due Date</label>
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Milestone</label>
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
</div>
<!-- Parent task -->
<div class="sb-field">
<label class="sb-label">Parent Task</label>
<div class="parent-search-wrapper">
<div class="parent-input-row">
<input
v-model="parentSearchQuery"
type="text"
class="sb-input"
placeholder="Search tasks..."
@input="onParentSearchInput"
@focus="onParentFocus"
@blur="hideParentDropdown"
/>
<button v-if="parentId" class="btn-clear-parent" @click="clearParentTask" title="Clear">&times;</button>
</div>
<div v-if="showParentDropdown" class="parent-dropdown">
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
<div
v-for="task in parentSearchResults"
:key="task.id"
class="parent-dropdown-item"
@mousedown.prevent="selectParentTask(task)"
>{{ task.title || "Untitled" }}</div>
<div v-if="!parentSearchLoading && parentSearchResults.length === 0" class="parent-dropdown-item parent-empty">No tasks found</div>
</div>
</div>
</div>
<div class="sb-divider"></div>
<!-- Sub-tasks -->
<div v-if="isEditing" class="subtasks-section">
<div class="subtasks-header">
<span class="subtasks-label">Sub-tasks</span>
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
</div>
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
<template v-else>
<div v-for="sub in subTasks" :key="sub.id" class="subtask-row">
<input type="checkbox" :checked="sub.status === 'done'" @change="toggleSubTask(sub)" class="subtask-checkbox" />
<router-link :to="`/tasks/${sub.id}`" :class="['subtask-title', { done: sub.status === 'done' }]">
{{ sub.title || "Untitled" }}
</router-link>
</div>
<p v-if="!subTasks.length && !addingSubTask" class="subtasks-empty">No sub-tasks yet.</p>
<div v-if="addingSubTask" class="subtask-add-row">
<input
v-model="newSubTaskTitle"
class="subtask-input"
placeholder="Sub-task title"
@keydown.enter="createSubTask"
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
autofocus
/>
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
</div>
</template>
</div>
<div class="sb-divider"></div>
<!-- Tags -->
<div class="sb-field">
<label class="sb-label">Tags</label>
<TagInput
v-model="tags"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@update:modelValue="markDirty"
/>
</div>
<div class="tag-suggest-row">
<button class="btn-suggest-tags" @click="fetchTagSuggestions" :disabled="suggestingTags">
{{ suggestingTags ? "Suggesting..." : "Suggest tags" }}
</button>
<template v-if="suggestedTags.length > 0">
<button
v-for="tag in suggestedTags"
:key="tag"
:class="['tag-pill', { applied: appliedTags.has(tag) }]"
:disabled="appliedTags.has(tag)"
@click="applyTagSuggestion(tag)"
>
#{{ tag }}
<span v-if="appliedTags.has(tag)" class="tag-check">&#10003;</span>
</button>
<button class="btn-dismiss-tags" @click="dismissTagSuggestions">&times;</button>
</template>
</div>
</div><!-- /sidebar-content -->
</aside><!-- /task-sidebar -->
<!-- Assist panel overlays on top of sidebar when open -->
<aside v-if="assistOpen" class="assist-panel">
<div class="assist-panel-header">
<span class="assist-panel-title"> AI Assist</span>
<button class="btn-proofread" @click="assist.proofread()" :disabled="assist.state.value === 'streaming'">Proofread</button>
<button class="btn-close-assist" @click="toggleAssist">×</button>
</div>
<div class="assist-panel-body">
<div v-if="assist.state.value === 'idle'" class="assist-idle">
<div class="assist-sections-label">Sections</div>
<div class="assist-sections">
<div
v-for="(section, i) in assist.sections.value"
:key="i"
:class="['assist-section-item', { selected: assist.selectedSection.value === section }]"
@click="assist.selectSection(section)"
>{{ section.heading || '(preamble)' }}</div>
<div v-if="!assist.sections.value.length" class="assist-empty">Write some content to get started.</div>
</div>
<template v-if="assist.target.value">
<div class="assist-target-preview">Editing: <em>{{ truncateTarget(assist.target.value.text) }}</em></div>
<textarea
ref="instructionRef"
v-model="assist.instruction.value"
placeholder="What should I do with this section?"
class="assist-instruction"
rows="3"
@keydown.enter.exact.prevent="assist.canSubmit.value && assist.submit()"
></textarea>
<div class="assist-input-actions">
<button class="btn-generate" @click="assist.submit()" :disabled="!assist.canSubmit.value">Generate</button>
<button class="btn-clear" @click="assist.clearSelection()">Clear</button>
</div>
</template>
<div v-else class="assist-hint">Select a section above or highlight text in the editor.</div>
</div>
<div v-if="assist.error.value" class="assist-error">{{ assist.error.value }}</div>
<div v-if="assist.state.value !== 'idle'" class="assist-active-hint">
{{ assist.state.value === 'streaming' ? 'Generating in editor…' : 'Review diff in editor ↑' }}
</div>
</div>
</aside>
</div><!-- /task-body -->
<!-- Floating inline assist button -->
<teleport to="body">
<button
v-if="floatingAssist.show"
class="inline-assist-btn"
:style="{ top: floatingAssist.top + 'px', left: floatingAssist.left + 'px' }"
@mousedown.prevent="handleInlineAssist"
> Assist</button>
</teleport>
<!-- Delete confirmation -->
<ConfirmDialog
v-if="showDeleteConfirm"
title="Delete Task"
message="Are you sure you want to delete this task? This cannot be undone."
@confirm="confirmDelete"
@cancel="showDeleteConfirm = false"
/>
</main>
</template>
<style src="@/assets/editor-shared.css" />
<style scoped>
/* ── Two-column task layout ─────────────────────────────────── */
.task-editor-page {
/* override shared max-width; task editor can be wider */
max-width: 1600px;
}
/* Replace .editor-body for task editor */
.task-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
position: relative;
}
/* Main column: editor + work log */
.task-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.75rem 1.25rem 2rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.body-tabs-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.body-editor-wrap {
min-height: 200px;
}
.body-log {
/* TaskLogSection already has its own border/bg */
}
/* Right sidebar: metadata fields */
.task-sidebar {
width: 280px;
flex-shrink: 0;
border-left: 1px solid var(--color-border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* Parent task search */
.parent-search-wrapper { position: relative; }
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
.parent-input-row .sb-input { flex: 1; }
.btn-clear-parent {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
flex-shrink: 0;
}
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--color-shadow);
z-index: 50;
max-height: 200px;
overflow-y: auto;
}
.parent-dropdown-item {
padding: 0.4rem 0.65rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
.parent-empty { color: var(--color-text-muted); cursor: default; }
.parent-empty:hover { background: none; }
/* Sub-tasks */
.subtasks-section {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.5rem 0.65rem;
background: var(--color-bg-secondary);
}
.subtasks-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.subtasks-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.btn-add-subtask {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
font-size: 0.78rem;
font-family: inherit;
padding: 0.1rem 0.2rem;
}
.btn-add-subtask:hover { opacity: 0.8; }
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
.subtask-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem 0;
}
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
.subtask-title {
font-size: 0.83rem;
color: var(--color-text);
text-decoration: none;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.subtask-title:hover { color: var(--color-primary); }
.subtask-title.done { text-decoration: line-through; color: var(--color-text-muted); }
.subtasks-empty { font-size: 0.78rem; color: var(--color-text-muted); margin: 0; }
.subtask-add-row {
display: flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.3rem;
}
.subtask-input {
flex: 1;
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.83rem;
font-family: inherit;
}
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
.btn-subtask-confirm {
padding: 0.25rem 0.5rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-subtask-cancel {
padding: 0.25rem 0.5rem;
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
.task-main { padding: 0.75rem 1rem 1rem; overflow-y: visible; }
.task-sidebar {
width: 100%;
border-left: none;
border-top: 1px solid var(--color-border);
overflow-y: visible;
}
}
</style>