<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost, apiPut, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
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 InceptionCard from "@/components/InceptionCard.vue";
import { fmtDate } from "@/utils/dateFormat";
import type { InceptionDecision, InceptionRecord } from "@/api/inception";
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;
  forge_connection_id: number | null;
  inception?: InceptionRecord | 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();

function onInceptionDecided(decision: InceptionDecision) {
  if (project.value) project.value.inception = decision.inception;
  toast.show("Inheritance recorded");
  void loadProject();
}
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 tasksError = ref<string | null>(null);
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);
  }
}

// A milestone IS the plan, so its body carries the whole design — Goal,
// Approach, Verification, and often several hundred words of reasoning. Rendered
// in full, one milestone's plan pushes every other milestone off the screen, and
// the board stops being a board.
//
// Length is judged on the SOURCE, not by measuring the rendered box. Measuring
// would be exact, but it means a ref per milestone, a post-render scrollHeight
// read, and a re-measure on every markdown change — a lot of machinery to decide
// whether to show one button. This proxy is wrong only in the narrow band around
// the threshold, where either answer is fine.
const PLAN_CLAMP_CHARS = 400;

const expandedPlans = ref<Set<number>>(new Set());

function isPlanLong(ms: Milestone): boolean {
  return (ms.body || "").length > PLAN_CLAMP_CHARS;
}

function isPlanClamped(ms: Milestone): boolean {
  return isPlanLong(ms) && !expandedPlans.value.has(ms.id);
}

function togglePlanExpanded(id: number) {
  if (expandedPlans.value.has(id)) {
    expandedPlans.value.delete(id);
  } else {
    expandedPlans.value.add(id);
  }
}

// Milestones this has already ruled on. Without it, the rule re-applies on every
// reload — and `loadMilestones` runs after a task's status changes. So expanding
// a finished milestone and then ticking anything snapped it shut again, with no
// visible cause. That is half of why the collapse state read as arbitrary: it
// wasn't only deciding at START, it was overriding the reader continuously.
const autoCollapsedOnce = ref<Set<number>>(new Set());

function autoCollapseCompleted(msList: Milestone[]) {
  for (const ms of msList) {
    if (autoCollapsedOnce.value.has(ms.id)) continue;
    autoCollapsedOnce.value.add(ms.id);
    // Fully done and non-empty: start collapsed. A finished milestone is
    // history, and the board is for what's live.
    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");
  }
}

// The route's max_limit. Asking for more is clamped server-side, so this is the
// largest page a single request can return.
const TASK_PAGE_SIZE = 500;

async function loadTasks() {
  tasksLoading.value = true;
  tasksError.value = null;
  try {
    // PAGE UNTIL COMPLETE. This board groups tasks under their milestone and
    // shows each milestone's progress beside them, and that progress is counted
    // SERVER-SIDE over every task. A partial fetch therefore doesn't just hide
    // rows — it makes the bar disagree with the cards under it, and the
    // auto-collapse rule (100% done starts collapsed) read as arbitrary.
    //
    // The original `limit=100` with no second page shipped the day this view was
    // written, when the project had a couple of dozen tasks. At 166 it was
    // dropping 66 — the least-recently-updated, so mostly done tasks in
    // completed milestones, which is exactly where the mismatch is least
    // visible and most confusing.
    const url = (offset: number) =>
      `/api/projects/${projectId.value}/notes?type=task` +
      `&limit=${TASK_PAGE_SIZE}&offset=${offset}`;

    const first = await apiGet<{ notes: NoteItem[]; total: number }>(url(0));
    const all = [...first.notes];
    while (all.length < first.total) {
      const next = await apiGet<{ notes: NoteItem[]; total: number }>(url(all.length));
      // A page that returns nothing while `total` still says there is more means
      // the two disagree. Stop rather than loop forever; showing what we have
      // beats hanging the board.
      if (!next.notes.length) break;
      all.push(...next.notes);
    }
    tasks.value = all;
  } catch {
    // Say so. This used to swallow the error and leave an empty board, which is
    // indistinguishable from a project with no tasks — the same "hidden with no
    // indicator" failure as the truncation above, one layer up.
    tasksError.value = "Could not load tasks. Refresh to try again.";
  } 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;
  }
}

