mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
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:
1 parent
744f9f7428
commit
ee196badee
22 files changed
+266
-16
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,6 +116,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -76,6 +76,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user