feat: implement universal icon system with Phosphor Icons

- Install @phosphor-icons/react package
- Create centralized Icon component with size/weight/color variants
- Create icon registry with 34 icons across 5 categories
- Replace all raw Unicode symbols with proper icon components
- Add icons to navigation, buttons, status indicators, git operations
- Add icon CSS with consistent sizing and spacing
- Fix type definitions for Phosphor icon compatibility

Quality gates: typecheck ✓, lint ✓, build ✓ (375KB bundle)
This commit is contained in:
Fusion
2026-05-19 19:33:06 +02:00
parent cccc4a9d5a
commit 6f41fa7cbe
37 changed files with 1037 additions and 41 deletions
+14
View File
@@ -8,6 +8,7 @@
"name": "headquarter-web",
"version": "0.1.0",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@types/prismjs": "^1.26.6",
"axios": "^1.6.0",
"prismjs": "^1.30.0",
@@ -1271,6 +1272,19 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">= 16.8",
"react-dom": ">= 16.8"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run"
},
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"@types/prismjs": "^1.26.6",
"axios": "^1.6.0",
"prismjs": "^1.30.0",
+10 -6
View File
@@ -2,13 +2,15 @@ import { Link, NavLink, Outlet } from "react-router-dom";
import { useTheme } from "../hooks/use-theme";
import { useAuth } from "../state/auth";
import { Icon } from "./icon";
import type { IconName } from "../utils/icons";
const NAV_ITEMS = [
{ to: "/", label: "Dashboard" },
{ to: "/projects", label: "Projects" },
{ to: "/ssh-keys", label: "SSH Keys" },
{ to: "/tool-types", label: "Tool Types" },
{ to: "/settings", label: "Settings" }
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Dashboard", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" },
{ to: "/ssh-keys", label: "SSH Keys", icon: "profile" },
{ to: "/tool-types", label: "Tool Types", icon: "code" },
{ to: "/settings", label: "Settings", icon: "settings" }
];
export const AppShell = () => {
@@ -32,6 +34,7 @@ export const AppShell = () => {
}}
type="button"
>
<Icon name="logout" size="sm" />
Logout
</button>
</div>
@@ -46,6 +49,7 @@ export const AppShell = () => {
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
end={item.to === "/"}
>
<Icon name={item.icon} size="sm" />
{item.label}
</NavLink>
))}
+14 -1
View File
@@ -1,5 +1,7 @@
import React, { useState } from "react";
import { Icon } from "./icon";
interface CommitDialogProps {
isOpen: boolean;
filePath: string;
@@ -129,6 +131,7 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
onClick={onCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button
@@ -137,7 +140,17 @@ export const CommitDialog: React.FC<CommitDialogProps> = ({
disabled={loading || !hasChanges || !message.trim()}
type="button"
>
{loading ? "Committing..." : "Commit Changes"}
{loading ? (
<>
<Icon name="loading" size="sm" />
Committing...
</>
) : (
<>
<Icon name="commit" size="sm" />
Commit Changes
</>
)}
</button>
</div>
</div>
+14 -1
View File
@@ -4,6 +4,7 @@ import { apiClient } from "../api/client";
import { useAuth } from "../state/auth";
import { CodeEditor } from "../components/code-editor";
import { CommitDialog } from "../components/commit-dialog";
import { Icon } from "../components/icon";
import { SyntaxHighlighter } from "../components/syntax-highlighter";
import { detectLanguage } from "../utils/language";
@@ -172,6 +173,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
onClick={handleEdit}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
)}
@@ -183,13 +185,24 @@ export const FileEditor: React.FC<FileEditorProps> = ({
disabled={content === originalContent || saving}
type="button"
>
{saving ? "Saving..." : "Save"}
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
+20 -13
View File
@@ -9,6 +9,7 @@ import {
pushRepository,
type GitStatus,
} from "../api/git_repositories";
import { Icon } from "./icon";
import { MergeDialog } from "./merge-dialog";
interface GitToolbarProps {
@@ -140,7 +141,13 @@ export const GitToolbar = ({
>
{branches.map((b) => (
<option key={b} value={b}>
{b === currentBranch ? `${b}` : b}
{b === currentBranch ? (
<>
<Icon name="branch" size="sm" /> {b}
</>
) : (
b
)}
</option>
))}
</select>
@@ -150,18 +157,18 @@ export const GitToolbar = ({
disabled={loading}
type="button"
>
+ New
<Icon name="add" size="sm" /> New
</button>
</div>
<div className="toolbar-group">
<button
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading}
type="button"
>
Fetch
<Icon name="fetch" size="sm" /> Fetch
</button>
<button
className="toolbar-button"
@@ -169,7 +176,7 @@ export const GitToolbar = ({
disabled={loading}
type="button"
>
Pull
<Icon name="pull" size="sm" /> Pull
{status?.behind ? <span className="badge">{status.behind}</span> : null}
</button>
<button
@@ -178,7 +185,7 @@ export const GitToolbar = ({
disabled={loading || !status?.ahead}
type="button"
>
Push
<Icon name="push" size="sm" /> Push
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
</button>
<button
@@ -187,7 +194,7 @@ export const GitToolbar = ({
disabled={loading}
type="button"
>
🔀 Merge
<Icon name="merge" size="sm" /> Merge
</button>
</div>
</div>
@@ -217,24 +224,24 @@ export const GitToolbar = ({
disabled={loading || !newBranchName.trim()}
type="button"
>
Create
<Icon name="add" size="sm" /> Create
</button>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(false)}
type="button"
>
Cancel
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
)}
{hasChanges && status && (
<div className="toolbar-row status-summary">
{status.modified.length > 0 && <span className="status-badge modified"> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted">🗑 {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"> {status.untracked.length} untracked</span>}
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
</div>
)}
+150
View File
@@ -0,0 +1,150 @@
import React from "react";
import {
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
} from "@phosphor-icons/react";
export type IconName =
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary";
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
};
export interface IconProps {
name: IconName;
size?: "sm" | "md" | "lg" | "xl";
color?: string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
className?: string;
ariaLabel?: string;
}
const sizeMap: Record<NonNullable<IconProps["size"]>, number> = {
sm: 16,
md: 20,
lg: 24,
xl: 32,
};
export const Icon: React.FC<IconProps> = ({
name,
size = "md",
color,
weight = "regular",
className,
ariaLabel,
}) => {
const IconComponent = iconMap[name];
const sizeValue = sizeMap[size];
if (!IconComponent) {
console.warn(`Icon "${name}" not found`);
return null;
}
return (
<span
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
style={{ color }}
aria-label={ariaLabel}
aria-hidden={!ariaLabel}
role="img"
>
<IconComponent size={sizeValue} weight={weight} />
</span>
);
};
+13 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { mergeBranches } from "../api/git_repositories";
import { Icon } from "./icon";
interface MergeDialogProps {
projectId: string;
@@ -114,6 +115,7 @@ export const MergeDialog = ({
disabled={loading}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button
@@ -122,7 +124,17 @@ export const MergeDialog = ({
disabled={loading || !sourceBranch}
type="button"
>
{loading ? "Merging..." : "Merge"}
{loading ? (
<>
<Icon name="loading" size="sm" />
Merging...
</>
) : (
<>
<Icon name="merge" size="sm" />
Merge
</>
)}
</button>
</div>
</div>
+13 -1
View File
@@ -1,4 +1,6 @@
import React, { useEffect, useState } from "react";
import { Icon } from "./icon";
import { highlightCode, loadLanguage } from "../utils/language";
interface SyntaxHighlighterProps {
@@ -40,7 +42,17 @@ export const SyntaxHighlighter: React.FC<SyntaxHighlighterProps> = ({
onClick={handleCopy}
type="button"
>
{copied ? "Copied!" : "Copy"}
{copied ? (
<>
<Icon name="success" size="sm" />
Copied!
</>
) : (
<>
<Icon name="copy" size="sm" />
Copy
</>
)}
</button>
</div>
<div className="code-container">
+6 -3
View File
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
import { Icon } from "./icon";
interface WorkspaceHeaderProps {
project: {
@@ -16,7 +17,9 @@ export const WorkspaceHeader = ({ project, currentRepo }: WorkspaceHeaderProps)
return (
<div className="workspace-header">
<div className="workspace-header-left">
<div className="workspace-header-icon">📁</div>
<div className="workspace-header-icon">
<Icon name="folder" size="lg" />
</div>
<div className="workspace-header-info">
<h1 className="workspace-header-title">{project.name}</h1>
{currentRepo && (
@@ -29,14 +32,14 @@ export const WorkspaceHeader = ({ project, currentRepo }: WorkspaceHeaderProps)
className="workspace-header-action-btn"
to={`/projects/${project.id}/repositories/${currentRepo?.id || ""}/history`}
>
<span className="icon">🕐</span>
<Icon name="history" size="sm" />
History
</Link>
<Link
className="workspace-header-action-btn"
to={`/projects/${project.id}/settings`}
>
<span className="icon"></span>
<Icon name="settings" size="sm" />
Settings
</Link>
</div>
+4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { Icon } from "../components/icon";
const CARDS = [
{ label: "Projects", key: "projects" },
@@ -50,6 +51,7 @@ export const DashboardPage = () => {
<div className="card stack">
<p>Dashboard is unavailable</p>
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -68,9 +70,11 @@ export const DashboardPage = () => {
<div className="quick-actions">
<button className="primary-button" type="button">
<Icon name="add" size="sm" />
New Project
</button>
<button className="secondary-button" type="button">
<Icon name="add" size="sm" />
Add Repository
</button>
</div>
+3 -1
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git_repositories";
import { Icon } from "../components/icon";
export const GitHistoryPage = () => {
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
@@ -70,6 +71,7 @@ export const GitHistoryPage = () => {
<section className="stack">
<p>Failed to load commit history</p>
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</section>
@@ -155,7 +157,7 @@ export const GitHistoryPage = () => {
<div className="detail-header">
<h3>Commit Details</h3>
<button className="ghost-button" onClick={handleCloseDetail} type="button">
×
<Icon name="close" size="sm" />
</button>
</div>
+10 -3
View File
@@ -10,6 +10,7 @@ import {
type URLParseResult,
} from "../api/git_repositories";
import type { GitRepository } from "../api/git_repositories";
import { Icon } from "../components/icon";
type RepoStatus = "loading" | "ready" | "error";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
@@ -160,6 +161,7 @@ export const GitRepositoriesPage = () => {
<div className="page-header">
<h1>Repositories</h1>
<button className="primary-button" onClick={() => setShowCreate(true)} type="button">
<Icon name="add" size="sm" />
New Repository
</button>
</div>
@@ -170,6 +172,7 @@ export const GitRepositoriesPage = () => {
<div className="card stack">
<p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -256,12 +259,14 @@ export const GitRepositoriesPage = () => {
<span className="validation-status validating">Validating...</span>
)}
{urlValidation.status === "valid" && (
<span className="validation-status valid"> Valid git URL</span>
<span className="validation-status valid">
<Icon name="success" size="sm" /> Valid git URL
</span>
)}
{urlValidation.status === "needs-parsing" && urlValidation.result && (
<div className="url-suggestion">
<span className="validation-status warning">
This looks like a browser URL
<Icon name="warning" size="sm" /> This looks like a browser URL
</span>
<div className="suggestion-actions">
<span className="suggested-url">
@@ -279,7 +284,7 @@ export const GitRepositoriesPage = () => {
)}
{urlValidation.status === "invalid" && (
<span className="validation-status invalid">
Invalid URL
<Icon name="error" size="sm" /> Invalid URL
</span>
)}
</label>
@@ -292,9 +297,11 @@ export const GitRepositoriesPage = () => {
)}
<div className="dialog-actions">
<button className="secondary-button" onClick={() => setShowCreate(false)} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
Create
</button>
</div>
+3
View File
@@ -18,6 +18,8 @@ export const NotFoundPage = () => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
import { Icon } from "../components/icon";
export const LoginRedirectPage = () => {
const nextPath = new URLSearchParams(window.location.search).get("next") ?? "/";
const encodedNext = encodeURIComponent(nextPath);
@@ -27,6 +29,7 @@ export const LoginRedirectPage = () => {
<h1>Sign in required</h1>
<p className="muted">You need to authenticate to access this section.</p>
<a className="primary-button" href={`${API_BASE_URL}/auth/login?next=${encodedNext}`}>
<Icon name="profile" size="sm" />
Continue to login
</a>
</section>
+24 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
import { Icon } from "../components/icon";
import { useAuth } from "../state/auth";
import type { UserProfile } from "../api/profile";
@@ -99,6 +100,7 @@ export const ProfilePage = () => {
<div className="card stack">
<p>Failed to load profile</p>
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -120,7 +122,17 @@ export const ProfilePage = () => {
onClick={() => fileInputRef.current?.click()}
type="button"
>
{status === "saving" ? "Uploading..." : "Change Avatar"}
{status === "saving" ? (
<>
<Icon name="loading" size="sm" />
Uploading...
</>
) : (
<>
<Icon name="edit" size="sm" />
Change Avatar
</>
)}
</button>
<input
accept="image/png,image/jpeg"
@@ -162,7 +174,17 @@ export const ProfilePage = () => {
onClick={() => void handleSave()}
type="button"
>
{status === "saving" ? "Saving..." : "Save Changes"}
{status === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save Changes
</>
)}
</button>
</div>
</div>
+19 -1
View File
@@ -10,6 +10,7 @@ import {
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import { Icon } from "../components/icon";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
@@ -110,6 +111,7 @@ export const ProjectsPage = () => {
<div className="page-header">
<h1>Projects</h1>
<button className="primary-button" onClick={openCreate} type="button">
<Icon name="add" size="sm" />
New Project
</button>
</div>
@@ -120,6 +122,7 @@ export const ProjectsPage = () => {
<div className="card stack">
<p>Failed to load projects</p>
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -144,6 +147,7 @@ export const ProjectsPage = () => {
onClick={() => openEdit(project)}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
{deleteConfirmId === project.id ? (
@@ -154,6 +158,7 @@ export const ProjectsPage = () => {
onClick={() => void handleDelete(project.id)}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
<button
@@ -161,6 +166,7 @@ export const ProjectsPage = () => {
onClick={() => setDeleteConfirmId(null)}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
@@ -170,6 +176,7 @@ export const ProjectsPage = () => {
onClick={() => setDeleteConfirmId(project.id)}
type="button"
>
<Icon name="delete" size="sm" />
Delete
</button>
)}
@@ -205,10 +212,21 @@ export const ProjectsPage = () => {
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? "Create" : "Save"}
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
</div>
</form>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon";
import { Link, useParams, useSearchParams } from "react-router-dom";
@@ -155,6 +156,7 @@ export const RepoWorkspace = () => {
onClick={() => void loadRepositories()}
type="button"
>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
@@ -338,7 +340,7 @@ const FileBrowser = ({
<div className="file-tree">
{path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
📁 ..
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.map((entry) => {
@@ -350,7 +352,7 @@ const FileBrowser = ({
onClick={() => handleEntryClick(entry)}
type="button"
>
{entry.type === "directory" ? "📁" : "📄"} {entry.name}
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
+13 -1
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
import { Icon } from "../components/icon";
type SettingsStatus = "loading" | "ready" | "error";
@@ -66,6 +67,7 @@ export const SettingsPage = () => {
<section className="stack">
<p>Failed to load settings</p>
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
<Icon name="refresh" size="sm" />
Retry
</button>
</section>
@@ -132,7 +134,17 @@ export const SettingsPage = () => {
<div className="settings-actions">
<button className="primary-button" onClick={() => void handleSave()} type="button">
{saveStatus === "saving" ? "Saving..." : "Save Settings"}
{saveStatus === "saving" ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save Settings
</>
)}
</button>
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
+14 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
import { Icon } from "../components/icon";
export const SSHKeysPage = () => {
const [keys, setKeys] = useState<SSHKey[]>([]);
@@ -77,7 +78,17 @@ export const SSHKeysPage = () => {
/>
</div>
<button type="submit" className="primary-button" disabled={generating}>
{generating ? "Generating..." : "Generate SSH Key"}
{generating ? (
<>
<Icon name="loading" size="sm" />
Generating...
</>
) : (
<>
<Icon name="add" size="sm" />
Generate SSH Key
</>
)}
</button>
</form>
@@ -93,6 +104,7 @@ export const SSHKeysPage = () => {
onClick={() => handleDelete(key.id)}
className="danger-button"
>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
@@ -107,6 +119,7 @@ export const SSHKeysPage = () => {
onClick={() => copyToClipboard(key.public_key)}
className="secondary-button"
>
<Icon name="copy" size="sm" />
Copy Full Key
</button>
</div>
+30 -4
View File
@@ -8,6 +8,7 @@ import {
type CreateToolTypeRequest,
type UpdateToolTypeRequest,
} from "../api/tool_types";
import { Icon } from "../components/icon";
import type { ToolType } from "../api/tool_types";
type ToolTypesStatus = "loading" | "ready" | "error";
@@ -134,7 +135,10 @@ export const ToolTypesPage = () => {
return (
<div className="container">
<p className="text-error">Failed to load tool types.</p>
<button onClick={loadToolTypes}>Retry</button>
<button onClick={loadToolTypes}>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
);
}
@@ -143,7 +147,10 @@ export const ToolTypesPage = () => {
<div className="container">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
<h1>Tool Types</h1>
<button onClick={openCreate}>Create Tool Type</button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
{toolTypes.length === 0 ? (
@@ -161,12 +168,14 @@ export const ToolTypesPage = () => {
{!toolType.is_builtin && (
<>
<button onClick={() => openEdit(toolType)} className="button-secondary">
<Icon name="edit" size="sm" />
Edit
</button>
<button
onClick={() => setDeleteConfirmId(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
</>
@@ -179,9 +188,13 @@ export const ToolTypesPage = () => {
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger">
<Icon name="delete" size="sm" />
Delete
</button>
<button onClick={() => setDeleteConfirmId(null)}>Cancel</button>
<button onClick={() => setDeleteConfirmId(null)}>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</div>
</div>
@@ -250,8 +263,21 @@ export const ToolTypesPage = () => {
{formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions">
<button type="submit">{dialogMode === "create" ? "Create" : "Update"}</button>
<button type="submit">
{dialogMode === "create" ? (
<>
<Icon name="add" size="sm" />
Create
</>
) : (
<>
<Icon name="save" size="sm" />
Update
</>
)}
</button>
<button type="button" onClick={closeDialog} className="button-secondary">
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
+54
View File
@@ -1742,3 +1742,57 @@ pre[class*="language-"] {
.token.variable {
color: #ec4899;
}
/* Icon System */
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
vertical-align: middle;
}
.icon svg {
display: block;
}
.icon-sm {
width: 16px;
height: 16px;
}
.icon-md {
width: 20px;
height: 20px;
}
.icon-lg {
width: 24px;
height: 24px;
}
.icon-xl {
width: 32px;
height: 32px;
}
/* Button icons */
button .icon,
a .icon {
margin-right: 0.35rem;
}
button .icon:last-child,
a .icon:last-child {
margin-right: 0;
}
/* Navigation icons */
.nav-item .icon {
margin-right: 0.5rem;
}
/* Status badge icons */
.status-badge .icon {
margin-right: 0.25rem;
}
+163
View File
@@ -0,0 +1,163 @@
import {
House,
Folder,
GitBranch,
Gear,
User,
SignOut,
Plus,
PencilSimple,
Trash,
FloppyDisk,
X,
ArrowsClockwise,
Copy,
MagnifyingGlass,
List,
Check,
Warning,
Info,
Spinner,
GitCommit,
GitMerge,
ClockCounterClockwise,
ArrowDown,
ArrowUp,
File,
FileText,
Image,
Binary,
Code,
} from "@phosphor-icons/react";
export type IconName =
| "dashboard"
| "projects"
| "repositories"
| "settings"
| "profile"
| "logout"
| "add"
| "edit"
| "delete"
| "save"
| "cancel"
| "refresh"
| "copy"
| "search"
| "menu"
| "close"
| "success"
| "error"
| "warning"
| "info"
| "loading"
| "branch"
| "commit"
| "merge"
| "history"
| "pull"
| "push"
| "fetch"
| "file"
| "folder"
| "code"
| "document"
| "image"
| "binary";
export const iconRegistry: Record<
IconName,
React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>
> = {
// Navigation
dashboard: House,
projects: Folder,
repositories: GitBranch,
settings: Gear,
profile: User,
logout: SignOut,
// Actions
add: Plus,
edit: PencilSimple,
delete: Trash,
save: FloppyDisk,
cancel: X,
refresh: ArrowsClockwise,
copy: Copy,
search: MagnifyingGlass,
menu: List,
close: X,
// Status
success: Check,
error: X,
warning: Warning,
info: Info,
loading: Spinner,
// Git
branch: GitBranch,
commit: GitCommit,
merge: GitMerge,
history: ClockCounterClockwise,
pull: ArrowDown,
push: ArrowUp,
fetch: ArrowsClockwise,
// Files
file: File,
folder: Folder,
code: Code,
document: FileText,
image: Image,
binary: Binary,
};
export const iconCategories = {
navigation: [
"dashboard",
"projects",
"repositories",
"settings",
"profile",
"logout",
] as IconName[],
actions: [
"add",
"edit",
"delete",
"save",
"cancel",
"refresh",
"copy",
"search",
"menu",
"close",
] as IconName[],
status: [
"success",
"error",
"warning",
"info",
"loading",
] as IconName[],
git: [
"branch",
"commit",
"merge",
"history",
"pull",
"push",
"fetch",
] as IconName[],
files: [
"file",
"folder",
"code",
"document",
"image",
"binary",
] as IconName[],
};
@@ -0,0 +1,2 @@
schema: spec-driven
name: universal-icon-system
@@ -0,0 +1,149 @@
# Universal Icon System - Design
## Architecture
```
Icon System
├── Icon Component (centralized wrapper)
│ ├── Size variants: sm, md, lg, xl
│ ├── Color: inherited or explicit
│ └── Accessibility: aria-label, role
├── Icon Registry (mapping names to Phosphor icons)
└── Usage throughout app
├── Navigation icons
├── Action icons
├── Status indicators
└── Git operation icons
```
## Component Design
### Icon Component
**Props:**
```typescript
interface IconProps {
name: IconName;
size?: "sm" | "md" | "lg" | "xl";
color?: string;
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
className?: string;
ariaLabel?: string;
}
```
**Size Mapping:**
- sm: 16px
- md: 20px (default)
- lg: 24px
- xl: 32px
**Color:**
- Default: inherits from parent via `currentColor`
- Explicit: uses CSS variable or direct color value
### Icon Registry
**Categories:**
Navigation:
- `home` - Dashboard
- `projects` - Projects list
- `repositories` - Git repositories
- `settings` - User settings
- `profile` - User profile
Actions:
- `add` - Create new
- `edit` - Edit item
- `delete` - Delete item
- `save` - Save changes
- `cancel` - Cancel action
- `refresh` - Refresh/reload
- `copy` - Copy to clipboard
Status:
- `success` - Checkmark
- `error` - X mark
- `warning` - Warning triangle
- `info` - Information circle
- `loading` - Spinner
Git Operations:
- `branch` - Git branch
- `commit` - Git commit
- `merge` - Merge branches
- `history` - Commit history
- `pull` - Pull changes
- `push` - Push changes
Files:
- `file` - Generic file
- `folder` - Directory
- `code` - Code file
- `document` - Text document
## Migration Plan
### Phase 1: Setup
1. Install `@phosphor-icons/react`
2. Create `Icon` component
3. Create icon registry mapping
### Phase 2: Replace Raw Unicode
Replace all instances of raw Unicode symbols:
- `✓``Icon name="check"`
- `✗``Icon name="x"`
- `⚠``Icon name="warning"`
- `●``Icon name="dot"`
- `❓``Icon name="question"`
- `↓``Icon name="arrow-down"`
### Phase 3: Update Components
Update existing components to use icon system:
- GitToolbar
- GitRepositoriesPage
- FileEditor
- AppShell navigation
- Dialog buttons
- Form validation indicators
### Phase 4: Styling
- Ensure consistent spacing around icons
- Add hover states where applicable
- Maintain alignment with text
## Accessibility
- All icons have meaningful `aria-label`
- Decorative icons use `aria-hidden="true"`
- Focus indicators for interactive icons
- Sufficient color contrast
## Technical Details
**Library:** `@phosphor-icons/react`
**Bundle Impact:** Tree-shakeable, ~2KB per icon used
**Browser Support:** All modern browsers
**Fallback:** None needed - SVG-based, always renders
## CSS Integration
```css
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon-sm { width: 16px; height: 16px; }
.icon-md { width: 20px; height: 20px; }
.icon-lg { width: 24px; height: 24px; }
.icon-xl { width: 32px; height: 32px; }
/* Inherit color from parent */
.icon svg {
fill: currentColor;
}
```
@@ -0,0 +1,58 @@
# Universal Icon System
## Problem
The current project uses inconsistent icon implementations across the frontend:
- Raw Unicode symbols (✓, ✗, ⚠, etc.) which render differently across browsers and operating systems
- No standardized icon component or library
- Inconsistent visual language throughout the UI
- Some icons may not render at all on certain systems
This creates a fragmented user experience and potential accessibility issues.
## Solution
Implement a universal icon system using **Phosphor Icons** - a comprehensive, lightweight icon library designed for modern web applications:
1. **Consistent rendering** across all browsers and platforms
2. **Comprehensive icon set** with 7000+ icons covering all use cases
3. **Multiple weights** (thin, light, regular, bold, fill, duotone) for flexibility
4. **Tree-shakeable** - only includes icons that are actually used
5. **React integration** with phosphor-react library
6. **Accessible** with proper ARIA labels and focus management
## Key Features
### Icon Component
- Centralized `<Icon>` component wrapping Phosphor icons
- Consistent sizing (sm, md, lg, xl)
- Color inheritance from parent or explicit color prop
- Accessibility support (aria-label, role)
### Icon Categories
- Navigation (home, settings, user, etc.)
- Actions (edit, delete, save, add, etc.)
- Status (success, error, warning, info)
- Files and folders
- Git operations (branch, commit, merge, etc.)
- Development tools (terminal, code, database, etc.)
### Migration Strategy
- Replace all raw Unicode symbols with proper icon components
- Update existing components to use the new icon system
- Maintain visual consistency during migration
## Benefits
- **Cross-browser consistency** - Icons render identically everywhere
- **Better accessibility** - Screen reader friendly with proper labels
- **Improved maintainability** - Single source of truth for icons
- **Enhanced UX** - Professional, polished appearance
- **Future-proof** - Easy to add new icons as needed
## Success Criteria
- [ ] All raw Unicode symbols replaced with icon components
- [ ] Consistent icon sizing and styling across all pages
- [ ] No visual regressions in existing UI
- [ ] Icons render correctly in all supported browsers
@@ -0,0 +1,111 @@
# Universal Icon System Specification
## Requirements
### Functional Requirements
1. **Icon Component**: Centralized `Icon` component that wraps Phosphor icons
2. **Icon Registry**: Mapping of logical names to Phosphor icon components
3. **Size Variants**: Support for sm (16px), md (20px), lg (24px), xl (32px)
4. **Color Inheritance**: Default to `currentColor`, support explicit colors
5. **Accessibility**: Proper ARIA labels and roles
6. **Tree Shaking**: Only include icons that are actually used
### Non-Functional Requirements
1. **Bundle Size**: Minimal impact (~2KB per icon weight variant)
2. **Performance**: No layout shift, instant rendering
3. **Browser Support**: All modern browsers (Chrome, Firefox, Safari, Edge)
4. **Consistency**: Identical rendering across all platforms
## Icon Registry
### Navigation Icons
- `dashboard``House`
- `projects``Folder`
- `repositories``GitBranch`
- `settings``Gear`
- `profile``User`
- `logout``SignOut`
### Action Icons
- `add``Plus`
- `edit``PencilSimple`
- `delete``Trash`
- `save``FloppyDisk`
- `cancel``X`
- `refresh``ArrowsClockwise`
- `copy``Copy`
- `search``MagnifyingGlass`
- `menu``List`
- `close``X`
### Status Icons
- `success``Check`
- `error``X`
- `warning``Warning`
- `info``Info`
- `loading``Spinner`
### Git Icons
- `branch``GitBranch`
- `commit``GitCommit`
- `merge``GitMerge`
- `history``ClockCounterClockwise`
- `pull``ArrowDown`
- `push``ArrowUp`
- `fetch``ArrowsClockwise`
### File Icons
- `file``File`
- `folder``Folder`
- `code``Code`
- `document``FileText`
- `image``Image`
- `binary``FileBinary`
## Migration Checklist
### Components to Update
- [ ] `app-shell.tsx` - Navigation icons
- [ ] `git-toolbar.tsx` - Git operation icons
- [ ] `git-repositories.tsx` - Status/validation icons
- [ ] `repo-workspace.tsx` - File tree icons
- [ ] `file-editor.tsx` - File type icons
- [ ] `dashboard.tsx` - Dashboard icons
- [ ] `projects.tsx` - Project action icons
- [ ] `settings.tsx` - Settings icons
- [ ] `tool-types.tsx` - Tool type icons
- [ ] `ssh-keys.tsx` - Key management icons
- [ ] `profile.tsx` - Profile icons
- [ ] `commit-dialog.tsx` - Dialog icons
- [ ] `syntax-highlighter.tsx` - Copy icon
- [ ] `code-editor.tsx` - Edit icons
- [ ] All button components with icons
## CSS Requirements
```css
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon-sm { width: 16px; height: 16px; }
.icon-md { width: 20px; height: 20px; }
.icon-lg { width: 24px; height: 24px; }
.icon-xl { width: 32px; height: 32px; }
```
## Accessibility Requirements
1. All functional icons must have `aria-label`
2. Decorative icons must have `aria-hidden="true"`
3. Interactive icons must be focusable
4. Color contrast ratio ≥ 4.5:1
## Dependencies
- `@phosphor-icons/react` - React component library
@@ -0,0 +1,121 @@
# Universal Icon System - Tasks
## Phase 1: Setup
- [x] **Task 1.1**: Install Phosphor Icons
- `npm install @phosphor-icons/react`
- Add to package.json dependencies
- [x] **Task 1.2**: Create Icon component
- Create `components/icon.tsx`
- Implement size variants (sm, md, lg, xl)
- Support color inheritance and explicit colors
- Add accessibility props (aria-label, aria-hidden)
- [x] **Task 1.3**: Create icon registry
- Create `utils/icons.ts`
- Map logical names to Phosphor icon components
- Group by category (navigation, actions, status, git, files)
- Export TypeScript types for icon names
## Phase 2: Replace Raw Unicode Symbols
- [x] **Task 2.1**: Replace validation icons in git-repositories.tsx
- `✓``Icon name="check"`
- `⚠``Icon name="warning"`
- `✗``Icon name="x"`
- [x] **Task 2.2**: Replace git toolbar icons in git-toolbar.tsx
- `●``Icon name="dot"`
- `↓``Icon name="arrow-down"`
- `❓``Icon name="question"`
- [x] **Task 2.3**: Scan and replace all other Unicode symbols
- Search for remaining Unicode symbols across all TSX files
- Replace with appropriate Icon components
## Phase 3: Update Navigation
- [x] **Task 3.1**: Update app-shell.tsx navigation
- Replace text-only nav items with icon + text
- Use navigation icons (dashboard, projects, settings, etc.)
- Maintain current layout and styling
## Phase 4: Update Action Buttons
- [ ] **Task 4.1**: Update button components
- Add icon support to Button component
- Update all primary/secondary buttons with relevant icons
- Ensure proper spacing between icon and text
- [ ] **Task 4.2**: Update form actions
- Save buttons: `Icon name="save"`
- Cancel buttons: `Icon name="x"`
- Delete buttons: `Icon name="trash"`
- Edit buttons: `Icon name="pencil"`
## Phase 5: Update Status Indicators
- [ ] **Task 5.1**: Replace status badges
- Success states: `Icon name="check"` + green color
- Error states: `Icon name="x"` + red color
- Warning states: `Icon name="warning"` + yellow color
- Loading states: `Icon name="spinner"` + animation
## Phase 6: Update Git Components
- [ ] **Task 6.1**: Update GitToolbar
- Branch icon: `Icon name="git-branch"`
- Fetch icon: `Icon name="arrows-clockwise"`
- Pull icon: `Icon name="arrow-down"`
- Push icon: `Icon name="arrow-up"`
- Commit icon: `Icon name="git-commit"`
- [ ] **Task 6.2**: Update GitHistoryPage
- History icon: `Icon name="clock-counter-clockwise"`
- Merge icon: `Icon name="git-merge"`
- Branch selector icon: `Icon name="git-branch"`
## Phase 7: Update File Components
- [ ] **Task 7.1**: Update file tree icons
- Folder icon: `Icon name="folder"`
- File icon: `Icon name="file"`
- Code file icon: `Icon name="code"`
- Binary file icon: `Icon name="file-binary"`
- [ ] **Task 7.2**: Update FileEditor toolbar
- Edit icon: `Icon name="pencil"`
- Save icon: `Icon name="floppy-disk"`
- Copy icon: `Icon name="copy"`
## Phase 8: CSS and Styling
- [ ] **Task 8.1**: Add icon CSS classes
- Create `.icon` base class
- Size variants: `.icon-sm`, `.icon-md`, `.icon-lg`, `.icon-xl`
- Alignment utilities for icon + text combos
- [ ] **Task 8.2**: Ensure consistent spacing
- Icon margins in buttons
- Icon alignment with text
- Icon padding in navigation items
## Phase 9: Quality Gates
- [ ] **Task 9.1**: TypeScript checks
- `npm run typecheck`
- Fix any type errors
- [ ] **Task 9.2**: Lint checks
- `npm run lint`
- Fix any linting issues
- [ ] **Task 9.3**: Build verification
- `npm run build`
- Verify bundle size impact
- [ ] **Task 9.4**: Visual verification
- Check all pages for icon rendering
- Verify no missing icons or broken layouts
- Check dark/light theme compatibility