/* ── Pattern-library coverage (#2692) ─────────────────────────── */

interface CoverageGap {
  dir: string;
  unclassified: number;
  total: number;
}
interface DeriveGroup {
  group: string;
  kind: string;
  label: string;
  size: number;
  paths: string[];
}
interface Coverage {
  total: number;
  accounted: number;
  unclassified: number;
  counts: Record<string, number>;
  estimate: boolean;
  computed_at: string;
  repos: { repo: string; ref: string; total: number; accounted: number }[];
  largest_gaps: CoverageGap[];
  // The mechanical proposer's standing (#2792): canon proposals awaiting an
  // agent's confirm, and the biggest repeats-with-no-canon families.
  proposed?: number;
  derive_groups?: DeriveGroup[];
  // The divergence readout (#2793): button B where button A is canon, and
  // judged shapes whose bodies moved since they were judged.
  divergent?: number;
  divergence?: Divergence[];
  recheck?: number;
}
interface Divergence {
  path: string;
  symbol: string;
  kind: string;
  canon_snippet_id: number;
}

const coverage = ref<Coverage | null>(null);
const coverageConfigured = ref(false);
const coverageRefreshing = ref(false);
const coverageError = ref<string | null>(null);

/** Swallows failure like loadDesignSystems: no forge is the ordinary state
 *  for most installs, and this card must never break the project page. */
async function loadCoverage() {
  try {
    const res = await apiGet<{ configured: boolean; coverage: Coverage | null }>(
      `/api/projects/${projectId.value}/coverage`
    );
    coverageConfigured.value = res.configured;
    coverage.value = res.coverage;
  } catch {
    coverageConfigured.value = false;
    coverage.value = null;
  }
}

async function refreshCoverage() {
  if (coverageRefreshing.value) return;
  coverageRefreshing.value = true;
  coverageError.value = null;
  try {
    const res = await apiPost<{ coverage: Coverage }>(
      `/api/projects/${projectId.value}/coverage/refresh`,
      {}
    );
    coverage.value = res.coverage;
  } catch (e) {
    coverageError.value =
      e instanceof Error ? e.message : "Coverage refresh failed";
  } finally {
    coverageRefreshing.value = false;
  }
}

/* ── Forge pin (#2778) — which of the OWNER's connections serves this
   project. Owner-only UI: the select lists the viewer's own keyring, which
   is only the eligible set when the viewer IS the owner. ── */

const authStore = useAuthStore();
const isProjectOwner = computed(
  () => !!project.value && project.value.user_id === authStore.user?.id
);
interface ForgeConnectionOption { id: number; host: string; kind: string }
const forgeOptions = ref<ForgeConnectionOption[]>([]);
const forgePin = ref<number | null>(null);
const savingForgePin = ref(false);

async function loadForgeOptions() {
  if (!isProjectOwner.value) return;
  try {
    const res = await apiGet<{ connections: ForgeConnectionOption[] }>(
      "/api/settings/forge-connections"
    );
    forgeOptions.value = res.connections;
    forgePin.value = project.value?.forge_connection_id ?? null;
  } catch {
    forgeOptions.value = [];
  }
}

async function saveForgePin() {
  savingForgePin.value = true;
  try {
    await apiPut(`/api/projects/${projectId.value}/forge`, {
      connection_id: forgePin.value,
    });
    if (project.value) project.value.forge_connection_id = forgePin.value;
    await loadCoverage();
  } catch (e) {
    toast.show(apiErrorMessage(e, "Failed to change the project's forge"), "error");
    forgePin.value = project.value?.forge_connection_id ?? null;
  } finally {
    savingForgePin.value = false;
  }
}

