Files
FabledScribe/frontend/src/views/ProjectView.vue
T
bvandeusenandClaude Opus 5 07bf58de46
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
fix(project): grid tracks that cannot shrink pushed the milestone rows off-page
Reported after deploy: the milestone rows and the kanban's Done column run past
the right edge and get cut.

Both grids here use a bare `1fr`, and a `1fr` track carries an AUTO minimum —
it cannot size below its content. So one wide descendant anywhere in the
content column widens the column past the grid, everything inside inherits that
width, and `.project-view`'s `overflow-x: clip` cuts it at the page edge. The
milestone header only made it visible: it is a flex row now, so its tail
(progress track, percent, actions) sits at the right edge where the clipping
happens, where before those children stacked at the left and never reached it.

`minmax(0, 1fr)` on both, plus `min-width: 0` on the content area — a grid
item's default `min-width: auto` refuses to shrink even when its track will,
so the two halves are needed together.

Worth naming, because it is the same property twice with opposite intent: the
header nav was fixed two commits ago by RELYING on the auto minimum, so neither
side could be squeezed under its content and the pill bar stays centred. Here
that same behaviour is the defect. `1fr` is not a neutral default — it is a
statement that the track may not shrink.

I could not isolate which descendant was the wide one by reading, and said so
rather than guessing at it; this is the structural fix, which holds whichever
of the candidates it was.

Not changed: RulesView's `280px 300px 1fr` is the same shape and a plausible
latent instance, but nothing has reported it and I have not seen that surface
misbehave. Guessing at unreported layouts is how eleven fixes become eleven
regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 10:08:48 -04:00

1428 lines
53 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 ProjectDesignTab from "@/components/ProjectDesignTab.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" | "design">("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-ghost"> 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-primary btn-compact"
:disabled="!planTitle.trim() || planningBusy"
@click="confirmStartPlanning"
>
Create plan
</button>
<button class="btn-ghost btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
</template>
<button
v-else-if="project"
class="btn-ghost btn-compact"
@click="showStartPlanning = true"
>
Start planning
</button>
<router-link v-if="project && !showStartPlanning" :to="`/workspace/${project.id}`" class="btn-cta btn-compact">
<LayoutGrid :size="16" />
Workspace
</router-link>
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
<button v-if="project && !showStartPlanning" class="btn-danger-outline btn-compact" @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>
<!-- A textarea, not a single-line input. A project goal is a
paragraph in practice this one showed as "Maintain Scribe as
the reliabl" and gave no way to read the rest without arrowing
through it. -->
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
</div>
<div class="edit-field">
<label class="edit-label">Description</label>
<textarea v-model="editDescription" class="edit-textarea" rows="6" 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-primary" @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>
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
Design
</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-ghost btn-inline 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-primary btn-compact" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
{{ creatingMilestone ? "..." : "Add" }}
</button>
<button class="btn-secondary btn-compact" @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" />
<!-- Design tab: this project's recorded components against its sheet.
Bound to the SAVED pointer rather than the picker's draft value,
so an unsaved change in the sidebar can't make the tab report on
a system this project isn't using. -->
<ProjectDesignTab
v-if="activeTab === 'design'"
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
</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);
color: inherit;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font: inherit;
min-width: 200px;
}
.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); }
.dot-done { background: var(--color-status-done); }
.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 ─────────────────────────────────────────── */
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
cannot shrink below its content — one wide descendant anywhere in the
content column widens the whole column past the grid, and everything inside
it then overflows the page and gets cut by `.project-view`'s
`overflow-x: clip`.
This is the same property the header nav relies on and wants (neither side
squeezed under its content); here it is exactly wrong, because the column
holds a kanban whose own tracks push outward. `min-width: 0` on the item is
the twin half — a grid item's default `min-width: auto` refuses to shrink
even when its track will. */
.project-body {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
}
.content-area { min-width: 0; }
/* ── 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 */
.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 */
/* RESTORED. Both of these lost their base rule to a CSS sweep and left only
modifiers behind — `.milestone-header.clickable`, `.milestone-header:hover`.
Every child here (`.ms-chevron`, `.ms-name { flex: 1 }`, the progress track,
`.ms-pct`) is written for a flex ROW, so without the parent they stacked
vertically and each milestone grew to five lines of mostly nothing. That is
the "uses space poorly" the operator saw, and it was a deletion rather than a
design change. A dangling `:hover` is the tell, and it is now checked for. */
.milestone-group {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 0.75rem;
}
.milestone-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.85rem;
background: var(--color-bg-secondary);
}
.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);
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-action-primary); color: var(--fs-text-on-action); border-color: var(--color-action-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); }
.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;
/* Same reason as .project-body: three auto-minimum tracks add up to more
than the column when a card title or a column header won't compress, and
the excess pushes the whole milestone card wider than the page. */
grid-template-columns: repeat(3, minmax(0, 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); }
.col-done { border-top-color: var(--color-status-done); }
.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); }
.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-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
.task-advance-btn--done:hover { background: var(--color-success); border-color: var(--color-success); color: var(--fs-text-on-action); }
.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); }
.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);
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: var(--fs-text-on-action); }
.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>