Files
FabledScribe/frontend/src/views/ProjectView.vue
T
bvandeusen 0937b1761e
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 15s
CI & Build / Python tests (push) Canceled after 15s
CI & Build / integration (push) Canceled after 15s
CI & Build / Build & push image (push) Canceled after 0s
feat(design-systems): the editing surface — overrides, effective set, provenance
Milestone #254 step 5 (#2294). /design-systems is the editable half of the
surface /design already showed: that page is what the browser renders, this one
is the record that ought to decide it. Each links to the other.

The layout follows the model rather than decorating it. Two token lists, and
they are deliberately different questions:

  Overrides  — the system's own rows. Short by design, and EMPTY is the correct
               state for an app that hasn't departed from its family yet, so
               that empty state says so rather than looking unfinished.
  Effective  — what it resolves to with inheritance applied, each row labelled
               with where its value came from.

Provenance renders PER MODE when the modes disagree. A system can own `base` and
inherit `dark` at once — that is the case the value column is a map for — and a
single badge per row would have to lie about one of them. Rows whose modes agree
(the common case) keep the single badge.

"Defined here" and "overridden here" are distinct labels. Introducing a token
and shadowing an ancestor's are different acts, and `is_overridden_in` is
already false for the first.

The parent picker filters out the selected system's descendants. The server
refuses those anyway with a message naming the loop — but a refusal you cannot
trigger beats a refusal explained well. Cycles that arrive some other way still
render a truncated chain rather than freezing the tab: the client keeps the same
defensive visited-set the server has.