onMounted(async () => {
  await loadProject();
  loadTasks();
  loadNotes();
  loadDesignSystems();
  loadCoverage();
  loadForgeOptions();
});

/** 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();
  loadCoverage();
  loadForgeOptions();
});

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="page-container">

    <!-- 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>

      <!-- Inception (milestone 297): the owner of an undecided project is asked
           what it inherits; once recorded, one line says what was decided. -->
      <InceptionCard
        v-if="project.inception == null && isProjectOwner"
        mode="decide"
        :project-id="projectId"
        @decided="onInceptionDecided"
      />
      <p v-else-if="project.inception" class="inception-line">
        Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
        <template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
          · excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
        </template>
        <template v-if="project.inception.choices.subscribe_rulebooks.length">
          · subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
        </template>
        · design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
        <template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
      </p>

      <!-- 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>

      <!-- Pattern-library coverage (#2692) — only when a forge is configured;
           forge-less installs never see this card at all. -->
      <div v-if="coverageConfigured" class="coverage-card">
        <div class="coverage-head">
          <span class="coverage-title">Pattern coverage</span>
          <span class="coverage-estimate" title="Keyword-based extraction over- and under-counts; watch the trend, not the digit">estimate</span>
          <span v-if="coverage" class="coverage-when">computed {{ relativeTime(coverage.computed_at) }}</span>
          <button
            class="btn-ghost btn-compact coverage-refresh"
            :disabled="coverageRefreshing"
            @click="refreshCoverage"
          >
            {{ coverageRefreshing ? "Measuring…" : "Refresh" }}
          </button>
        </div>
        <template v-if="coverage">
          <div class="coverage-numbers">
            <span class="coverage-count">{{ coverage.accounted }}/{{ coverage.total }}</span>
            <span class="coverage-label">shapes accounted for</span>
          </div>
          <div
            class="coverage-bar"
            role="progressbar"
            :aria-valuenow="coverage.accounted"
            :aria-valuemin="0"
            :aria-valuemax="coverage.total"
            aria-label="Shapes classified against canon"
          >
            <div
              class="coverage-bar-fill"
              :style="{ width: (coverage.total ? (coverage.accounted / coverage.total) * 100 : 0) + '%' }"
            ></div>
          </div>
          <div v-if="coverage.counts" class="coverage-gaps">
            <span
              v-for="k in ['canonical', 'instance', 'variant', 'exempt', 'scoped']"
              :key="k"
            >
              <span v-if="coverage.counts[k]" class="coverage-gap-chip">
                {{ coverage.counts[k] }} {{ k }}
              </span>
            </span>
          </div>
          <div v-if="coverage.largest_gaps?.length" class="coverage-gaps">
            <span class="coverage-gaps-label">Most unclassified:</span>
            <span v-for="gap in coverage.largest_gaps" :key="gap.dir" class="coverage-gap-chip">
              {{ gap.dir }} <span class="coverage-gap-count">{{ gap.unclassified }}</span>
            </span>
          </div>
          <div
            v-if="coverage.proposed || coverage.derive_groups?.length"
            class="coverage-gaps"
            title="The proposer matched these against canon; an agent confirms them in batches (confirm_shape_proposals)."
          >
            <span class="coverage-gaps-label">Proposed:</span>
            <span v-if="coverage.proposed" class="coverage-gap-chip">
              {{ coverage.proposed }} awaiting confirm
            </span>
            <span
              v-for="g in coverage.derive_groups || []"
              :key="g.group"
              class="coverage-gap-chip"
              :title="'Repeats with no canon — derive one first. ' + g.paths.join(', ')"
            >
              {{ g.label }} <span class="coverage-gap-count">×{{ g.size }}</span>
            </span>
          </div>
          <div
            v-if="coverage.divergent || coverage.recheck"
            class="coverage-gaps"
            title="Divergent: a new shape where one canon dominates its directory and that isn't proposed as that canon — button B where button A is canon. Recheck: a judged shape whose body changed since it was judged."
          >
            <span class="coverage-gaps-label">Divergence:</span>
            <span
              v-for="d in coverage.divergence || []"
              :key="d.path + '::' + d.symbol"
              class="coverage-gap-chip coverage-divergent"
              :title="d.path + ' — canon here is snippet #' + d.canon_snippet_id"
            >
              {{ d.kind === 'css' ? '.' : '' }}{{ d.symbol }}
              <span class="coverage-gap-count">→ #{{ d.canon_snippet_id }}</span>
            </span>
            <span v-if="(coverage.divergent || 0) > (coverage.divergence?.length || 0)" class="coverage-gap-chip">
              +{{ (coverage.divergent || 0) - (coverage.divergence?.length || 0) }} more
            </span>
            <span v-if="coverage.recheck" class="coverage-gap-chip">
              {{ coverage.recheck }} to recheck
            </span>
          </div>
        </template>
        <p v-else class="coverage-empty">
          Not measured yet — Refresh reads the bound repo's definitions into
          the shape ledger and reports how many are classified against canon.
        </p>
        <p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
        <!-- Forge pin (#2778): owner-only, because the eligible set is the
             owner's own keyring. Automatic = resolve by repo host. -->
        <div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
          <label for="forge-pin" class="coverage-gaps-label">Forge</label>
          <select
            id="forge-pin"
            v-model="forgePin"
            class="input"
            :disabled="savingForgePin"
            @change="saveForgePin"
          >
            <option :value="null">Automatic (by repo host)</option>
            <option v-for="c in forgeOptions" :key="c.id" :value="c.id">
              {{ c.host }} ({{ c.kind }})
            </option>
          </select>
        </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="fs-input 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="fs-input edit-textarea" rows="6" placeholder="Optional description..."></textarea>
          </div>
          <div class="edit-field">
            <label class="edit-label">Status</label>
            <select v-model="editStatus" class="fs-input 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="fs-input 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>
            <p v-else-if="tasksError" class="tasks-error">{{ tasksError }}</p>
            <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>
                  <template v-else>
                    <div
                      :class="['ms-plan-rendered', 'markdown-body',
                               { 'ms-plan-clamped': isPlanClamped(group.milestone) }]"
                      @click="startEditPlan(group.milestone.id, group.milestone.body)"
                      v-html="renderMarkdown(group.milestone.body || '')"
                    ></div>
                    <button
                      v-if="isPlanLong(group.milestone)"
                      class="btn-text ms-plan-toggle"
                      @click.stop="togglePlanExpanded(group.milestone.id)"
                    >
                      {{ expandedPlans.has(group.milestone.id) ? "Show less" : "Show more" }}
                    </button>
                  </template>
                </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 ─────────────────────────────────────────────────── */
