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
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>
1863 lines
70 KiB
Vue
1863 lines
70 KiB
Vue
<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>
|