Files
FabledScribe/frontend/src/views/ProjectView.vue
T
bvandeusenandClaude Fable 5 c5faaf38fb
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 36s
refactor(frontend): delete the dead CSS the consumer map surfaced — 102 classes, 588 lines (#2962)
Milestone 302's map flagged 195 css rows no template names. Re-derived the
list locally with a stricter test than the map's — a class is dead only if
its token appears NOWHERE outside a <style> block, in markup or script, and
is not the static half of a concatenated name (`priority-${p}`) — which
takes the map's known blind spot off the table. 123 survived that; 21 of
those are Vue <Transition>/<transition-group> classes generated at runtime
from a name= attribute (detail-fade, peek-slide, shortcuts-fade, tab-fade,
toast) and one is ProseMirror's vendor class. Those stay. The other 102 go.

- SettingsView.vue (80, -417 lines): whole features whose UI was removed —
  Ollama model management (model-*, pull-bar-*, suggestion-chip), push
  notifications (push-*), the voice library and voice blending (voice-*,
  blend-*), geo status (geo-*), MCP package rows, retention, the learned
  summary, and the form furniture that served them.
- editor-shared.css (16, -151): the standalone assist panel, superseded by
  the sidebar assist section — streaming now renders as .stream-preview in
  the main area, so .assist-panel*, .assist-sections*, .assist-preview-box
  and .typing-indicator have no markup left. @keyframes blink went with the
  last rule that animated it. .editor-body/.editor-main are dead too:
  TaskEditorView replaced them with .task-body/.task-main.
- theme.css: .btn-new-conv/.btn-send dropped from the touch-target list
  (names from another app), and the .hide-desktop utility no one used. The
  generated --fs-* token block is untouched.
- ShareDialog .user-result-email, KnowledgeView .today-link,
  ProjectView .edit-input (the remainder rule keeps its rationale comment
  and its two live selectors).

A selector dies only when every class in its descendant chain is checked —
`.live .dead` matches nothing either — and a rule only when its whole comma
list is dead, so mixed lists keep their live half. Template and script
regions are byte-identical; check_dangling_styles.py and
check_design_tokens.py both pass, with only the six pre-existing reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:16:06 -04:00

1863 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, 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 {
margin-bottom: 1.5rem; /* roomier than the shared recipe */
}
.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-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 { text-align: center; padding: 1rem; } /* remainder over the shared recipe */
/* 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>