From b0ac567038b00145759654abd879b9f01d5ca0b2 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 1 Apr 2026 13:48:01 -0700 Subject: [PATCH] fix(FN-680): restore TaskStore project resolution and fresh schema writes - fix TaskStore.getOrCreateForProject to resolve projects through BackwardCompat and support legacy .fusion/.kb detection - add regression tests for exact-name project lookup, legacy cwd fallback, and fresh-schema task create/update persistence - sync the tasks schema definition with missionId support so fresh databases match runtime migrations - add project overview and selector styles for the multi-project dashboard UI --- .changeset/fn-680-taskstore-regressions.md | 5 + .../__tests__/store-backward-compat.test.ts | 47 +- packages/core/src/db.ts | 1 + packages/core/src/migration.ts | 18 +- packages/core/src/store.test.ts | 76 +++ packages/core/src/store.ts | 125 ++-- packages/dashboard/app/styles.css | 560 ++++++++++++++++++ 7 files changed, 724 insertions(+), 108 deletions(-) create mode 100644 .changeset/fn-680-taskstore-regressions.md diff --git a/.changeset/fn-680-taskstore-regressions.md b/.changeset/fn-680-taskstore-regressions.md new file mode 100644 index 000000000..b378188b3 --- /dev/null +++ b/.changeset/fn-680-taskstore-regressions.md @@ -0,0 +1,5 @@ +--- +"@gsxdsm/fusion": patch +--- + +Fix project resolution and fresh-schema task persistence regressions. diff --git a/packages/core/src/__tests__/store-backward-compat.test.ts b/packages/core/src/__tests__/store-backward-compat.test.ts index ae86f9b5f..dc28a75c2 100644 --- a/packages/core/src/__tests__/store-backward-compat.test.ts +++ b/packages/core/src/__tests__/store-backward-compat.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { TaskStore } from "../store.js"; @@ -10,10 +11,13 @@ function createTempDir(): string { return mkdtempSync(join(tmpdir(), "kb-compat-test-")); } -// Helper to create a fake kb project structure -function createFakeKbProject(dir: string): void { - mkdirSync(join(dir, ".kb"), { recursive: true }); - writeFileSync(join(dir, ".kb", "kb.db"), ""); +// Helper to create a fake fusion project structure for the current store implementation +function createFakeFusionProject(dir: string): void { + const fusionDir = join(dir, ".fusion"); + mkdirSync(fusionDir, { recursive: true }); + const db = new DatabaseSync(join(fusionDir, "kb.db")); + db.exec("CREATE TABLE IF NOT EXISTS sanity (id INTEGER PRIMARY KEY)"); + db.close(); } describe("TaskStore Backward Compatibility", () => { @@ -58,7 +62,7 @@ describe("TaskStore Backward Compatibility", () => { expect(settings).toBeDefined(); }); - it("should find project by name when ID not found", async () => { + it("should fall back to exact project name lookup when ID lookup misses", async () => { const projectDir = join(tempDir, "my-project"); mkdirSync(projectDir, { recursive: true }); @@ -115,13 +119,24 @@ describe("TaskStore Backward Compatibility", () => { }); - it("should fall back to legacy mode when no projects registered", async () => { - // No projects registered in central core - process.chdir(tempDir); + it("should fall back to process.cwd() legacy mode against the current .fusion path", async () => { + const projectDir = join(tempDir, "legacy-project"); + mkdirSync(projectDir, { recursive: true }); + createFakeFusionProject(projectDir); + process.chdir(projectDir); + + const centralDb = join(tempDir, "kb-central.db"); + await centralCore.close(); + rmSync(centralDb, { force: true }); + centralCore = new CentralCore(tempDir); const store = await TaskStore.getOrCreateForProject(undefined, centralCore); expect(store).toBeInstanceOf(TaskStore); + const task = await store.createTask({ description: "legacy task" }); + expect(task.id).toBe("FN-001"); + expect(existsSync(join(projectDir, ".fusion", "kb.db"))).toBe(true); + expect(existsSync(join(projectDir, ".fusion", "tasks", task.id, "task.json"))).toBe(true); }); it("should throw when project ID not found", async () => { @@ -130,6 +145,20 @@ describe("TaskStore Backward Compatibility", () => { ).rejects.toThrow('Project "non-existent-project" not found'); }); + it("should find project by exact registered name", async () => { + const projectDir = join(tempDir, "Casey"); + mkdirSync(projectDir, { recursive: true }); + + await centralCore.registerProject({ + name: "Casey", + path: projectDir, + isolationMode: "in-process", + }); + + const store = await TaskStore.getOrCreateForProject("Casey", centralCore); + expect(store).toBeInstanceOf(TaskStore); + }); + it("should auto-initialize central core if not provided", async () => { const projectDir = join(tempDir, "my-project"); mkdirSync(projectDir, { recursive: true }); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 53280f07d..b6e474930 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -161,6 +161,7 @@ CREATE TABLE IF NOT EXISTS tasks ( breakIntoSubtasks INTEGER DEFAULT 0, enabledWorkflowSteps TEXT DEFAULT '[]', modifiedFiles TEXT DEFAULT '[]', + missionId TEXT, sliceId TEXT ); diff --git a/packages/core/src/migration.ts b/packages/core/src/migration.ts index 33c183201..0413281cb 100644 --- a/packages/core/src/migration.ts +++ b/packages/core/src/migration.ts @@ -541,8 +541,8 @@ export class BackwardCompat { const projects = await this.central.listProjects(); if (projects.length === 0) { - // No projects registered - check if cwd has a .kb/ directory - if (this.hasKbProject(cwd)) { + // No projects registered - check if cwd has a current .fusion project or legacy .kb project + if (this.hasProjectData(cwd)) { // Auto-migrate this project const coordinator = new MigrationCoordinator(this.central); const result = await coordinator.registerSingleProject(cwd); @@ -612,13 +612,17 @@ export class BackwardCompat { } /** - * Check if a directory contains a kb project. + * Check if a directory contains a current .fusion project or legacy .kb project. */ - private hasKbProject(dir: string): boolean { - const kbDir = join(dir, ".kb"); - const dbPath = join(kbDir, "kb.db"); + private hasProjectData(dir: string): boolean { + return this.hasProjectDb(dir, ".fusion") || this.hasProjectDb(dir, ".kb"); + } - if (!existsSync(kbDir)) return false; + private hasProjectDb(dir: string, folderName: ".fusion" | ".kb"): boolean { + const projectDir = join(dir, folderName); + const dbPath = join(projectDir, "kb.db"); + + if (!existsSync(projectDir)) return false; if (!existsSync(dbPath)) return false; try { diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index d3bcad537..ed97564d6 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -823,6 +823,82 @@ describe("TaskStore", () => { }); }); + describe("upsertTask regression coverage", () => { + it("creates tasks successfully on a fresh database schema", async () => { + const freshRoot = makeTmpDir(); + const freshGlobal = makeTmpDir(); + const freshStore = new TaskStore(freshRoot, freshGlobal); + await freshStore.init(); + + const task = await freshStore.createTask({ description: "fresh schema task" }); + expect(task.id).toBe("FN-001"); + expect(await freshStore.getTask(task.id)).toBeDefined(); + + freshStore.stopWatching(); + await rm(freshRoot, { recursive: true, force: true }); + await rm(freshGlobal, { recursive: true, force: true }); + }); + + it("persists createTask with nullable, array, and optional scalar fields", async () => { + const created = await store.createTask({ + title: "Persist me", + description: "Create path coverage", + column: "todo", + dependencies: ["FN-999"], + enabledWorkflowSteps: ["WS-001"], + modelProvider: "anthropic", + modelId: "claude-sonnet-4-5", + validatorModelProvider: "openai", + validatorModelId: "gpt-4o", + modelPresetId: "normal", + }); + + const persisted = await store.getTask(created.id); + expect(persisted.title).toBe("Persist me"); + expect(persisted.column).toBe("todo"); + expect(persisted.dependencies).toEqual(["FN-999"]); + expect(persisted.enabledWorkflowSteps).toEqual(["WS-001"]); + expect(persisted.modelProvider).toBe("anthropic"); + expect(persisted.validatorModelProvider).toBe("openai"); + expect(persisted.modelPresetId).toBe("normal"); + }); + + it("persists updateTask changes across scalar, array, and nullable JSON-backed fields", async () => { + const task = await store.createTask({ description: "Update path coverage" }); + + await store.updateTask(task.id, { + title: "Updated title", + dependencies: ["FN-002", "FN-003"], + blockedBy: "FN-002", + status: "failed", + error: "boom", + summary: "summary", + workflowStepResults: [ + { + workflowStepId: "WS-001", + workflowStepName: "QA", + status: "passed", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: "2026-04-01T00:01:00.000Z", + output: "ok", + }, + ], + modifiedFiles: ["packages/core/src/store.ts"], + }); + + const persisted = await store.getTask(task.id); + expect(persisted.title).toBe("Updated title"); + expect(persisted.dependencies).toEqual(["FN-002", "FN-003"]); + expect(persisted.blockedBy).toBe("FN-002"); + expect(persisted.status).toBe("failed"); + expect(persisted.error).toBe("boom"); + expect(persisted.summary).toBe("summary"); + expect(persisted.workflowStepResults).toHaveLength(1); + expect(persisted.workflowStepResults?.[0].workflowStepId).toBe("WS-001"); + expect(persisted.modifiedFiles).toEqual(["packages/core/src/store.ts"]); + }); + }); + describe("directory recreation for file-backed blobs", () => { it("pauseTask recreates missing task directory before writing task.json", async () => { const task = await createTestTask(); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 9dd83dabf..218a3e511 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -9,6 +9,8 @@ import { GlobalSettingsStore } from "./global-settings.js"; import { Database, toJson, toJsonNullable, fromJson } from "./db.js"; import { detectLegacyData, migrateFromLegacy } from "./db-migrate.js"; import { MissionStore } from "./mission-store.js"; +import { BackwardCompat, ProjectRequiredError } from "./migration.js"; +import { CentralCore } from "./central-core.js"; export interface TaskStoreEvents { "task:created": [task: Task]; @@ -21,6 +23,36 @@ export interface TaskStoreEvents { } export class TaskStore extends EventEmitter { + static async getOrCreateForProject(projectId?: string, centralCore?: CentralCore): Promise { + const central = centralCore ?? new CentralCore(); + let initializedHere = false; + + if (!centralCore) { + await central.init(); + initializedHere = true; + } + + try { + const compat = new BackwardCompat(central); + const context = await compat.resolveProjectContext(process.cwd(), projectId); + const store = new TaskStore(context.workingDirectory); + await store.init(); + return store; + } catch (error) { + if (error instanceof ProjectRequiredError) { + if (projectId) { + throw new Error(`Project "${projectId}" not found`); + } + throw new Error(error.message); + } + throw error; + } finally { + if (initializedHere) { + await central.close(); + } + } + } + /** * Hybrid storage note: task metadata lives in SQLite, while blob files remain on disk. * Any write to `.kb/tasks/{id}` must recreate the directory on demand, and any read from @@ -2062,8 +2094,7 @@ export class TaskStore extends EventEmitter { task.updatedAt = new Date().toISOString(); task.log.push({ timestamp: task.updatedAt, - action: "Comment added", - outcome: `by ${author}`, + action: `Comment added by ${author}`, }); await this.atomicWriteTaskJson(dir, task); @@ -2786,94 +2817,4 @@ ${notificationsSection}`; // ── Backward Compatibility (Multi-Project Support) ──────────────────────── - /** - * Get or create a TaskStore for a project, supporting backward-compatible - * single-project mode and multi-project resolution. - * - * Resolution logic: - * - If `projectId` provided: look up in central registry, create store for that path - * - If no `projectId` and single project registered: use that project - * - If no `projectId` and multiple projects: throw requiring explicit selection - * - If no central DB available: fall back to legacy behavior (current directory) - * - * @param projectId — Optional project ID to resolve - * @param centralCore — Optional CentralCore instance (creates new if not provided) - * @returns TaskStore initialized for the resolved project - * @throws Error if project resolution fails or multiple projects require explicit selection - */ - static async getOrCreateForProject( - projectId?: string, - centralCore?: import("./central-core.js").CentralCore - ): Promise { - // If no centralCore provided, try to create one - let core = centralCore; - let shouldCleanupCore = false; - - if (!core) { - try { - const { CentralCore } = await import("./central-core.js"); - core = new CentralCore(); - await core.init(); - shouldCleanupCore = true; - } catch { - // Central core not available - fall back to legacy mode - } - } - - // Legacy mode: no central core available - if (!core) { - const store = new TaskStore(process.cwd()); - await store.init(); - return store; - } - - try { - // If projectId provided, look it up directly - if (projectId) { - const project = await core.getProject(projectId); - if (!project) { - // Try to find by name - const allProjects = await core.listProjects(); - const byName = allProjects.find(p => p.name === projectId); - if (!byName) { - throw new Error(`Project "${projectId}" not found`); - } - const store = new TaskStore(byName.path); - await store.init(); - return store; - } - const store = new TaskStore(project.path); - await store.init(); - return store; - } - - // No projectId provided - check registered projects - const projects = await core.listProjects(); - - if (projects.length === 0) { - // No projects registered - fall back to legacy mode (current directory) - const store = new TaskStore(process.cwd()); - await store.init(); - return store; - } - - if (projects.length === 1) { - // Exactly one project - use it - const store = new TaskStore(projects[0].path); - await store.init(); - return store; - } - - // Multiple projects - require explicit selection - const projectList = projects.map(p => ` - ${p.name}: ${p.path}`).join("\n"); - throw new Error( - `Multiple projects registered. Use --project to specify one.\n\nAvailable projects:\n${projectList}` - ); - } finally { - // Clean up the central core if we created it - if (shouldCleanupCore && core) { - await core.close(); - } - } - } } diff --git a/packages/dashboard/app/styles.css b/packages/dashboard/app/styles.css index 225484673..6365c81e5 100644 --- a/packages/dashboard/app/styles.css +++ b/packages/dashboard/app/styles.css @@ -1141,6 +1141,566 @@ body { animation: spin 1s linear infinite; } + + +/* === ProjectOverview Component === */ +.project-overview { + display: flex; + flex-direction: column; + gap: var(--space-xl); + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: var(--space-xl); +} + +.project-overview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + flex-wrap: wrap; + padding-bottom: var(--space-lg); + border-bottom: 1px solid var(--border); +} + +.project-overview__title { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + margin: 0; + font-size: 24px; + font-weight: 700; + color: var(--text); +} + +.project-overview__stats { + display: flex; + align-items: stretch; + gap: var(--space-md); + flex: 1; + flex-wrap: wrap; + justify-content: center; +} + +.project-stat { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + min-width: 120px; + padding: var(--space-sm) var(--space-md); + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.project-stat--active { + border-color: rgba(88, 166, 255, 0.35); + background: rgba(88, 166, 255, 0.08); +} + +.project-stat--completed { + border-color: rgba(63, 185, 80, 0.35); + background: rgba(63, 185, 80, 0.08); +} + +.project-stat--error { + border-color: rgba(248, 81, 73, 0.35); + background: rgba(248, 81, 73, 0.08); +} + +.project-stat__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-muted); + flex-shrink: 0; +} + +.project-stat--active .project-stat__icon { + color: var(--todo); +} + +.project-stat--completed .project-stat__icon { + color: var(--color-success); +} + +.project-stat--error .project-stat__icon { + color: var(--color-error); +} + +.project-stat__content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.project-stat__value { + font-family: var(--font-mono); + font-size: 18px; + font-weight: 700; + line-height: 1.1; + color: var(--text); +} + +.project-stat__label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.project-overview__add-btn { + flex-shrink: 0; + white-space: nowrap; +} + +.project-overview__filters { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-lg); + flex-wrap: wrap; +} + +.project-filter-tabs { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.project-filter-tab { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + transform var(--transition-fast); +} + +.project-filter-tab:hover { + background: var(--card-hover); + border-color: var(--text-dim); + color: var(--text); +} + +.project-filter-tab.active { + background: rgba(88, 166, 255, 0.12); + border-color: rgba(88, 166, 255, 0.35); + color: var(--todo); +} + +.project-filter-tab.has-errors { + border-color: rgba(248, 81, 73, 0.35); +} + +.project-filter-tab.has-errors:not(.active) { + color: var(--color-error); +} + +.project-filter-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + border-radius: 999px; + background: var(--card); + border: 1px solid var(--border); + color: inherit; + font-size: 11px; + font-family: var(--font-mono); +} + +.project-sort { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-muted); +} + +.project-sort-select { + appearance: none; + background: transparent; + border: none; + color: var(--text); + font-size: 13px; + font-weight: 500; + padding: 0; + min-width: 210px; + outline: none; + cursor: pointer; +} + +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: var(--space-lg); + align-items: stretch; +} + +.project-overview--empty { + min-height: 60vh; + align-items: center; + justify-content: center; +} + +.project-empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-md); + max-width: 560px; + padding: var(--space-2xl); + text-align: center; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-md); +} + +.project-empty-state__icon { + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--surface); + color: var(--todo); +} + +.project-empty-state__title { + margin: 0; + font-size: 28px; + font-weight: 700; + color: var(--text); +} + +.project-empty-state__description { + margin: 0; + font-size: 15px; + line-height: 1.6; + color: var(--text-muted); +} + +.project-empty-state__cta { + margin-top: var(--space-sm); +} + +.project-overview__no-results { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + padding: var(--space-2xl); + border: 1px dashed var(--border); + border-radius: var(--radius-xl); + color: var(--text-muted); + text-align: center; + background: color-mix(in srgb, var(--surface) 85%, transparent); +} + +/* === ProjectSelector Component === */ +.project-selector { + position: relative; + display: inline-block; +} + +.project-selector__trigger { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + min-width: 220px; + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text); + cursor: pointer; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + box-shadow var(--transition-fast); +} + +.project-selector__trigger:hover, +.project-selector__trigger.open { + background: var(--card-hover); + border-color: var(--text-dim); +} + +.project-selector__trigger.open { + box-shadow: var(--shadow-sm); +} + +.project-selector__trigger-icon { + color: var(--todo); + flex-shrink: 0; +} + +.project-selector__trigger-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + font-weight: 500; +} + +.project-selector__trigger-chevron { + color: var(--text-muted); + flex-shrink: 0; + transition: transform var(--transition-fast); +} + +.project-selector__trigger-chevron.rotate { + transform: rotate(180deg); +} + +.project-selector__dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: 50; + min-width: 320px; + max-width: min(420px, 90vw); + max-height: min(70vh, 520px); + overflow-y: auto; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); +} + +.project-selector__search { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + background: var(--card); + z-index: 1; +} + +.project-selector__search-icon { + color: var(--text-muted); + flex-shrink: 0; +} + +.project-selector__search-input { + flex: 1; + min-width: 0; + background: transparent; + border: none; + color: var(--text); + font-size: 13px; + outline: none; +} + +.project-selector__search-input::placeholder { + color: var(--text-dim); +} + +.project-selector__search-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 999px; + color: var(--text-muted); + cursor: pointer; +} + +.project-selector__search-clear:hover { + color: var(--text); + border-color: var(--text-dim); +} + +.project-selector__section { + display: flex; + flex-direction: column; + padding: var(--space-sm); +} + +.project-selector__section + .project-selector__section { + border-top: 1px solid var(--border); +} + +.project-selector__section-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.project-selector__item, +.project-selector__view-all { + display: flex; + align-items: center; + gap: var(--space-sm); + width: 100%; + padding: 10px 12px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-md); + color: var(--text); + text-align: left; + cursor: pointer; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); +} + +.project-selector__item:hover, +.project-selector__item.highlighted, +.project-selector__view-all:hover, +.project-selector__view-all.highlighted { + background: var(--surface); + border-color: var(--border); +} + +.project-selector__item-info { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.project-selector__item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} + +.project-selector__item-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + color: var(--text-muted); +} + +.project-selector__item-check { + margin-left: auto; + color: var(--todo); + flex-shrink: 0; +} + +.project-selector__no-results { + padding: var(--space-lg) var(--space-md); + color: var(--text-muted); + font-size: 13px; + text-align: center; +} + +.project-selector__footer { + padding: var(--space-sm); + border-top: 1px solid var(--border); +} + +.project-selector__view-all { + justify-content: center; + font-weight: 600; + color: var(--todo); +} + +@media (max-width: 900px) { + .project-overview__header { + align-items: flex-start; + } + + .project-overview__stats { + width: 100%; + justify-content: flex-start; + } + + .project-overview__filters { + align-items: stretch; + } + + .project-sort { + width: 100%; + } + + .project-sort-select { + width: 100%; + min-width: 0; + } +} + +@media (max-width: 640px) { + .project-overview { + padding: var(--space-lg); + } + + .project-overview__title { + font-size: 20px; + } + + .project-stat { + flex: 1 1 calc(50% - var(--space-sm)); + min-width: 0; + } + + .project-grid { + grid-template-columns: 1fr; + } + + .project-selector, + .project-selector__trigger, + .project-selector__dropdown { + width: 100%; + min-width: 0; + } + + .project-selector__dropdown { + left: 0; + right: 0; + max-width: none; + } +} + /* === ActivityFeed Component === */ .activity-feed { display: flex;