/* ── 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(--fs-surface-page);
  color: inherit;
  border: 1px solid var(--fs-border-color);
  border-radius: 6px;
  padding: 0.4rem 0.6rem;
  font: inherit;
  min-width: 200px;
}

.inception-line { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.85rem; }
.project-title-input {
  flex: 1;
  font-size: 1.75rem;
  font-weight: 500;
  font-family: "Fraunces", Georgia, serif;
  color: var(--fs-text-primary);
  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(--fs-accent); }
.project-title-input::placeholder { color: var(--fs-text-tertiary); 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(--fs-success) 14%, transparent); color: var(--fs-success); border: 1px solid color-mix(in srgb, var(--fs-success) 30%, transparent); }
.status-paused    { background: color-mix(in srgb, var(--fs-warning) 14%, transparent); color: var(--fs-warning); border: 1px solid color-mix(in srgb, var(--fs-warning) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--fs-accent) 14%, transparent); color: var(--fs-accent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); }
.status-archived  { background: color-mix(in srgb, var(--fs-text-tertiary) 14%, transparent); color: var(--fs-text-tertiary); border: 1px solid color-mix(in srgb, var(--fs-text-tertiary) 30%, transparent); }

.project-goal {
  font-size: 1rem;
  color: var(--fs-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(--fs-text-tertiary);
  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(--fs-radius-lg);
  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(--fs-text-tertiary); }
.dot-inprogress { background: var(--fs-status-in-progress); }
.dot-done      { background: var(--fs-status-done); }

.stat-todo      { background: color-mix(in srgb, var(--fs-text-tertiary) 8%, transparent); color: var(--fs-text-secondary); border-color: var(--fs-border-color); }
.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(--fs-success) 10%, transparent); color: var(--fs-success); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); }
.stat-notes     { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); }

/* ── Pattern-library coverage card ───────────────────────────── */
.coverage-card {
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  padding: 0.75rem 1rem;
  margin-bottom: 1.25rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.coverage-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.coverage-title {
  font-size: 0.72rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--fs-text-tertiary);
}
.coverage-estimate {
  font-size: 0.68rem;
  padding: 0.05rem 0.4rem;
  border-radius: var(--fs-radius-lg);
  border: 1px solid var(--fs-border-color);
  color: var(--fs-text-tertiary);
  cursor: help;
}
.coverage-when {
  font-size: 0.72rem;
  color: var(--fs-text-tertiary);
}
.coverage-refresh { margin-left: auto; }
.coverage-numbers {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
}
.coverage-count { font-size: 1.15rem; font-weight: 500; }
.coverage-label { font-size: 0.82rem; color: var(--fs-text-secondary); }
.coverage-bar {
  height: 6px;
  border-radius: 3px;
  background: color-mix(in srgb, var(--fs-text-tertiary) 14%, transparent);
  overflow: hidden;
}
.coverage-bar-fill {
  height: 100%;
  border-radius: 3px;
  background: var(--fs-accent);
  transition: width 0.3s ease;
}
.coverage-gaps {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
  font-size: 0.78rem;
}

