PAP-70: project icon infra + ProjectTile renderer + picker (PAP-68 part 3)

Adds the project icon system mirroring the agent icon pattern:

- shared: PROJECT_ICON_NAMES + ProjectIconName (default "folder", curated
  Lucide set), exported from index; tighten project validator icon field
  from loose string to z.enum(PROJECT_ICON_NAMES).
- ui: project-icons.ts (PROJECT_ICONS map + getProjectIcon defaulting to
  folder) and ProjectIconPicker (Popover + search + grid), mirroring the
  agent equivalents.
- ProjectTile gains an optional `icon` prop, rendering getProjectIcon(icon)
  instead of a hard-coded Folder (default unchanged). Color behavior intact.
- Pass icon={project.icon ?? null} at all three tile sites: Projects list,
  SidebarProjects, and the detail header (threaded through ColorPicker).
- Tests: ProjectTile renders chosen icon / falls back to folder for unknown.
- Backfill required Project.icon on test/storybook fixtures left un-updated
  by part 1, restoring a green UI typecheck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Opus 4.8 committed 2026-06-05 15:18:54 -07:00
1 parent 744f9f7428
commit ee196badee
22 files changed
+266 -16

No files matched your search

+50
View File
@@ -124,6 +124,56 @@ export const AGENT_ICON_NAMES = [
] as const;
export type AgentIconName = (typeof AGENT_ICON_NAMES)[number];
/**
* Curated Lucide icon set for projects (PAP-68 part 3).
*
* The first entry, `"folder"`, is the default for any project without an
* explicit icon. The remaining entries reuse much of the agent icon set plus a
* handful of folder/structure icons that read well at small tile sizes.
*/
export const PROJECT_ICON_NAMES = [
"folder",
"rocket",
"code",
"terminal",
"database",
"globe",
"package",
"boxes",
"box",
"layers",
"briefcase",
"compass",
"target",
"flame",
"zap",
"star",
"bug",
"wrench",
"hammer",
"lightbulb",
"sparkles",
"shield",
"lock",
"search",
"cog",
"brain",
"cpu",
"git-branch",
"file-code",
"puzzle",
"gem",
"atom",
"heart",
"mail",
"message-square",
"crown",
"radar",
"telescope",
"hexagon",
] as const;
export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number];
export const ISSUE_STATUSES = [
"backlog",
"todo",
+2
View File
@@ -41,6 +41,7 @@ export {
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
MODEL_PROFILE_KEYS,
AGENT_ICON_NAMES,
PROJECT_ICON_NAMES,
ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
@@ -169,6 +170,7 @@ export {
type AgentRole,
type ModelProfileKey,
type AgentIconName,
type ProjectIconName,
type IssueStatus,
type IssuePriority,
type IssueWorkMode,
+2 -4
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { PROJECT_STATUSES } from "../constants.js";
import { PROJECT_STATUSES, PROJECT_ICON_NAMES } from "../constants.js";
import { envConfigSchema } from "./secret.js";
import { trustAuthorizationPolicySchema } from "./trust-policy.js";
@@ -107,9 +107,7 @@ const projectFields = {
leadAgentId: z.string().uuid().optional().nullable(),
targetDate: z.string().optional().nullable(),
color: z.string().optional().nullable(),
// TODO(PAP-68 part 3): tighten to `z.enum(PROJECT_ICON_NAMES)` once the icon
// name enum lands. Loosely typed as a string until then.
icon: z.string().optional().nullable(),
icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(),
env: envConfigSchema.optional().nullable(),
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
archivedAt: z.string().datetime().optional().nullable(),
@@ -294,6 +294,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#6366f1",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+77
View File
@@ -0,0 +1,77 @@
import { useState, useMemo } from "react";
import { PROJECT_ICON_NAMES, type ProjectIconName } from "@paperclipai/shared";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { PROJECT_ICONS, getProjectIcon } from "../lib/project-icons";
const DEFAULT_ICON: ProjectIconName = "folder";
interface ProjectIconProps {
icon: string | null | undefined;
className?: string;
}
export function ProjectIcon({ icon, className }: ProjectIconProps) {
const Icon = getProjectIcon(icon);
return <Icon className={className} />;
}
interface ProjectIconPickerProps {
value: string | null | undefined;
onChange: (icon: string) => void;
children: React.ReactNode;
}
export function ProjectIconPicker({ value, onChange, children }: ProjectIconPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const filtered = useMemo(() => {
const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const);
if (!search) return entries;
const q = search.toLowerCase();
return entries.filter(([name]) => name.includes(q));
}, [search]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-72 p-3" align="start">
<Input
placeholder="Search icons..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="mb-2 h-8 text-sm"
autoFocus
/>
<div className="grid grid-cols-7 gap-1 max-h-48 overflow-y-auto">
{filtered.map(([name, Icon]) => (
<button
key={name}
onClick={() => {
onChange(name);
setOpen(false);
setSearch("");
}}
className={cn(
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
(value ?? DEFAULT_ICON) === name && "bg-accent ring-1 ring-primary"
)}
title={name}
>
<Icon className="h-4 w-4" />
</button>
))}
{filtered.length === 0 && (
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
)}
</div>
</PopoverContent>
</Popover>
);
}
+12 -1
View File
@@ -9,7 +9,18 @@ describe("ProjectTile", () => {
expect(markup).toContain("bg-muted");
expect(markup).toContain("text-muted-foreground");
expect(markup).not.toContain("background-color");
// Folder icon is always present (fixed for this phase).
// Folder icon is the default when no icon prop is supplied.
expect(markup).toContain("lucide-folder");
});
it("renders the chosen Lucide icon when an icon prop is provided", () => {
const markup = renderToStaticMarkup(<ProjectTile icon="rocket" />);
expect(markup).toContain("lucide-rocket");
expect(markup).not.toContain("lucide-folder");
});
it("falls back to the folder icon for an unknown icon name", () => {
const markup = renderToStaticMarkup(<ProjectTile icon="not-a-real-icon" />);
expect(markup).toContain("lucide-folder");
});
+11 -8
View File
@@ -1,15 +1,15 @@
import { Folder } from "lucide-react";
import { cn } from "../lib/utils";
import { getProjectIcon } from "../lib/project-icons";
/**
* Reusable project tile (IA Phase 3 — PAP-58).
* Reusable project tile (IA Phase 3 — PAP-58; icon picker added PAP-68 part 3).
*
* Default render is a neutral gray rounded rectangle with a folder icon.
* An optional `color` tints the background; the folder icon is fixed for all
* tiles this phase (custom-icon picker is deferred to a later phase).
* An optional `color` tints the background; an optional `icon` selects which
* Lucide glyph to render (defaults to `folder`).
*
* Used by the Projects list rows and the project detail header. The color
* lives on the project itself (`project.color`) — no new `project.icon` field.
* Used by the Projects list rows and the project detail header. Both `color`
* and `icon` live on the project itself (`project.color` / `project.icon`).
*/
export type ProjectTileSize = "xs" | "sm" | "md" | "lg";
@@ -24,13 +24,16 @@ const SIZE_STYLES: Record<ProjectTileSize, { box: string; icon: string }> = {
export interface ProjectTileProps {
/** Optional project color. When unset, the tile stays neutral gray. */
color?: string | null;
/** Optional Lucide icon name. When unset, defaults to `folder`. */
icon?: string | null;
size?: ProjectTileSize;
className?: string;
}
export function ProjectTile({ color, size = "md", className }: ProjectTileProps) {
export function ProjectTile({ color, icon, size = "md", className }: ProjectTileProps) {
const dims = SIZE_STYLES[size];
const tinted = Boolean(color);
const Icon = getProjectIcon(icon);
return (
<span
@@ -43,7 +46,7 @@ export function ProjectTile({ color, size = "md", className }: ProjectTileProps)
)}
style={tinted ? { backgroundColor: color ?? undefined } : undefined}
>
<Folder className={dims.icon} />
<Icon className={dims.icon} />
</span>
);
}
@@ -70,6 +70,7 @@ function createProject(): Project {
leadAgentId: null,
targetDate: null,
color: "#22c55e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
@@ -145,6 +145,7 @@ function makeProject(overrides: Partial<Project>): Project {
leadAgentId: null,
targetDate: null,
color: "#ef4444",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1 -1
View File
@@ -140,7 +140,7 @@ function ProjectItem({
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
)}
>
<ProjectTile color={project.color ?? null} size="xs" />
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
<span className="flex-1 truncate">{project.name}</span>
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
</NavLink>
@@ -46,6 +46,7 @@ function makeProject(id: string, name: string): Project {
leadAgentId: null,
targetDate: null,
color: null,
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1
View File
@@ -82,6 +82,7 @@ describe("issueDetailBreadcrumb", () => {
leadAgentId: null,
targetDate: null,
color: null,
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
@@ -594,6 +594,7 @@ describe("optimistic issue comments", () => {
leadAgentId: null,
targetDate: null,
color: null,
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+94
View File
@@ -0,0 +1,94 @@
import {
Atom,
Box,
Boxes,
Brain,
Briefcase,
Bug,
Code,
Cog,
Compass,
Cpu,
Crown,
Database,
FileCode,
Flame,
Folder,
Gem,
GitBranch,
Globe,
Hammer,
Heart,
Hexagon,
Layers,
Lightbulb,
Lock,
Mail,
MessageSquare,
Package,
Puzzle,
Radar,
Rocket,
Search,
Shield,
Sparkles,
Star,
Target,
Telescope,
Terminal,
Wrench,
Zap,
type LucideIcon,
} from "lucide-react";
import { PROJECT_ICON_NAMES, type ProjectIconName } from "@paperclipai/shared";
export const PROJECT_ICONS: Record<ProjectIconName, LucideIcon> = {
folder: Folder,
rocket: Rocket,
code: Code,
terminal: Terminal,
database: Database,
globe: Globe,
package: Package,
boxes: Boxes,
box: Box,
layers: Layers,
briefcase: Briefcase,
compass: Compass,
target: Target,
flame: Flame,
zap: Zap,
star: Star,
bug: Bug,
wrench: Wrench,
hammer: Hammer,
lightbulb: Lightbulb,
sparkles: Sparkles,
shield: Shield,
lock: Lock,
search: Search,
cog: Cog,
brain: Brain,
cpu: Cpu,
"git-branch": GitBranch,
"file-code": FileCode,
puzzle: Puzzle,
gem: Gem,
atom: Atom,
heart: Heart,
mail: Mail,
"message-square": MessageSquare,
crown: Crown,
radar: Radar,
telescope: Telescope,
hexagon: Hexagon,
};
const DEFAULT_ICON: ProjectIconName = "folder";
export function getProjectIcon(iconName: string | null | undefined): LucideIcon {
if (iconName && PROJECT_ICON_NAMES.includes(iconName as ProjectIconName)) {
return PROJECT_ICONS[iconName as ProjectIconName];
}
return PROJECT_ICONS[DEFAULT_ICON];
}
@@ -155,6 +155,7 @@ function project(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1
View File
@@ -116,6 +116,7 @@ function project(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+4 -1
View File
@@ -108,9 +108,11 @@ function OverviewContent({
function ColorPicker({
color,
icon,
onSelect,
}: {
color: string | null;
icon?: string | null;
onSelect: (color: string | null) => void;
}) {
const [open, setOpen] = useState(false);
@@ -135,7 +137,7 @@ function ColorPicker({
className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
aria-label="Change project color"
>
<ProjectTile color={color} size="md" />
<ProjectTile color={color} icon={icon ?? null} size="md" />
</button>
{open && (
<div className="absolute top-full left-0 mt-2 p-3 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
@@ -722,6 +724,7 @@ export function ProjectDetail() {
<div className="h-7 flex items-center">
<ColorPicker
color={project.color ?? null}
icon={project.icon ?? null}
onSelect={(color) => updateProject.mutate({ color })}
/>
</div>
@@ -137,6 +137,7 @@ function project(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1
View File
@@ -76,6 +76,7 @@ function makeProject(overrides: Partial<Project>): Project {
leadAgentId: null,
targetDate: null,
color: "#ef4444",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+1 -1
View File
@@ -207,7 +207,7 @@ export function Projects() {
return (
<EntityRow
key={project.id}
leading={<ProjectTile color={project.color ?? null} size="sm" />}
leading={<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="sm" />}
title={project.name}
subtitle={project.description ?? undefined}
reserveSubtitleSpace
+1
View File
@@ -590,6 +590,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
leadAgentId: "agent-codex",
targetDate: "2026-04-30",
color: "#0f766e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
@@ -296,6 +296,7 @@ const storybookProject: Project = {
leadAgentId: "agent-codex",
targetDate: null,
color: "#0f766e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,