Three drift bugs caught while writing the styles, all of the shape this
milestone exists to surface:

  - `--color-accent` does not exist. I had used it for every focus ring and
    active border; it would have rendered as nothing at all, silently. The
    brand token is `--color-primary`.
  - focus rings are ALREADY global in theme.css (`button:focus-visible` et al).
    My per-element rules would have overridden the house ring with a different
    one — the exact "bypassed abstraction" shape from #253.
  - every existing `.btn-primary` copy uses `color: #fff`, which is rule 52's
    prohibition and 67 live violations (#2275). This one uses Parchment and
    says why in a comment, rather than becoming the 68th.

Also wires the project pointer into ProjectView's details panel, hidden entirely
when no design systems exist (rule #115 — that is the ordinary state, not a
degraded one) and saved through its own PUT, since clearing it is a real outcome
rather than an omission.
2026-07-30 17:17:43 -04:00

1513 lines
55 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
import { renderMarkdown } from "@/utils/markdown";
import ShareDialog from "@/components/ShareDialog.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import {
fetchDesignSystems,
setProjectDesignSystem,
type DesignSystem,
} from "@/api/designSystems";
import {
LayoutGrid,
Clock,
FileText,
ChevronRight,
ChevronDown,
Pencil,
Trash2,
Check,
} from "lucide-vue-next";
interface Milestone {
id: number;
title: string;
description: string | null;
body: string | null;
status: string;
order_index: number;
pct: number;
total: number;
completed: number;
status_counts: { todo: number; in_progress: number; done: number };
}
interface Project {
id: number;
user_id: number;
title: string;
description: string | null;
goal: string | null;
status: "active" | "paused" | "completed" | "archived";
color: string | null;
design_system_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
summary?: {
task_counts: { todo: number; in_progress: number; done: number };
note_count: number;
last_activity: string | null;
milestone_summary: Milestone[];
};
}
interface NoteItem {
id: number;
title: string;
type: "task" | "note";
status?: string;
priority?: string;
due_date?: string | null;
updated_at: string;
milestone_id?: number | null;
}
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const tasksStore = useTasksStore();
const project = ref<Project | null>(null);
// Design system the project is styled from. Loaded separately because an
// install with none is the ordinary case (rule #115) and the picker simply
// doesn't render — a failed fetch must not take the project page with it.
const designSystems = ref<DesignSystem[]>([]);
const editDesignSystemId = ref<number | null>(null);
const loading = ref(false);
const showStartPlanning = ref(false);
const planTitle = ref("");
const planningBusy = ref(false);
async function confirmStartPlanning() {
const title = planTitle.value.trim();
if (!title || !project.value) return;
planningBusy.value = true;
try {
// start_planning creates a MILESTONE (the plan container). Reload milestones,
// make sure the new one is expanded, and open its plan editor.
const result = await tasksStore.startPlanning(project.value.id, title);
planTitle.value = "";
showStartPlanning.value = false;
await loadMilestones();
collapsedMilestones.value.delete(result.milestone.id);
startEditPlan(result.milestone.id, result.milestone.body);
toast.show("Plan started");
} finally {
planningBusy.value = false;
}
}
const saving = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
const tasksLoading = ref(false);
const notesLoading = ref(false);
const milestones = ref<Milestone[]>([]);
const collapsedMilestones = ref<Set<number>>(new Set());
const showNewMilestone = ref(false);
const newMilestoneTitle = ref("");
const creatingMilestone = ref(false);
// Milestone edit/delete state
const renamingMilestoneId = ref<number | null>(null);
const renamingMilestoneTitle = ref("");
const deletingMilestone = ref<Milestone | null>(null);
// Edit state
const editTitle = ref("");
const editDescription = ref("");
const editGoal = ref("");
const editStatus = ref<Project["status"]>("active");
const editDirty = ref(false);
const projectId = computed(() => Number(route.params.id));
// Group tasks by milestone_id for the milestone view
interface MilestoneGroup {
milestone: Milestone | null;
tasks: NoteItem[];
}
const milestoneGroups = computed((): MilestoneGroup[] => {
const groups: MilestoneGroup[] = [];
// One group per milestone (ordered by order_index)
for (const ms of milestones.value) {
groups.push({
milestone: ms,
tasks: tasks.value.filter((t) => t.milestone_id === ms.id),
});
}
// Unassigned tasks at the bottom
const assignedIds = new Set(milestones.value.map((m) => m.id));
const unassigned = tasks.value.filter(
(t) => !t.milestone_id || !assignedIds.has(t.milestone_id)
);
if (unassigned.length > 0 || milestones.value.length === 0) {
groups.push({ milestone: null, tasks: unassigned });
}
return groups;
});
function toggleMilestoneCollapse(id: number) {
if (collapsedMilestones.value.has(id)) {
collapsedMilestones.value.delete(id);
} else {
collapsedMilestones.value.add(id);
}
}
function autoCollapseCompleted(msList: Milestone[]) {
for (const ms of msList) {
if (ms.total > 0 && ms.completed === ms.total) {
collapsedMilestones.value.add(ms.id);
}
}
}
async function loadProject() {
loading.value = true;
error.value = null;
try {
const data = await apiGet<Project>(`/api/projects/${projectId.value}`);
project.value = data;
editTitle.value = data.title;
editDescription.value = data.description ?? "";
editGoal.value = data.goal ?? "";
editStatus.value = data.status;
editDesignSystemId.value = data.design_system_id ?? null;
editDirty.value = false;
milestones.value = data.summary?.milestone_summary ?? [];
autoCollapseCompleted(milestones.value);
} catch {
error.value = "Failed to load project.";
} finally {
loading.value = false;
}
}
async function loadMilestones() {
try {
const data = await apiGet<{ milestones: Milestone[] }>(
`/api/projects/${projectId.value}/milestones`
);
milestones.value = data.milestones;
autoCollapseCompleted(milestones.value);
} catch {
// silent
}
}
async function createMilestone() {
if (!newMilestoneTitle.value.trim() || creatingMilestone.value) return;
creatingMilestone.value = true;
try {
await apiPost(`/api/projects/${projectId.value}/milestones`, {
title: newMilestoneTitle.value.trim(),
});
newMilestoneTitle.value = "";
showNewMilestone.value = false;
await loadMilestones();
toast.show("Milestone created");
} catch {
toast.show("Failed to create milestone", "error");
} finally {
creatingMilestone.value = false;
}
}
function startRenameMilestone(ms: Milestone) {
renamingMilestoneId.value = ms.id;
renamingMilestoneTitle.value = ms.title;
}
async function commitRenameMilestone(ms: Milestone) {
const newTitle = renamingMilestoneTitle.value.trim();
renamingMilestoneId.value = null;
if (!newTitle || newTitle === ms.title) return;
try {
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, { title: newTitle });
await loadMilestones();
toast.show("Milestone renamed");
} catch {
toast.show("Failed to rename milestone", "error");
}
}
// Plan body editing — a milestone IS the plan; its `body` holds the design.
const editingPlanId = ref<number | null>(null);
const editPlanBody = ref("");
const savingPlan = ref(false);
function startEditPlan(id: number, body: string | null) {
editingPlanId.value = id;
editPlanBody.value = body ?? "";
}
function cancelEditPlan() {
editingPlanId.value = null;
editPlanBody.value = "";
}
async function commitEditPlan(ms: Milestone) {
if (savingPlan.value) return;
savingPlan.value = true;
try {
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, {
body: editPlanBody.value,
});
await loadMilestones();
editingPlanId.value = null;
editPlanBody.value = "";
toast.show("Plan saved");
} catch {
toast.show("Failed to save plan", "error");
} finally {
savingPlan.value = false;
}
}
async function confirmDeleteMilestone() {
const ms = deletingMilestone.value;
if (!ms) return;
deletingMilestone.value = null;
try {
await apiDelete(`/api/projects/${projectId.value}/milestones/${ms.id}`);
await loadMilestones();
await loadTasks();
toast.show("Milestone deleted");
} catch {
toast.show("Failed to delete milestone", "error");
}
}
async function loadTasks() {
tasksLoading.value = true;
try {
const data = await apiGet<{ notes: NoteItem[]; total: number }>(
`/api/projects/${projectId.value}/notes?type=task&limit=100`
);
tasks.value = data.notes;
} catch {
// Silently fail — tasks just won't show
} finally {
tasksLoading.value = false;
}
}
const advancingTaskId = ref<number | null>(null);
const taskStatusNext: Record<string, string> = {
todo: "in_progress",
in_progress: "done",
};
async function advanceTaskStatus(task: NoteItem, e: Event) {
e.preventDefault();
e.stopPropagation();
const next = taskStatusNext[task.status ?? ""];
if (!next || advancingTaskId.value === task.id) return;
advancingTaskId.value = task.id;
try {
await apiPatch(`/api/notes/${task.id}`, { status: next });
const idx = tasks.value.findIndex((t) => t.id === task.id);
if (idx !== -1) tasks.value[idx] = { ...tasks.value[idx], status: next };
} catch {
toast.show("Failed to update task", "error");
} finally {
advancingTaskId.value = null;
}
}
async function loadNotes() {
notesLoading.value = true;
try {
const data = await apiGet<{ notes: NoteItem[]; total: number }>(
`/api/projects/${projectId.value}/notes?type=note&limit=100`
);
notes.value = data.notes;
} catch {
// Silently fail
} finally {
notesLoading.value = false;
}
}
onMounted(async () => {
await loadProject();
loadTasks();
loadNotes();
loadDesignSystems();
});
/** Populate the design-system picker. Swallows failure on purpose: with no
* design systems the picker doesn't render at all, which is the ordinary state
* for most installs — so this must never be able to break the project page. */
async function loadDesignSystems() {
try {
designSystems.value = (await fetchDesignSystems()).design_systems;
} catch {
designSystems.value = [];
}
}
watch(projectId, async () => {
await loadProject();
loadTasks();
loadNotes();
});
watch(
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
() => {
if (!project.value) return;
editDirty.value =
editTitle.value !== project.value.title ||
editDescription.value !== (project.value.description ?? "") ||
editGoal.value !== (project.value.goal ?? "") ||
editStatus.value !== project.value.status ||
editDesignSystemId.value !== (project.value.design_system_id ?? null);
}
);
async function saveProject() {
// Bound once rather than re-read: the checks below straddle two awaits, and
// `project.value` is a ref whose narrowing doesn't survive them.
const current = project.value;
if (!current || saving.value) return;
saving.value = true;
try {
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
title: editTitle.value.trim(),
description: editDescription.value.trim() || null,
goal: editGoal.value.trim() || null,
status: editStatus.value,
});
// The design-system pointer is its own endpoint (PUT, because clearing it
// is a real outcome rather than an omission), so it saves separately —
// only when it actually changed, to keep the common save at one request.
if (editDesignSystemId.value !== (current.design_system_id ?? null)) {
await setProjectDesignSystem(current.id, editDesignSystemId.value);
updated.design_system_id = editDesignSystemId.value;
}
project.value = { ...current, ...updated };
editDirty.value = false;
toast.show("Project saved");
} catch {
toast.show("Failed to save project", "error");
} finally {
saving.value = false;
}
}
const showDeleteConfirm = ref(false);
const showShare = ref(false);
async function confirmDelete() {
if (!project.value) return;
showDeleteConfirm.value = false;
try {
await apiDelete(`/api/projects/${project.value.id}`);
toast.show("Project deleted");
router.push("/projects");
} catch {
toast.show("Failed to delete project", "error");
}
}
</script>
<template>
<main class="project-view">
<!-- Nav bar -->
<div class="page-header">
<router-link to="/projects" class="btn-back"> Projects</router-link>
<div class="page-header-actions">
<template v-if="showStartPlanning">
<input
v-model="planTitle"
class="plan-title-input"
placeholder="Plan title…"
@keyup.enter="confirmStartPlanning"
/>
<button
class="btn-workspace"
:disabled="!planTitle.trim() || planningBusy"
@click="confirmStartPlanning"
>
Create plan
</button>
<button class="btn-share" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
</template>
<button
v-else-if="project"
class="btn-workspace"
@click="showStartPlanning = true"
>
Start planning
</button>
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-workspace">
<LayoutGrid :size="16" />
Workspace
</router-link>
<button v-if="project && !showStartPlanning" class="btn-share" @click="showShare = true">Share</button>
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
</div>
</div>
<!-- Skeleton -->
<div v-if="loading" class="proj-skeleton" aria-label="Loading project">
<div class="skel-title"></div>
<div class="skel-goal"></div>
<div class="skel-stats"></div>
<div class="skel-body"></div>
</div>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else-if="project">
<!-- Project identity header -->
<div class="project-header">
<div class="title-row">
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
<span :class="['status-badge', `status-${project.status}`]">
{{ project.status.charAt(0).toUpperCase() + project.status.slice(1) }}
</span>
</div>
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
<p v-if="project.summary?.last_activity" class="project-activity">
<Clock :size="16" />
Active {{ relativeTime(project.summary.last_activity) }}
</p>
</div>
<!-- Summary stat chips -->
<div v-if="project.summary" class="summary-stats">
<div class="stat-chip stat-todo">
<span class="stat-dot dot-todo"></span>
<span class="stat-val">{{ project.summary.task_counts.todo }}</span>
<span class="stat-label">todo</span>
</div>
<div class="stat-chip stat-inprogress">
<span class="stat-dot dot-inprogress"></span>
<span class="stat-val">{{ project.summary.task_counts.in_progress }}</span>
<span class="stat-label">in progress</span>
</div>
<div class="stat-chip stat-done">
<span class="stat-dot dot-done"></span>
<span class="stat-val">{{ project.summary.task_counts.done }}</span>
<span class="stat-label">done</span>
</div>
<div class="stat-chip stat-notes">
<FileText :size="16" style="opacity:0.6" />
<span class="stat-val">{{ project.summary.note_count }}</span>
<span class="stat-label">notes</span>
</div>
</div>
<div class="project-body">
<!-- Edit panel -->
<aside class="edit-panel">
<h3 class="panel-heading">Details</h3>
<div class="edit-field">
<label class="edit-label">Goal</label>
<input v-model="editGoal" type="text" class="edit-input" placeholder="What are you trying to achieve?" />
</div>
<div class="edit-field">
<label class="edit-label">Description</label>
<textarea v-model="editDescription" class="edit-textarea" rows="4" placeholder="Optional description..."></textarea>
</div>
<div class="edit-field">
<label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
</div>
<div v-if="designSystems.length" class="edit-field">
<label class="edit-label" for="project-design-system">Design system</label>
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
{{ saving ? "Saving..." : "Save Changes" }}
</button>
</aside>
<!-- Main content area -->
<div class="content-area">
<div class="tab-bar">
<button :class="['tab-btn', { active: activeTab === 'tasks' }]" @click="activeTab = 'tasks'">
Tasks
<span v-if="project.summary" class="tab-count">{{ (project.summary.task_counts.todo ?? 0) + (project.summary.task_counts.in_progress ?? 0) + (project.summary.task_counts.done ?? 0) }}</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'notes' }]" @click="activeTab = 'notes'">
Notes
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'systems' }]" @click="activeTab = 'systems'">
Systems
</button>
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
Rules
</button>
</div>
<!-- Tasks tab milestone-grouped kanban -->
<div v-if="activeTab === 'tasks'" class="tasks-view">
<div v-if="tasksLoading" class="proj-skeleton-inline">
<div class="skel-row"></div>
<div class="skel-row skel-row--short"></div>
<div class="skel-row"></div>
</div>
<template v-else>
<div class="milestone-actions">
<button v-if="!showNewMilestone" class="btn-add-milestone" @click="showNewMilestone = true">
+ Milestone
</button>
<div v-else class="new-milestone-row">
<input
v-model="newMilestoneTitle"
class="milestone-title-input"
placeholder="Milestone title"
autofocus
@keydown.enter="createMilestone"
@keydown.escape="showNewMilestone = false; newMilestoneTitle = ''"
/>
<button class="btn-ms-confirm" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
{{ creatingMilestone ? "..." : "Add" }}
</button>
<button class="btn-ms-cancel" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
</div>
</div>
<div v-for="group in milestoneGroups" :key="group.milestone?.id ?? 'unassigned'" class="milestone-group">
<div
class="milestone-header"
:class="{ clickable: !!group.milestone && renamingMilestoneId !== group.milestone?.id }"
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)"
>
<span class="ms-chevron" v-if="group.milestone">
<ChevronRight v-if="collapsedMilestones.has(group.milestone.id)" :size="16" />
<ChevronDown v-else :size="16" />
</span>
<template v-if="group.milestone && renamingMilestoneId === group.milestone.id">
<input
class="ms-rename-input"
v-model="renamingMilestoneTitle"
@click.stop
@keydown.enter.stop="commitRenameMilestone(group.milestone)"
@keydown.escape.stop="renamingMilestoneId = null"
@blur="commitRenameMilestone(group.milestone)"
autofocus
/>
</template>
<span v-else class="ms-name">{{ group.milestone?.title ?? "No Milestone" }}</span>
<span class="ms-count">{{ group.tasks.length }}</span>
<template v-if="group.milestone">
<div class="ms-progress-track">
<div class="ms-progress-fill" :style="{ width: group.milestone.pct + '%' }"></div>
</div>
<span class="ms-pct">{{ group.milestone.pct }}%</span>
<div class="ms-actions" @click.stop>
<button class="ms-action-btn" :title="group.milestone.body ? 'Edit plan' : 'Add plan'" @click="startEditPlan(group.milestone.id, group.milestone.body)">
<FileText :size="16" />
</button>
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
<Pencil :size="16" />
</button>
<button class="ms-action-btn ms-action-delete" title="Delete" @click="deletingMilestone = group.milestone">
<Trash2 :size="16" />
</button>
</div>
</template>
</div>
<!-- Plan body: the milestone IS the plan; design lives here, steps are the tasks below. -->
<div
v-if="group.milestone && !collapsedMilestones.has(group.milestone.id) && (editingPlanId === group.milestone.id || group.milestone.body)"
class="ms-plan"
>
<template v-if="editingPlanId === group.milestone.id">
<textarea
v-model="editPlanBody"
class="ms-plan-editor"
rows="10"
placeholder="The plan: Goal / Approach / Verification. Track each step as a task below."
></textarea>
<div class="ms-plan-actions">
<button class="btn-secondary" @click="cancelEditPlan">Cancel</button>
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
</div>
</template>
<div
v-else
class="ms-plan-rendered markdown-body"
@click="startEditPlan(group.milestone.id, group.milestone.body)"
v-html="renderMarkdown(group.milestone.body || '')"
></div>
</div>
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
<!-- Todo column -->
<div class="kanban-col col-todo">
<div class="kanban-col-header">
<span class="col-status-dot dot-todo"></span>
<span class="col-label">Todo</span>
<span class="col-count">{{ group.tasks.filter(t => t.status === 'todo').length }}</span>
<router-link
:to="`/tasks/new?projectId=${projectId}${group.milestone ? '&milestoneId=' + group.milestone.id : ''}`"
class="col-add-btn" title="Add task"
>+</router-link>
</div>
<div class="kanban-cards">
<router-link
v-for="task in group.tasks.filter(t => t.status === 'todo')"
:key="task.id" :to="`/tasks/${task.id}`"
:class="['task-card', `pri-${task.priority || 'none'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
</div>
<button
class="task-advance-btn"
title="Move to In Progress"
:disabled="advancingTaskId === task.id"
@click="advanceTaskStatus(task, $event)"
></button>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'todo').length" class="col-empty">No tasks</p>
</div>
</div>
<!-- In Progress column -->
<div class="kanban-col col-inprogress">
<div class="kanban-col-header">
<span class="col-status-dot dot-inprogress"></span>
<span class="col-label">In Progress</span>
<span class="col-count">{{ group.tasks.filter(t => t.status === 'in_progress').length }}</span>
</div>
<div class="kanban-cards">
<router-link
v-for="task in group.tasks.filter(t => t.status === 'in_progress')"
:key="task.id" :to="`/tasks/${task.id}`"
:class="['task-card', `pri-${task.priority || 'none'}`]"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div class="task-card-footer">
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
<span v-if="task.due_date" class="due-date">{{ task.due_date }}</span>
</div>
<button
class="task-advance-btn task-advance-btn--done"
title="Mark as Done"
:disabled="advancingTaskId === task.id"
@click="advanceTaskStatus(task, $event)"
><Check :size="16" /></button>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
</div>
</div>
<!-- Done column -->
<div class="kanban-col col-done">
<div class="kanban-col-header">
<span class="col-status-dot dot-done"></span>
<span class="col-label">Done</span>
<span class="col-count">{{ group.tasks.filter(t => t.status === 'done').length }}</span>
</div>
<div class="kanban-cards">
<router-link
v-for="task in group.tasks.filter(t => t.status === 'done')"
:key="task.id" :to="`/tasks/${task.id}`"
class="task-card task-card-done"
>
<span class="task-title">{{ task.title || "Untitled" }}</span>
<div v-if="task.due_date" class="task-meta">
<span class="due-date">{{ task.due_date }}</span>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'done').length" class="col-empty">No tasks</p>
</div>
</div>
</div>
</div>
<p v-if="tasks.length === 0" class="empty-msg">No tasks in this project yet.</p>
</template>
</div>
<!-- Notes tab -->
<div v-if="activeTab === 'notes'" class="notes-list">
<div v-if="notesLoading" class="proj-skeleton-inline">
<div class="skel-row"></div>
<div class="skel-row skel-row--short"></div>
<div class="skel-row"></div>
</div>
<template v-else>
<router-link v-for="note in notes" :key="note.id" :to="`/notes/${note.id}`" class="note-row">
<FileText class="note-icon" :size="16" />
<span class="note-title">{{ note.title || "Untitled" }}</span>
<span class="note-date">{{ relativeTime(note.updated_at) }}</span>
</router-link>
<p v-if="!notes.length" class="empty-msg">No notes in this project.</p>
</template>
</div>
<!-- Systems tab -->
<SystemsSection v-if="activeTab === 'systems'" :project-id="projectId" />
<!-- Rules tab -->
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
</div>
</div>
</template>
<!-- Milestone delete confirmation -->
<teleport to="body">
<div v-if="deletingMilestone" class="modal-overlay" @click.self="deletingMilestone = null">
<div class="modal-card">
<h3 class="modal-title">Delete Milestone</h3>
<p class="modal-message">
Delete <strong>{{ deletingMilestone.title }}</strong>?
Tasks will be unlinked from this milestone but not deleted.
</p>
<div class="modal-actions">
<button class="modal-btn" @click="deletingMilestone = null">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDeleteMilestone">Delete</button>
</div>
</div>
</div>
</teleport>
<!-- Project delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="showDeleteConfirm = false">
<div class="modal-card">
<h3 class="modal-title">Delete Project</h3>
<p class="modal-message">Are you sure you want to delete this project? This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" @click="showDeleteConfirm = false">Cancel</button>
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
</teleport>
<ShareDialog
v-if="showShare && project"
resource-type="project"
:resource-id="project.id"
:resource-title="project.title"
@close="showShare = false"
/>
</main>
</template>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────── */
.project-view {
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
/* ── Nav bar ─────────────────────────────────────────────────── */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
.plan-title-input {
background: var(--color-bg, #111113);
color: inherit;
border: 1px solid var(--color-border, #2a2a2e);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font: inherit;
min-width: 200px;
}
.btn-back {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: border-color 0.15s, color 0.15s;
}
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
the project's "central feature moment" — entering the focused workspace
is a Scribe-flavored action, not a plain operation. */
.btn-workspace {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 1rem;
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
/* Share: Bronze action-secondary — alternate path */
.btn-share {
padding: 0.4rem 0.8rem;
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-share:hover { background: var(--color-action-secondary-hover); }
/* Delete project: Oxblood action-destructive ghost — outline form since
the actual confirm modal carries the filled destructive treatment */
.btn-danger-outline {
padding: 0.4rem 0.8rem;
background: none;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
/* ── Project identity header ─────────────────────────────────── */
.project-header { margin-bottom: 1rem; }
.title-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.project-title-input {
flex: 1;
font-size: 1.75rem;
font-weight: 500;
font-family: "Fraunces", Georgia, serif;
color: var(--color-text);
background: transparent;
border: none;
border-bottom: 1.5px solid transparent;
outline: none;
padding: 0.1rem 0;
min-width: 0;
transition: border-color 0.15s;
}
.project-title-input:focus { border-bottom-color: var(--color-primary); }
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; }
.status-badge {
font-size: 0.68rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.18rem 0.55rem;
border-radius: 999px;
flex-shrink: 0;
}
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
.project-goal {
font-size: 1rem;
color: var(--color-text-secondary);
margin: 0 0 0.3rem;
line-height: 1.45;
}
.project-activity {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--color-text-muted);
margin: 0;
}
/* ── Summary stat chips ──────────────────────────────────────── */
.summary-stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.25rem;
flex-wrap: wrap;
}
.stat-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.65rem;
border-radius: var(--radius-md);
font-size: 0.82rem;
border: 1px solid;
}
.stat-val { font-weight: 500; font-size: 0.9rem; }
.stat-label { color: inherit; opacity: 0.8; }
.stat-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-todo { background: transparent; border: 2px solid var(--color-text-muted); }
.dot-inprogress { background: var(--color-status-in-progress, #3b82f6); }
.dot-done { background: var(--color-status-done, #22c55e); }
.stat-todo { background: color-mix(in srgb, var(--color-text-muted) 8%, transparent); color: var(--color-text-secondary); border-color: var(--color-border); }
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
.stat-done { background: color-mix(in srgb, var(--color-success) 10%, transparent); color: var(--color-success); border-color: color-mix(in srgb, var(--color-success) 28%, transparent); }
.stat-notes { background: color-mix(in srgb, var(--color-primary) 8%, transparent); color: var(--color-primary); border-color: color-mix(in srgb, var(--color-primary) 22%, transparent); }
/* ── Two-column body ─────────────────────────────────────────── */
.project-body {
display: grid;
grid-template-columns: 248px 1fr;
gap: 1.25rem;
align-items: start;
}
/* ── Edit panel ──────────────────────────────────────────────── */
.edit-panel {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
position: sticky;
top: 1rem;
}
.panel-heading {
margin: 0 0 0.1rem;
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
.edit-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.edit-input, .edit-textarea, .edit-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--color-primary); }
.edit-textarea { resize: vertical; }
/* Save panel: Moss action-primary per Hybrid rule */
.btn-save-panel {
padding: 0.45rem 0.9rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
width: 100%;
transition: background 0.15s;
}
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
/* ── Content area ────────────────────────────────────────────── */
.content-area { display: flex; flex-direction: column; gap: 0.75rem; }
.tab-bar {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border);
}
.tab-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.9rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-family: inherit;
margin-bottom: -1px;
transition: color 0.15s;
}
.tab-btn:hover { color: var(--color-primary); }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 500; }
.tab-count {
font-size: 0.7rem;
font-weight: 500;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.5;
color: var(--color-text-muted);
}
.tab-btn.active .tab-count {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: color-mix(in srgb, var(--color-primary) 30%, transparent);
color: var(--color-primary);
}
/* ── Tasks view ──────────────────────────────────────────────── */
.tasks-view { display: flex; flex-direction: column; gap: 1rem; }
.milestone-actions { display: flex; align-items: center; }
.btn-add-milestone {
background: none;
border: 1px dashed var(--color-border);
color: var(--color-text-secondary);
padding: 0.28rem 0.65rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.btn-add-milestone:hover { border-color: var(--color-primary); color: var(--color-primary); }
.new-milestone-row { display: flex; gap: 0.4rem; align-items: center; flex: 1; }
.milestone-title-input {
flex: 1;
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
}
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
.btn-ms-confirm {
padding: 0.3rem 0.65rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-ms-cancel {
padding: 0.3rem 0.65rem;
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
/* ── Milestone group ─────────────────────────────────────────── */
.milestone-group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.milestone-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
background: var(--color-bg-secondary);
font-size: 0.85rem;
user-select: none;
border-bottom: 1px solid var(--color-border);
}
.milestone-header.clickable { cursor: pointer; }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
/* Plan body: the milestone's design/intent, shown above its task columns. */
.ms-plan {
padding: 0.6rem 0.85rem;
background: color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card));
border-bottom: 1px solid var(--color-border);
}
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
.ms-plan-editor {
width: 100%;
font-family: var(--font-mono, monospace);
font-size: 0.8rem;
line-height: 1.5;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
resize: vertical;
box-sizing: border-box;
}
.ms-plan-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
.ms-plan-actions .btn-primary,
.ms-plan-actions .btn-secondary {
font-size: 0.8rem;
padding: 0.3rem 0.75rem;
border-radius: 6px;
cursor: pointer;
border: 1px solid var(--color-border);
}
.ms-plan-actions .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-count {
font-size: 0.7rem;
color: var(--color-text-muted);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.05rem 0.4rem;
flex-shrink: 0;
font-weight: 500;
}
.ms-progress-track {
width: 72px;
height: 6px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
}
.ms-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-primary), color-mix(in srgb, var(--color-primary) 70%, var(--color-success)));
border-radius: 999px;
transition: width 0.4s ease;
}
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 500; }
.ms-actions { display: flex; gap: 0.15rem; margin-left: 0.2rem; opacity: 0; transition: opacity 0.15s; }
.milestone-header:hover .ms-actions { opacity: 1; }
.ms-action-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
}
.ms-action-btn:hover { background: var(--color-bg-card); color: var(--color-text); }
.ms-action-delete:hover { color: var(--color-danger, #e74c3c); }
.ms-rename-input {
flex: 1;
min-width: 0;
padding: 0.1rem 0.35rem;
border: 1px solid var(--color-primary);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
font-family: inherit;
font-weight: 500;
outline: none;
}
/* ── Kanban ──────────────────────────────────────────────────── */
.kanban {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
align-items: start;
padding: 0.75rem;
background: color-mix(in srgb, var(--color-bg) 60%, var(--color-bg-secondary));
}
.kanban-col {
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
padding: 0.65rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
border-top: 3px solid;
}
.col-todo { border-top-color: var(--color-border); }
.col-inprogress { border-top-color: var(--color-status-in-progress, #3b82f6); }
.col-done { border-top-color: var(--color-status-done, #22c55e); }
.kanban-col-header {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
margin-bottom: 0.3rem;
}
.col-status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.col-label { flex: 1; }
.col-count {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.05rem 0.4rem;
font-size: 0.68rem;
color: var(--color-text-muted);
font-weight: 500;
}
.col-add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
color: var(--color-text-muted);
text-decoration: none;
font-size: 1rem;
line-height: 1;
border-radius: 3px;
margin-left: auto;
}
.col-add-btn:hover { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }
.task-card {
display: block;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-left: 3px solid transparent;
border-radius: var(--radius-sm);
padding: 0.45rem 0.55rem;
text-decoration: none;
color: var(--color-text);
font-size: 0.85rem;
transition: border-color 0.12s, box-shadow 0.18s, transform 0.18s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.task-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
border-left-color: var(--color-primary);
box-shadow: 0 3px 10px rgba(0,0,0,0.08);
transform: translateY(-2px);
}
/* Priority left-border colors */
.task-card.pri-high { border-left-color: var(--color-danger, #e74c3c); }
.task-card.pri-medium { border-left-color: #f59e0b; }
.task-card.pri-low { border-left-color: var(--color-success); }
.task-card-done { opacity: 0.65; }
.task-card-done .task-title { text-decoration: line-through; }
.task-title { display: block; font-weight: 500; margin-bottom: 0.2rem; line-height: 1.3; word-break: break-word; }
.task-card-footer { display: flex; align-items: center; justify-content: space-between; gap: 0.35rem; min-height: 1.2rem; }
.task-meta { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.task-advance-btn {
flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
width: 1.4rem; height: 1.4rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
line-height: 1;
}
.task-card:hover .task-advance-btn { opacity: 1; }
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
.priority-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-pri-high { background: var(--color-danger, #e74c3c); }
.dot-pri-medium { background: #f59e0b; }
.dot-pri-low { background: var(--color-success); }
.due-date { font-size: 0.7rem; color: var(--color-text-muted); }
.col-empty {
text-align: center;
padding: 1.25rem 0.5rem;
color: var(--color-text-muted);
font-size: 0.78rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
opacity: 0.7;
}
/* ── Notes list ──────────────────────────────────────────────── */
.notes-list { display: flex; flex-direction: column; gap: 0.35rem; }
.note-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-decoration: none;
color: var(--color-text);
font-size: 0.9rem;
transition: border-color 0.12s, box-shadow 0.15s, transform 0.15s;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.note-row:hover {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
transform: translateY(-1px);
}
.note-icon { color: var(--color-text-muted); flex-shrink: 0; }
.note-title { font-weight: 500; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-date { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
.empty-msg { color: var(--color-text-muted); font-size: 0.875rem; text-align: center; padding: 1rem; }
/* ── Modal ───────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--color-bg); }
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
/* ── Skeleton ────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
.proj-skeleton, .proj-skeleton-inline {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.skel-title, .skel-goal, .skel-stats, .skel-body, .skel-row {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-secondary) 25%,
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
var(--color-bg-secondary) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-title { height: 2.2rem; width: 55%; border-radius: var(--radius-md); }
.skel-goal { height: 1rem; width: 75%; }
.skel-stats { height: 2rem; width: 50%; border-radius: var(--radius-md); }
.skel-body { height: 200px; border-radius: var(--radius-md); }
.skel-row { height: 2.5rem; border-radius: var(--radius-sm); }
.skel-row--short { width: 65%; }
/* ── Responsive ──────────────────────────────────────────────── */
@media (max-width: 900px) {
.project-body { grid-template-columns: 1fr; }
.edit-panel { position: static; }
.kanban { grid-template-columns: 1fr; }
}
</style>