.coverage-forge-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.6rem;
  font-size: 0.78rem;
}

.coverage-forge-row select {
  max-width: 20rem;
}
.coverage-gaps-label { color: var(--fs-text-tertiary); }
.coverage-gap-chip {
  padding: 0.1rem 0.5rem;
  border-radius: var(--fs-radius-lg);
  border: 1px solid var(--fs-border-color);
  color: var(--fs-text-secondary);
  font-family: var(--fs-font-mono);
  font-size: 0.74rem;
}
.coverage-gap-count { opacity: 0.65; }
.coverage-divergent { border-color: var(--fs-warning); }
.coverage-empty {
  margin: 0;
  font-size: 0.8rem;
  color: var(--fs-text-tertiary);
}
.coverage-error {
  margin: 0;
  font-size: 0.8rem;
  color: var(--fs-error);
}

/* ── 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 `.page-container`'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(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  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(--fs-text-tertiary);
}
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
.edit-label {
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--fs-text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}
/* The input itself is the .fs-input canon (components.css); only the
   layout remainder lives here. */
.edit-input, .edit-textarea, .edit-select { box-sizing: border-box; width: 100%; }
.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(--fs-text-secondary);
  font-family: inherit;
  margin-bottom: -1px;
  transition: color 0.15s;
}
.tab-btn:hover { color: var(--fs-accent); }
.tab-btn.active { color: var(--fs-accent); border-bottom-color: var(--fs-accent); font-weight: 500; }
.tab-count {
  font-size: 0.7rem;
  font-weight: 500;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: 999px;
  padding: 0 0.4rem;
  line-height: 1.5;
  color: var(--fs-text-tertiary);
}
.tab-btn.active .tab-count {
  background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
  border-color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
  color: var(--fs-accent);
}

