diff --git a/DEVLOG.md b/DEVLOG.md
index bf60e79c..b1c0aaad 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -13173,3 +13173,35 @@ fresh production E2E build completed. The browser run passed 35 of 38 journeys
before Chromium crashed on a memory-saturated host; each of the three reported
journeys passed immediately in a fresh isolated browser process. Release,
deployment, and production smoke results follow after the pull request lands.
+
+---
+
+## 2026-07-15 — Projects and issues lifecycle UX
+
+Audited the projects, project creation, global issues, and project issue flows
+from captured browser evidence. The same completed issue could previously be
+absent from the default list, visible on the default board, and hidden again
+behind an implicit Done filter, while duplicate quick filters and an empty
+saved-view row obscured what the page was actually showing.
+
+Added one explicit Open/All lifecycle scope shared by global List and Kanban
+views and by project List and Board tabs. Counts, columns, results, URLs,
+search labels, and filtered-empty recovery now use that same scope; searches
+with no open match offer a direct completed-work recovery path. Removed the
+duplicate Done quick filter, clarified updated-time filtering versus sorting,
+hid the saved-view bar when it has nothing to show, and added a clear-search
+control.
+
+Project creation now identifies required fields before submission, explains
+that the project key becomes an immutable issue-ID prefix, clears validation
+feedback as the form is corrected, gives color choices accessible names and
+selected state, and navigates directly to the created project. Starter
+templates follow the same destination. Project cards now expose explicit
+completed/total progress text and a semantic progress bar.
+
+Verification: formatting and typecheck passed; lint passed with existing
+repository warnings; the focused saved-view filter suite passed 13 tests; the
+fresh production E2E build succeeded; and both new Playwright journeys passed.
+The unconfigured full Vitest command could not run integration tests because
+this shell did not provide `DATABASE_URL` or `AUTH_SECRET`; its focused,
+dependency-free coverage passed separately.
diff --git a/src/app/(app)/w/[slug]/issues/page.tsx b/src/app/(app)/w/[slug]/issues/page.tsx
index 2bf5e2a8..43b68f50 100644
--- a/src/app/(app)/w/[slug]/issues/page.tsx
+++ b/src/app/(app)/w/[slug]/issues/page.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
-import { Archive, ArchiveRestore, Sparkles, Folder } from "lucide-react";
+import { Archive, ArchiveRestore, Sparkles, Folder, X } from "lucide-react";
import Link from "next/link";
import { Topbar } from "@/components/topbar";
import { IssueList } from "@/components/issue-list";
@@ -22,11 +22,13 @@ import {
import { GroupChip, IssueFacetChips, SortChip } from "@/components/saved-views/facet-chips";
import { QuickFilterChips } from "@/components/saved-views/quick-filter-chips";
import { SavedViewsBar, SaveViewDialog } from "@/components/saved-views/saved-views-bar";
+import { IssueScopeToggle, type IssueLifecycleScope } from "@/components/issue-scope-toggle";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { useWorkspace } from "@/hooks/use-workspace";
import {
doneStatusIds,
+ filtersTargetDone,
filtersEqual,
isEmptyFilters,
ISSUE_GROUP_VALUES,
@@ -75,6 +77,7 @@ export default function IssuesPage() {
const fParam = searchParams?.get("f") ?? null;
const dueOnFromUrl = searchParams?.get("dueOn") ?? null;
const showArchived = searchParams?.get("archived") === "1";
+ const requestedScope: IssueLifecycleScope = searchParams?.get("scope") === "all" ? "all" : "open";
// Reject a malformed or calendar-invalid ?dueOn (e.g. 2026-13-45, which
// passes the shape regex but rolls over) before it reaches the chip/query.
const dueOn = dueOnFromUrl && isRealDateString(dueOnFromUrl) ? dueOnFromUrl : null;
@@ -114,6 +117,13 @@ export default function IssuesPage() {
const { data: wsCount, isLoading: wsLoading } = trpc.workspace.current.useQuery();
const { data: me } = trpc.user.me.useQuery();
const { data: views } = trpc.savedView.list.useQuery();
+ const { data: statuses } = trpc.status.list.useQuery();
+ const doneIds = useMemo(() => doneStatusIds(statuses ?? []), [statuses]);
+ // Legacy saved views may already encode terminal inclusion in their
+ // filter blob. Reflect that truth in the visible scope control instead
+ // of claiming the view is "Open" while rendering completed work.
+ const scope: IssueLifecycleScope =
+ filters.includeDone || filtersTargetDone(filters, doneIds) ? "all" : requestedScope;
const key = wsCount?.key ?? ws.key;
// Canonical URL writer. Every mutation routes through here, building the
@@ -129,6 +139,7 @@ export default function IssuesPage() {
view: string | null;
dueOn: string | null;
archived: boolean;
+ scope: IssueLifecycleScope;
}) => {
const p = new URLSearchParams();
if (s.view) p.set("view", s.view);
@@ -138,6 +149,7 @@ export default function IssuesPage() {
if (s.group !== "status") p.set("group", s.group);
if (s.dueOn) p.set("dueOn", s.dueOn);
if (s.archived) p.set("archived", "1");
+ if (s.scope === "all") p.set("scope", "all");
return p.toString();
},
[],
@@ -152,6 +164,7 @@ export default function IssuesPage() {
view: string | null;
dueOn: string | null;
archived: boolean;
+ scope: IssueLifecycleScope;
}>,
opts?: { push?: boolean },
) => {
@@ -163,6 +176,7 @@ export default function IssuesPage() {
view: next.view !== undefined ? next.view : (activeViewId ?? viewIdFromUrl),
dueOn: next.dueOn !== undefined ? next.dueOn : dueOn,
archived: next.archived ?? showArchived,
+ scope: next.scope ?? scope,
});
const url = qs ? `${pathname}?${qs}` : pathname;
if (opts?.push) router.push(url);
@@ -178,6 +192,7 @@ export default function IssuesPage() {
viewIdFromUrl,
dueOn,
showArchived,
+ scope,
pathname,
router,
],
@@ -289,7 +304,30 @@ export default function IssuesPage() {
setActiveViewId(null);
// One URL write that drops filters, query, view AND dueOn together —
// the old two-call clear read a stale snapshot and could re-add ?view=.
- commit({ filters: {}, query: "", view: null, dueOn: null }, { push: true });
+ commit({ filters: {}, query: "", view: null, dueOn: null, scope: "open" }, { push: true });
+ }
+
+ function setLifecycleScope(nextScope: IssueLifecycleScope) {
+ if (nextScope === "all") {
+ commit({ scope: "all" }, { push: true });
+ return;
+ }
+
+ // Returning to Open also clears terminal-only selections and legacy
+ // `includeDone` flags so the visible scope is authoritative.
+ const nextStatusIds = filters.statusIds?.filter((id) => !doneIds.has(id));
+ const nextStatusCategories = filters.statusCategories?.filter(
+ (category) => category !== "DONE" && category !== "CANCELED",
+ );
+ const nextFilters: SavedViewFilters = {
+ ...filters,
+ statusIds: nextStatusIds?.length ? nextStatusIds : undefined,
+ statusCategories: nextStatusCategories?.length ? nextStatusCategories : undefined,
+ includeDone: undefined,
+ };
+ setFilters(nextFilters);
+ setActiveViewId(null);
+ commit({ filters: nextFilters, view: null, scope: "open" }, { push: true });
}
function clearDueOn() {
@@ -300,8 +338,10 @@ export default function IssuesPage() {
commit({ archived }, { push: true });
}
- const hasFilters = !isEmptyFilters(filters) || !!query || !!dueOn;
- const isWorkspaceEmpty = !showArchived && !!wsCount && wsCount._count.issues === 0 && !hasFilters;
+ const hasNarrowing = !isEmptyFilters(filters) || !!query || !!dueOn;
+ const hasViewAdjustments = hasNarrowing || scope !== "open";
+ const isWorkspaceEmpty =
+ !showArchived && !!wsCount && wsCount._count.issues === 0 && !hasViewAdjustments;
// The query object we pass down. `query` is kept separate from the
// saved-view filter blob (it's not persisted with views by default to
@@ -313,13 +353,11 @@ export default function IssuesPage() {
// Honest header count. Tracks what the current view actually shows
// instead of a raw workspace total (which over-counts soft-deleted +
- // done + snoozed). The board includes done; the list applies the same
- // effective includeDone as IssueList. Shares `issue.count`, so it can't
- // drift from the list's own where-clause.
- const { data: statuses } = trpc.status.list.useQuery();
- const doneIds = useMemo(() => doneStatusIds(statuses ?? []), [statuses]);
- const countIncludeDone =
- showArchived || view === "board" ? true : resolveIncludeDone(issueQueryFilters, false, doneIds);
+ // done + snoozed). List and board share this exact lifecycle baseline,
+ // so changing presentation cannot change which issues are eligible.
+ const countIncludeDone = showArchived
+ ? true
+ : resolveIncludeDone(issueQueryFilters, scope === "all", doneIds);
const { data: countData } = trpc.issue.count.useQuery({
...issueQueryFilters,
includeDone: countIncludeDone,
@@ -335,7 +373,15 @@ export default function IssuesPage() {
title={showArchived ? "Archived issues" : "All issues"}
subtitle={
countData
- ? `${countData.count} ${hasFilters ? "matching" : showArchived ? "archived" : "issues"}`
+ ? `${countData.count} ${
+ hasNarrowing
+ ? "matching"
+ : showArchived
+ ? "archived"
+ : scope === "open"
+ ? "open"
+ : "issues"
+ }`
: !showArchived && wsCount
? `${wsCount._count.issues} issues`
: undefined
@@ -351,14 +397,41 @@ export default function IssuesPage() {
setQuery(e.target.value)}
- placeholder="Search…"
- className="h-8 w-[min(44vw,12rem)] pr-7 text-xs sm:h-7 sm:w-48"
+ placeholder={
+ showArchived
+ ? "Search archived issues…"
+ : scope === "open"
+ ? "Search open issues…"
+ : "Search all issues…"
+ }
+ aria-label={
+ showArchived
+ ? "Search archived issues"
+ : scope === "open"
+ ? "Search open issues"
+ : "Search all issues"
+ }
+ className="h-8 w-[min(55vw,15rem)] pr-8 text-xs sm:h-7 sm:w-60"
/>
{searchPending && (
)}
+ {!searchPending && query && (
+ {
+ setQuery("");
+ setDebouncedQuery("");
+ commit({ query: "" });
+ }}
+ aria-label="Clear search"
+ className="focus-ring absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
+ >
+
+
+ )}
)}
{(showArchived || view === "list") && !isWorkspaceEmpty && }
@@ -397,12 +470,13 @@ export default function IssuesPage() {
fields) + Sprint/Initiative (single-pick). Sort — and Group,
list view only — are pinned right. */}
+ {!showArchived &&
}
{dueOn &&
}
- {hasFilters && (
+ {hasViewAdjustments && (
setShowArchived(false)} />
- ) : hasFilters ? (
+ ) : hasViewAdjustments ? (
setLifecycleScope("all")}
/>
) : undefined
}
@@ -471,13 +549,17 @@ export default function IssuesPage() {
setLifecycleScope("all")}
/>
) : undefined
}
@@ -521,9 +603,15 @@ function ArchivedEmptyState({ onReturn }: { onReturn: () => void }) {
function FilteredEmptyState({
activeViewName,
onClear,
+ query,
+ scope,
+ onSearchAll,
}: {
activeViewName: string | null;
onClear: () => void;
+ query: string;
+ scope: IssueLifecycleScope;
+ onSearchAll: () => void;
}) {
return (
@@ -533,11 +621,25 @@ function FilteredEmptyState({
title="No issues match this view"
description={
- {activeViewName ? (
+ {query && scope === "open" ? (
+ <>
+ No open issues match “{query}” .{" "}
+
+ Search all issues
+ {" "}
+ to include completed work, or{" "}
+ >
+ ) : activeViewName ? (
<>
The view {activeViewName} has
no matches right now.{" "}
>
+ ) : query ? (
+ "No issues match this search. "
) : (
"Your active filters returned nothing. "
)}
@@ -546,7 +648,7 @@ function FilteredEmptyState({
onClick={onClear}
className="text-foreground underline-offset-2 hover:text-ember hover:underline"
>
- Clear filters
+ {query ? "Clear search and filters" : "Clear filters"}
{" "}
to see all issues.
diff --git a/src/app/(app)/w/[slug]/projects/[id]/page.tsx b/src/app/(app)/w/[slug]/projects/[id]/page.tsx
index b7f071a5..4189cd61 100644
--- a/src/app/(app)/w/[slug]/projects/[id]/page.tsx
+++ b/src/app/(app)/w/[slug]/projects/[id]/page.tsx
@@ -18,6 +18,7 @@ import type { EventKind, StatusCategory } from "@prisma/client";
import { Topbar } from "@/components/topbar";
import { IssueList } from "@/components/issue-list";
import { IssueBoard } from "@/components/issue-board";
+import { IssueScopeToggle, type IssueLifecycleScope } from "@/components/issue-scope-toggle";
import { Avatar } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -48,6 +49,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
const tabParam = (searchParams?.get("view") ?? "overview") as Tab;
const tab: Tab = tabParam === "list" || tabParam === "board" ? tabParam : "overview";
+ const issueScope: IssueLifecycleScope = searchParams?.get("scope") === "all" ? "all" : "open";
const setTab = (next: Tab) => {
const params = new URLSearchParams(searchParams?.toString() ?? "");
@@ -57,6 +59,14 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
router.replace(qs ? `${pathname}?${qs}` : pathname);
};
+ const setIssueScope = (scope: IssueLifecycleScope) => {
+ const params = new URLSearchParams(searchParams?.toString() ?? "");
+ if (scope === "all") params.set("scope", "all");
+ else params.delete("scope");
+ const qs = params.toString();
+ router.replace(qs ? `${pathname}?${qs}` : pathname);
+ };
+
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -176,6 +186,9 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
setTab("board")}>
Board
+ {tab !== "overview" && (
+
+ )}
@@ -186,10 +199,20 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
)}
{tab === "list" && (
-
+
)}
- {tab === "board" &&
}
+ {tab === "board" && (
+
+ )}
@@ -950,9 +973,7 @@ function EditProjectDialog({
const [repoBranch, setRepoBranch] = useState(project.repoBranch ?? "");
const [completionAutomation, setCompletionAutomation] = useState<
"INHERIT" | "OFF" | "RECOMMEND" | "AUTO_WHEN_SAFE"
- >(
- project.completionAutomation ?? "INHERIT",
- );
+ >(project.completionAutomation ?? "INHERIT");
const update = trpc.project.update.useMutation({
onSuccess: () => {
@@ -1039,11 +1060,7 @@ function EditProjectDialog({
value={completionAutomation}
onChange={(value) =>
setCompletionAutomation(
- (value ?? "INHERIT") as
- | "INHERIT"
- | "OFF"
- | "RECOMMEND"
- | "AUTO_WHEN_SAFE",
+ (value ?? "INHERIT") as "INHERIT" | "OFF" | "RECOMMEND" | "AUTO_WHEN_SAFE",
)
}
options={[
diff --git a/src/app/(app)/w/[slug]/projects/page.tsx b/src/app/(app)/w/[slug]/projects/page.tsx
index 653cc4b8..5b54e90b 100644
--- a/src/app/(app)/w/[slug]/projects/page.tsx
+++ b/src/app/(app)/w/[slug]/projects/page.tsx
@@ -24,7 +24,7 @@ export default function ProjectsPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const { data, isLoading, refetch } = trpc.project.list.useQuery({ archived: false, limit: 50 });
+ const { data, isLoading } = trpc.project.list.useQuery({ archived: false, limit: 50 });
const [starterOpen, setStarterOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const existingKeys = new Set((data?.items ?? []).map((p) => p.key));
@@ -50,7 +50,7 @@ export default function ProjectsPage() {
}) {
setPendingTemplate(s.id);
try {
- await create.mutateAsync({
+ const project = await create.mutateAsync({
key: s.suggestedKey,
name: s.name,
description: s.description ?? undefined,
@@ -58,7 +58,7 @@ export default function ProjectsPage() {
icon: s.icon ?? undefined,
});
toast.success(`Created ${s.name}.`);
- refetch();
+ router.push(`/w/${slug}/projects/${project.id}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create project.");
} finally {
@@ -120,9 +120,9 @@ export default function ProjectsPage() {
Projects group related issues and roll up to initiatives.
-
- Start blank, or kick off from a template. Press{" "}
- ⇧C from anywhere to quick-create.
+
+ Start blank, or kick off from a template. Press ⇧C from anywhere to
+ quick-create.
}
@@ -132,11 +132,7 @@ export default function ProjectsPage() {
New project
{previewStarters.length === 0 && (starters?.length ?? 0) === 0 && (
- setStarterOpen(true)}
- >
+ setStarterOpen(true)}>
Browse templates
)}
@@ -168,19 +164,17 @@ export default function ProjectsPage() {
style={{ backgroundColor: s.color ?? "#78716c" }}
/>
{s.icon && {s.icon} }
-
- {s.name}
-
-
+ {s.name}
+
{s.suggestedKey}
{s.description && (
-
+
{s.description}
)}
-
+
{pendingTemplate === s.id ? "Adding…" : "Tap to add →"}
@@ -218,7 +212,7 @@ export default function ProjectsPage() {
style={{ backgroundColor: p.color ?? "#78716c" }}
/>
{p.key}
-
+
{p._count.issues} issues
@@ -232,16 +226,31 @@ export default function ProjectsPage() {
)}
{p._count.issues > 0 && (
-
+
+
+ Progress
+
+ {p._count.doneIssues}/{p._count.issues} done
+
+
+ className="h-1 w-full overflow-hidden rounded-full bg-subtle"
+ role="progressbar"
+ aria-label={`${p.name} completion`}
+ aria-valuemin={0}
+ aria-valuemax={p._count.issues}
+ aria-valuenow={p._count.doneIssues}
+ >
+
+
)}
-
+
Updated {relativeTime(p.updatedAt)}
{p.initiative && (
@@ -262,12 +271,12 @@ export default function ProjectsPage() {
open={starterOpen}
onClose={() => setStarterOpen(false)}
existingKeys={existingKeys}
- onCreated={() => refetch()}
+ onCreated={(project) => router.push(`/w/${slug}/projects/${project.id}`)}
/>
setCreateOpen(false)}
- onCreated={() => refetch()}
+ onCreated={(project) => router.push(`/w/${slug}/projects/${project.id}`)}
/>
>
);
@@ -282,7 +291,7 @@ function StarterDialog({
open: boolean;
onClose: () => void;
existingKeys: Set;
- onCreated: () => void;
+ onCreated: (project: { id: string }) => void;
}) {
const { slug } = useWorkspace();
const { data: starters } = trpc.projectTemplate.list.useQuery(undefined, { enabled: open });
@@ -299,7 +308,7 @@ function StarterDialog({
}) {
setPending(s.id);
try {
- await create.mutateAsync({
+ const project = await create.mutateAsync({
key: s.suggestedKey,
name: s.name,
description: s.description ?? undefined,
@@ -307,7 +316,7 @@ function StarterDialog({
icon: s.icon ?? undefined,
});
toast.success(`Created ${s.name}.`);
- onCreated();
+ onCreated(project);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to create project.");
} finally {
@@ -329,7 +338,7 @@ function StarterDialog({
- Managed in Settings. Each row below invokes the standard project.create API.
+ Choose a prepared starting point. You can customize it after creation.
@@ -348,12 +357,10 @@ function StarterDialog({
{s.icon && {s.icon} }
{s.name}
-
- {s.suggestedKey}
-
+ {s.suggestedKey}
{s.description && (
- {s.description}
+ {s.description}
)}
+
No templates defined. Create some in Settings.
)}
@@ -382,4 +389,3 @@ function StarterDialog({
);
}
-
diff --git a/src/components/issue-board.tsx b/src/components/issue-board.tsx
index 3b4f5c08..e4a59612 100644
--- a/src/components/issue-board.tsx
+++ b/src/components/issue-board.tsx
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import { Plus } from "lucide-react";
+import { StatusCategory } from "@prisma/client";
import { trpc } from "@/lib/trpc";
import { Badge } from "@/components/ui/badge";
import { Avatar } from "@/components/ui/avatar";
@@ -30,6 +31,7 @@ export function IssueBoard({
cycleId,
initiativeId,
extraFilters,
+ includeDone = true,
sort,
dueOn,
emptyOverride,
@@ -43,6 +45,8 @@ export function IssueBoard({
initiativeId?: string | null;
/** Phase 1D saved-view projection. Spread into each column's `issue.list`. */
extraFilters?: SavedViewFilters;
+ /** Whether terminal workflow columns and rows are eligible to appear. */
+ includeDone?: boolean;
/** Within-column ordering. Forwarded to each column's query. */
sort?: IssueSort;
/** Single-day due-date filter (UTC `YYYY-MM-DD`), same as the list view. */
@@ -58,17 +62,16 @@ export function IssueBoard({
const base = ws ? `/w/${ws.slug}` : "";
// Total matching count across all columns — drives the filtered-empty
- // state. Uses the same filter the columns do (includeDone defaults true
- // for a board so the Done/Cancelled columns populate; a saved view can
- // still override). Shares `issue.count`, so it can't drift from the list.
+ // state. The parent supplies the lifecycle baseline so switching between
+ // list and board never changes the result universe.
const countFilter = {
- includeDone: true,
projectId,
assigneeId,
cycleId,
initiativeId,
dueOn,
...(extraFilters ?? {}),
+ includeDone,
};
const { data: countData } = trpc.issue.count.useQuery(countFilter);
@@ -79,6 +82,12 @@ export function IssueBoard({
const statusIdFilter = extraFilters?.statusIds;
const statusCatFilter = extraFilters?.statusCategories;
const visibleStatuses = statuses.filter((s) => {
+ if (
+ !includeDone &&
+ (s.category === StatusCategory.DONE || s.category === StatusCategory.CANCELED)
+ ) {
+ return false;
+ }
if (statusIdFilter?.length && !statusIdFilter.includes(s.id)) return false;
if (statusCatFilter?.length && !statusCatFilter.includes(s.category)) return false;
return true;
@@ -104,8 +113,8 @@ export function IssueBoard({
workspaceKey={workspaceKey}
projectId={projectId}
columnFilter={{
- includeDone: true,
...restFilters,
+ includeDone,
statusIds: [s.id],
projectId,
assigneeId,
diff --git a/src/components/issue-scope-toggle.tsx b/src/components/issue-scope-toggle.tsx
new file mode 100644
index 00000000..8998e400
--- /dev/null
+++ b/src/components/issue-scope-toggle.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+export type IssueLifecycleScope = "open" | "all";
+
+/**
+ * Explicit lifecycle baseline shared by issue lists and boards.
+ *
+ * Status facets still narrow to a specific workflow state; this control
+ * answers the broader question that was previously implicit: whether
+ * terminal work is eligible to appear at all.
+ */
+export function IssueScopeToggle({
+ value,
+ onChange,
+ className,
+}: {
+ value: IssueLifecycleScope;
+ onChange: (value: IssueLifecycleScope) => void;
+ className?: string;
+}) {
+ return (
+
+ {(["open", "all"] as const).map((scope) => (
+ onChange(scope)}
+ aria-pressed={value === scope}
+ className={cn(
+ "focus-ring rounded px-2 py-0.5 capitalize transition-colors",
+ value === scope
+ ? "bg-card text-foreground shadow-sm"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {scope}
+
+ ))}
+
+ );
+}
diff --git a/src/components/projects/new-project-dialog.tsx b/src/components/projects/new-project-dialog.tsx
index f87b8110..12cb5290 100644
--- a/src/components/projects/new-project-dialog.tsx
+++ b/src/components/projects/new-project-dialog.tsx
@@ -15,6 +15,16 @@ const DEFAULT_COLORS = [
"#78716c",
];
+const COLOR_NAMES: Record = {
+ "#d97706": "Ember",
+ "#ca8a04": "Ochre",
+ "#65a30d": "Moss",
+ "#0ea5e9": "Sky",
+ "#7c3aed": "Violet",
+ "#be185d": "Rose",
+ "#78716c": "Stone",
+};
+
/**
* Shared "New project" dialog. Used by the projects page and by the
* context-aware quick-create (`⇧C` on `/projects`).
@@ -29,7 +39,7 @@ export function NewProjectDialog({
}: {
open: boolean;
onClose: () => void;
- onCreated?: () => void;
+ onCreated?: (project: { id: string; name: string; key: string }) => void;
}) {
const [name, setName] = useState("");
const [key, setKey] = useState("");
@@ -39,7 +49,7 @@ export function NewProjectDialog({
const utils = trpc.useUtils();
const create = trpc.project.create.useMutation({
- onSuccess: () => {
+ onSuccess: (project) => {
toast.success("Project created.");
setName("");
setKey("");
@@ -47,7 +57,7 @@ export function NewProjectDialog({
setColor(DEFAULT_COLORS[0]);
setIcon("");
utils.project.list.invalidate();
- onCreated?.();
+ onCreated?.(project);
},
});
@@ -73,6 +83,7 @@ export function NewProjectDialog({
if (!o) onClose();
}}
title="New project"
+ description="Start with the identity people will use to recognize and reference this project."
primaryLabel={create.isPending ? "Creating…" : "Create project"}
loading={create.isPending}
draftKey="project.create"
@@ -94,23 +105,30 @@ export function NewProjectDialog({
}}
>
-
+
onNameChange(e.target.value)}
autoFocus
+ aria-required="true"
/>
-
+
- setKey(e.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase())
- }
+ onChange={(e) => setKey(e.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase())}
placeholder="FRG"
maxLength={8}
+ aria-required="true"
/>
@@ -131,6 +149,7 @@ export function NewProjectDialog({
value={color}
onChange={(e) => setColor(e.target.value)}
className="h-8 w-10 cursor-pointer rounded border border-input bg-background"
+ aria-label="Custom project color"
/>
{DEFAULT_COLORS.map((c) => (
@@ -138,21 +157,19 @@ export function NewProjectDialog({
key={c}
type="button"
onClick={() => setColor(c)}
- className="h-5 w-5 rounded border border-border"
+ className={`h-5 w-5 rounded border border-border ${
+ color === c ? "ring-2 ring-ember/50 ring-offset-1 ring-offset-background" : ""
+ }`}
style={{ backgroundColor: c }}
- aria-label={c}
+ aria-label={`Choose ${COLOR_NAMES[c] ?? c}`}
+ aria-pressed={color === c}
/>
))}
- setIcon(e.target.value)}
- maxLength={8}
- />
+ setIcon(e.target.value)} maxLength={8} />
diff --git a/src/components/saved-views/facet-chips.tsx b/src/components/saved-views/facet-chips.tsx
index 6f50cb5e..cd903561 100644
--- a/src/components/saved-views/facet-chips.tsx
+++ b/src/components/saved-views/facet-chips.tsx
@@ -39,10 +39,7 @@ function priorityLabel(p: Priority): string {
}
/** Toggle a string id within an optional array; empty collapses to undefined. */
-function toggleId(
- arr: readonly string[] | undefined,
- id: string,
-): string[] | undefined {
+function toggleId(arr: readonly string[] | undefined, id: string): string[] | undefined {
const set = new Set(arr ?? []);
if (set.has(id)) set.delete(id);
else set.add(id);
@@ -134,9 +131,7 @@ export function IssueFacetChips({
label={l.name}
color={l.color}
checked={filters.labelIds?.includes(l.id) ?? false}
- onToggle={() =>
- onChange({ ...filters, labelIds: toggleId(filters.labelIds, l.id) })
- }
+ onToggle={() => onChange({ ...filters, labelIds: toggleId(filters.labelIds, l.id) })}
/>
))}
@@ -159,7 +154,7 @@ export function SortChip({
priority: "Priority",
newest: "Newest",
oldest: "Oldest",
- updated: "Recently updated",
+ updated: "Last updated",
title: "Title A–Z",
};
return (
@@ -236,9 +231,7 @@ function usePopoverKeys(
e.preventDefault();
const idx = items.indexOf(document.activeElement as HTMLElement);
const next =
- e.key === "ArrowDown"
- ? (idx + 1) % items.length
- : (idx - 1 + items.length) % items.length;
+ e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length;
items[next]?.focus();
}
};
@@ -285,9 +278,7 @@ function FacetChip({
>
{label}
{active && (
-
- {count}
-
+ {count}
)}
@@ -407,9 +398,7 @@ function SelectChip({
)}
>
{o.label}
- {value === o.value && (
-
- )}
+ {value === o.value && }
))}
diff --git a/src/components/saved-views/quick-filter-chips.tsx b/src/components/saved-views/quick-filter-chips.tsx
index 32a7cf16..73f7b52f 100644
--- a/src/components/saved-views/quick-filter-chips.tsx
+++ b/src/components/saved-views/quick-filter-chips.tsx
@@ -1,5 +1,5 @@
"use client";
-import { CheckCircle2, CircleSlash, Inbox, Layers, ShieldAlert, Sparkles } from "lucide-react";
+import { CircleSlash, Inbox, Layers, ShieldAlert, Sparkles } from "lucide-react";
import { StatusCategory } from "@prisma/client";
import { cn } from "@/lib/utils";
import { trpc } from "@/lib/trpc";
@@ -39,11 +39,7 @@ export function QuickFilterChips({
// dedicated "backlog category" column on Workspace yet because the
// category enum already serves that role.
const backlogStatusIds = (statuses ?? [])
- .filter(
- (s) =>
- s.category === StatusCategory.BACKLOG ||
- s.category === StatusCategory.TODO,
- )
+ .filter((s) => s.category === StatusCategory.BACKLOG || s.category === StatusCategory.TODO)
.map((s) => s.id);
// -- Predicate helpers — derive `active` and `toggle` per chip from
@@ -66,24 +62,16 @@ export function QuickFilterChips({
const toggleMyBacklog = () => {
if (!meId) return;
if (myBacklogActive) {
- const nextAssignees = (filters.assigneeIds ?? []).filter(
- (id) => id !== meId,
- );
- const nextStatuses = (filters.statusIds ?? []).filter(
- (id) => !backlogStatusIds.includes(id),
- );
+ const nextAssignees = (filters.assigneeIds ?? []).filter((id) => id !== meId);
+ const nextStatuses = (filters.statusIds ?? []).filter((id) => !backlogStatusIds.includes(id));
onChange({
...filters,
assigneeIds: nextAssignees.length ? nextAssignees : undefined,
statusIds: nextStatuses.length ? nextStatuses : undefined,
});
} else {
- const nextAssignees = Array.from(
- new Set([...(filters.assigneeIds ?? []), meId]),
- );
- const nextStatuses = Array.from(
- new Set([...(filters.statusIds ?? []), ...backlogStatusIds]),
- );
+ const nextAssignees = Array.from(new Set([...(filters.assigneeIds ?? []), meId]));
+ const nextStatuses = Array.from(new Set([...(filters.statusIds ?? []), ...backlogStatusIds]));
onChange({
...filters,
assigneeIds: nextAssignees,
@@ -111,20 +99,6 @@ export function QuickFilterChips({
});
};
- // Done / completed work. Use category instead of ids so custom terminal
- // statuses stay covered and the server/client both infer includeDone.
- const doneActive = filters.statusCategories?.includes(StatusCategory.DONE) ?? false;
- const toggleDone = () => {
- const nextCategories = doneActive
- ? (filters.statusCategories ?? []).filter((c) => c !== StatusCategory.DONE)
- : Array.from(new Set([...(filters.statusCategories ?? []), StatusCategory.DONE]));
- onChange({
- ...filters,
- statusCategories: nextCategories.length ? nextCategories : undefined,
- includeDone: doneActive ? undefined : true,
- });
- };
-
// Epics — pin kinds to [EPIC] to see just the top-level parents.
const epicsActive = filters.kinds?.includes("EPIC") ?? false;
const toggleEpics = () => {
@@ -170,15 +144,7 @@ export function QuickFilterChips({
active={updatedActive}
onClick={toggleUpdated}
icon={ }
- label="Recently updated"
- />
-
-
- }
- label="Done"
+ label={`Updated in ${filters.updatedSince ?? "3d"}`}
/>
@@ -216,7 +182,7 @@ function Chip({
title={title}
aria-pressed={active}
className={cn(
- "focus-ring inline-flex h-6 items-center gap-1.5 rounded-md border px-2 text-meta transition-colors",
+ "focus-ring text-meta inline-flex h-6 items-center gap-1.5 rounded-md border px-2 transition-colors",
active
? "border-ember/60 bg-ember/15 text-foreground"
: "border-border/80 bg-background/40 text-muted-foreground hover:border-ember/30 hover:bg-subtle/60 hover:text-foreground",
diff --git a/src/components/saved-views/saved-views-bar.tsx b/src/components/saved-views/saved-views-bar.tsx
index 78bed226..c87fe976 100644
--- a/src/components/saved-views/saved-views-bar.tsx
+++ b/src/components/saved-views/saved-views-bar.tsx
@@ -146,12 +146,14 @@ export function SavedViewsBar({
// sortKey, since the saved-view bar doesn't track sort yet.
const matchedView = useMemo(() => {
if (!filtersDirty || !views) return null;
- return (
- views.find((v) => filtersEqual(safeParseFilters(v.filters), currentFilters)) ??
- null
- );
+ return views.find((v) => filtersEqual(safeParseFilters(v.filters), currentFilters)) ?? null;
}, [views, currentFilters, filtersDirty]);
+ // Avoid dedicating a permanent toolbar row to an empty-state sentence.
+ // The row appears as soon as there is a saved view or something useful
+ // to save.
+ if (!hasViews && !filtersDirty) return null;
+
return (
<>
@@ -181,16 +183,12 @@ export function SavedViewsBar({
e.preventDefault();
setOverId(v.id);
}}
- onDragLeave={() =>
- setOverId((o) => (o === v.id ? null : o))
- }
+ onDragLeave={() => setOverId((o) => (o === v.id ? null : o))}
onDrop={(e) => {
e.preventDefault();
onDrop(v.id);
}}
- onClick={() =>
- onApply(v.id, safeParseFilters(v.filters))
- }
+ onClick={() => onApply(v.id, safeParseFilters(v.filters))}
className={cn(
"focus-ring inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 transition-colors",
"text-meta",
@@ -202,15 +200,10 @@ export function SavedViewsBar({
title="Click to apply · drag to reorder"
>
-
- {v.name}
-
+
{v.name}
{/* Phase 1A: per-workspace pin toggle. Lives between
the chip body and the ⋯ menu so it's discoverable
@@ -328,7 +321,7 @@ export function SavedViewsBar({
filters: currentFilters,
})
}
- className="focus-ring inline-flex h-6 items-center gap-1 rounded-full border border-ember/40 px-2.5 text-meta text-foreground hover:bg-ember/10"
+ className="focus-ring text-meta inline-flex h-6 items-center gap-1 rounded-full border border-ember/40 px-2.5 text-foreground hover:bg-ember/10"
title={`Overwrite "${matchedView.name}" with the current filters.`}
disabled={update.isPending}
>
@@ -338,7 +331,7 @@ export function SavedViewsBar({
@@ -351,16 +344,12 @@ export function SavedViewsBar({
onClick={onCreate}
disabled={!filtersDirty}
className={cn(
- "focus-ring inline-flex h-6 items-center gap-1 rounded-full border border-dashed px-2.5 text-meta",
+ "focus-ring text-meta inline-flex h-6 items-center gap-1 rounded-full border border-dashed px-2.5",
filtersDirty
? "border-ember/40 text-foreground hover:bg-ember/10"
: "cursor-not-allowed border-border/60 text-muted-foreground/60",
)}
- title={
- filtersDirty
- ? "Save the current filters as a view"
- : "Apply some filters first"
- }
+ title={filtersDirty ? "Save the current filters as a view" : "Apply some filters first"}
>
Save view
@@ -384,9 +373,7 @@ export function SavedViewsBar({
if (!open) setDeleteTarget(null);
}}
variant="destructive"
- title={
- deleteTarget ? `Delete view "${deleteTarget.name}"?` : "Delete view?"
- }
+ title={deleteTarget ? `Delete view "${deleteTarget.name}"?` : "Delete view?"}
description="The filters are detached, but no issues are touched."
primaryLabel="Delete"
loading={remove.isPending}
@@ -440,12 +427,7 @@ function RenameDialog({
}}
>
- setName(e.target.value)}
- maxLength={80}
- />
+ setName(e.target.value)} maxLength={80} />
);
diff --git a/src/components/ui/modal/quick-form.tsx b/src/components/ui/modal/quick-form.tsx
index 08bc49ad..09850eac 100644
--- a/src/components/ui/modal/quick-form.tsx
+++ b/src/components/ui/modal/quick-form.tsx
@@ -33,9 +33,7 @@ type QuickFormProps = {
description?: React.ReactNode;
primaryLabel: string;
secondaryLabel?: string;
- onSubmit: (
- e: React.FormEvent,
- ) => SubmitResult | Promise;
+ onSubmit: (e: React.FormEvent) => SubmitResult | Promise;
onSecondary?: () => void | Promise;
/** Optional external loading flag — merges with internal pending. */
loading?: boolean;
@@ -232,20 +230,18 @@ export function QuickForm({
ref={formRef}
onSubmit={doSubmit}
onKeyDown={handleKeyDown}
+ onChange={() => {
+ if (error) setError(null);
+ }}
className="flex max-h-[calc(100dvh-5rem)] flex-col gap-3 p-5"
noValidate
>
-
{description}
- )}
+ {description &&
{description}
}
{restored && (
@@ -255,7 +251,11 @@ export function QuickForm({
{error && (
-
+
{error}
)}
@@ -320,18 +320,13 @@ function Field({
return (
{label && (
-
+
{label}
{required && * }
)}
{children}
- {hint && !error && (
- {hint}
- )}
+ {hint && !error && {hint}
}
{error && (
{error}
diff --git a/tests/e2e/issues-scope.spec.ts b/tests/e2e/issues-scope.spec.ts
new file mode 100644
index 00000000..e74d3716
--- /dev/null
+++ b/tests/e2e/issues-scope.spec.ts
@@ -0,0 +1,59 @@
+import { expect, test } from "@playwright/test";
+
+test("lifecycle scope stays consistent when switching between list and kanban", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ localStorage.setItem("forge:view:issues", "list");
+ });
+ await page.goto("/w/forge/issues");
+
+ const openScope = page.getByRole("button", { name: "open", exact: true });
+ const allScope = page.getByRole("button", { name: "all", exact: true });
+ const visibleCompletedIssue = page
+ .locator("a:visible")
+ .filter({ hasText: "Webhook delivery retry backoff" });
+ await expect(openScope).toHaveAttribute("aria-pressed", "true");
+
+ await page
+ .getByRole("textbox", { name: "Search open issues" })
+ .fill("Webhook delivery retry backoff");
+ await expect(page.getByText("No issues match this view")).toBeVisible();
+ await expect(page.getByText("Webhook delivery retry backoff", { exact: true })).toHaveCount(0);
+
+ await page.getByRole("button", { name: "Kanban", exact: true }).click();
+ await expect(page.getByText("No issues match this view")).toBeVisible();
+ await expect(page.getByText("Webhook delivery retry backoff", { exact: true })).toHaveCount(0);
+
+ await allScope.click();
+ await expect(page.getByRole("textbox", { name: "Search all issues" })).toBeVisible();
+ await expect(visibleCompletedIssue).toBeVisible();
+
+ await page.getByRole("button", { name: "List", exact: true }).click();
+ await expect(visibleCompletedIssue).toBeVisible();
+});
+
+test("project creation explains the key and recovers from validation", async ({ page }) => {
+ await page.goto("/w/forge/projects");
+ await page.getByRole("button", { name: "New project", exact: true }).click();
+
+ await expect(
+ page.getByText("Used in issue IDs, for example FRG-123. It cannot be changed later."),
+ ).toBeVisible();
+
+ const createProject = page.getByRole("button", { name: /^Create project/ });
+ await createProject.click();
+ const validationError = page.getByText("Name and key are required.", { exact: true });
+ await expect(validationError).toBeVisible();
+
+ const uniqueKey = `PW${Date.now().toString(36).slice(-5)}`.toUpperCase();
+ await page.getByLabel("Name").fill("Playwright lifecycle project");
+ await expect(validationError).toHaveCount(0);
+ await page.getByLabel("Key").fill(uniqueKey);
+ await createProject.click();
+
+ await expect(page).toHaveURL(/\/w\/forge\/projects\/[^/?]+$/);
+ await expect(
+ page.getByRole("heading", { name: "Playwright lifecycle project", exact: true }),
+ ).toBeVisible();
+});
diff --git a/tests/unit/saved-view-filters.test.ts b/tests/unit/saved-view-filters.test.ts
index 761e5b83..6e03b05a 100644
--- a/tests/unit/saved-view-filters.test.ts
+++ b/tests/unit/saved-view-filters.test.ts
@@ -3,6 +3,7 @@ import {
filtersEqual,
hasTerminalStatusCategory,
isEmptyFilters,
+ resolveIncludeDone,
safeParseFilters,
withIncludeDoneForTerminalFilters,
type SavedViewFilters,
@@ -39,9 +40,7 @@ describe("filtersEqual", () => {
});
it("returns false when array lengths differ", () => {
- expect(filtersEqual({ statusIds: ["x"] }, { statusIds: ["x", "y"] })).toBe(
- false,
- );
+ expect(filtersEqual({ statusIds: ["x"] }, { statusIds: ["x", "y"] })).toBe(false);
});
it("treats missing array and empty array as equal", () => {
@@ -51,9 +50,7 @@ describe("filtersEqual", () => {
it("compares scalar values strictly", () => {
expect(filtersEqual({ query: "foo" }, { query: "foo" })).toBe(true);
expect(filtersEqual({ query: "foo" }, { query: "bar" })).toBe(false);
- expect(filtersEqual({ updatedSince: "7d" }, { updatedSince: "1d" })).toBe(
- false,
- );
+ expect(filtersEqual({ updatedSince: "7d" }, { updatedSince: "1d" })).toBe(false);
});
});
@@ -83,4 +80,14 @@ describe("terminal status filter helpers", () => {
});
expect(filters.includeDone).toBe(true);
});
+
+ it("keeps the surface default authoritative until a terminal filter is explicit", () => {
+ const doneIds = new Set(["done-id", "canceled-id"]);
+ expect(resolveIncludeDone({}, false, doneIds)).toBe(false);
+ expect(resolveIncludeDone({}, true, doneIds)).toBe(true);
+ expect(resolveIncludeDone({ statusIds: ["done-id"] }, false, doneIds)).toBe(true);
+ expect(
+ resolveIncludeDone({ statusCategories: [StatusCategory.CANCELED] }, false, doneIds),
+ ).toBe(true);
+ });
});