/* ── 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(--fs-border-color);
  color: var(--fs-text-secondary);
  padding: 0.28rem 0.65rem;
  border-radius: var(--fs-radius-sm);
  cursor: pointer;
  font-size: 0.78rem;
  font-family: inherit;
}
.btn-add-milestone:hover { border-color: var(--fs-accent); color: var(--fs-accent); }

.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(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  background: var(--fs-surface-page);
  color: var(--fs-text-primary);
  font-size: 0.875rem;
  font-family: inherit;
}
.milestone-title-input:focus { outline: none; border-color: var(--fs-accent); }
/* 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(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-lg);
  overflow: hidden;
  margin-bottom: 0.75rem;
}

.milestone-header {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.85rem;
  background: var(--fs-surface-raised);
}

.milestone-header.clickable { cursor: pointer; }
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--fs-accent) 4%, var(--fs-surface-raised)); }

/* 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(--fs-accent) 3%, var(--fs-surface-raised));
  border-bottom: 1px solid var(--fs-border-color);
}
.ms-plan-rendered { font-size: 0.85rem; color: var(--fs-text-primary); cursor: text; }
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--fs-accent) 4%, transparent); }

/* max-height rather than -webkit-line-clamp: the body is rendered markdown, so
   it holds headings, lists and tables. line-clamp counts lines inside ONE inline
   formatting context and behaves unpredictably once block children are involved,
   which is most plans. */
.ms-plan-clamped {
  max-height: 6.5rem;
  overflow: hidden;
  position: relative;
}
/* Fades into the plan block's own background, which is a tint over the card —
   restate it here rather than approximating, or the fade shows as a grey band. */
.ms-plan-clamped::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 2.25rem;
  background: linear-gradient(
    to bottom,
    transparent,
    color-mix(in srgb, var(--fs-accent) 3%, var(--fs-surface-raised))
  );
  pointer-events: none;   /* the text under the fade stays clickable to edit */
}
.ms-plan-toggle { padding-left: 0; margin-top: 0.15rem; }
.ms-plan-editor {
  width: 100%;
  font-family: var(--fs-font-mono);
  font-size: 0.8rem;
  line-height: 1.5;
  padding: 0.5rem;
  border: 1px solid var(--fs-border-color);
  border-radius: 6px;
  background: var(--fs-surface-page);
  color: var(--fs-text-primary);
  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(--fs-border-color);
}
.ms-plan-actions .btn-primary { background: var(--fs-action-primary); color: var(--fs-text-on-action); border-color: var(--fs-action-primary); }
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
.ms-plan-actions .btn-secondary { background: var(--fs-surface-raised); color: var(--fs-text-primary); }

.ms-chevron { display: flex; align-items: center; color: var(--fs-text-tertiary); flex-shrink: 0; }
.ms-name { font-weight: 500; color: var(--fs-text-primary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-count {
  font-size: 0.7rem;
  color: var(--fs-text-tertiary);
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: 999px;
  padding: 0.05rem 0.4rem;
  flex-shrink: 0;
  font-weight: 500;
}
.ms-progress-track {
  width: 72px;
  height: 6px;
  background: var(--fs-border-color);
  border-radius: 999px;
  overflow: hidden;
  flex-shrink: 0;
}
.ms-progress-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--fs-accent), color-mix(in srgb, var(--fs-accent) 70%, var(--fs-success)));
  border-radius: 999px;
  transition: width 0.4s ease;
}
.ms-pct { font-size: 0.72rem; color: var(--fs-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(--fs-text-tertiary);
  width: 24px;
  height: 24px;
  border-radius: var(--fs-radius-sm);
}
.ms-action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
.ms-action-delete:hover { color: var(--fs-error); }

.ms-rename-input {
  flex: 1;
  min-width: 0;
  padding: 0.1rem 0.35rem;
  border: 1px solid var(--fs-accent);
  border-radius: var(--fs-radius-sm);
  background: var(--fs-surface-page);
  color: var(--fs-text-primary);
  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(--fs-surface-page) 60%, var(--fs-surface-raised));
}
.kanban-col {
  background: var(--fs-surface-raised);
  border-radius: var(--fs-radius-lg);
  padding: 0.65rem;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  border-top: 3px solid;
}
.col-todo      { border-top-color: var(--fs-border-color); }
.col-inprogress { border-top-color: var(--fs-status-in-progress); }
.col-done      { border-top-color: var(--fs-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(--fs-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(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: 999px;
  padding: 0.05rem 0.4rem;
  font-size: 0.68rem;
  color: var(--fs-text-tertiary);
  font-weight: 500;
}
.col-add-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  color: var(--fs-text-tertiary);
  text-decoration: none;
  font-size: 1rem;
  line-height: 1;
  border-radius: 3px;
  margin-left: auto;
}
.col-add-btn:hover { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }

.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }

.task-card {
  display: block;
  background: var(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-left: 3px solid transparent;
  border-radius: var(--fs-radius-sm);
  padding: 0.45rem 0.55rem;
  text-decoration: none;
  color: var(--fs-text-primary);
  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(--fs-accent) 50%, var(--fs-border-color));
  border-left-color: var(--fs-accent);
  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(--fs-error); }
.task-card.pri-medium { border-left-color: #f59e0b; }
.task-card.pri-low    { border-left-color: var(--fs-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(--fs-border-color);
  border-radius: 4px;
  background: transparent;
  color: var(--fs-text-tertiary);
  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(--fs-action-primary); border-color: var(--fs-action-primary); color: var(--fs-text-on-action); }
.task-advance-btn--done:hover { background: var(--fs-success); border-color: var(--fs-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(--fs-error); }
.dot-pri-medium { background: #f59e0b; }
.dot-pri-low    { background: var(--fs-success); }

.due-date { font-size: 0.7rem; color: var(--fs-text-tertiary); }
.col-empty {
  text-align: center;
  padding: 1.25rem 0.5rem;
  color: var(--fs-text-tertiary);
  font-size: 0.78rem;
  border: 1px dashed var(--fs-border-color);
  border-radius: var(--fs-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(--fs-surface-raised);
  border: 1px solid var(--fs-border-color);
  border-radius: var(--fs-radius-sm);
  text-decoration: none;
  color: var(--fs-text-primary);
  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(--fs-accent) 50%, var(--fs-border-color));
  box-shadow: 0 3px 10px rgba(0,0,0,0.07);
  transform: translateY(-1px);
}
.note-icon { color: var(--fs-text-tertiary); 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(--fs-text-tertiary); flex-shrink: 0; }

.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; text-align: center; padding: 1rem; }
/* Deliberately NOT styled like .empty-msg: "no tasks" and "the tasks did not
   load" look identical to a user, and conflating them is what let a silent
   failure read as an empty project. */
.tasks-error { color: var(--fs-error); font-size: 0.875rem; padding: 1rem; text-align: center; }

/* ── Modal ───────────────────────────────────────────────────── */
.modal-overlay {
  position: fixed; inset: 0;
  background: var(--fs-overlay);
  display: flex; align-items: center; justify-content: center;
  z-index: 200;
}

/* ── 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(--fs-radius-sm);
  background: linear-gradient(
    90deg,
    var(--fs-surface-raised) 25%,
    color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
    var(--fs-surface-raised) 75%
  );
  background-size: 200% 100%;
  animation: skel-shine 1.5s ease infinite;
}
.skel-title  { height: 2.2rem; width: 55%; border-radius: var(--fs-radius-lg); }
.skel-goal   { height: 1rem; width: 75%; }
.skel-stats  { height: 2rem; width: 50%; border-radius: var(--fs-radius-lg); }
.skel-body   { height: 200px; border-radius: var(--fs-radius-lg); }
.skel-row    { height: 2.5rem; border-radius: var(--fs-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>
