diff --git a/.prettierignore b/.prettierignore
index c1e8b36..7d7c0b1 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -8,6 +8,7 @@ package-lock.json
*.tsbuildinfo
app/src-tauri/target
app/src-tauri/gen
+app/src-old/
# SVGs have no Prettier parser and are either hand-crafted art or generated
# from upstream icon sets; leave them untouched.
diff --git a/CLAUDE.md b/CLAUDE.md
index 0198957..a1eaf69 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,7 +29,7 @@ Port selection depends on mode: Tauri binds **1420** (hard-coded in `tauri.conf.
### Three-workspace monorepo
- `shared/` (`@recrest/shared`) — constants, types, pure utils. Compiled to `dist/` and consumed as a normal npm dep. `postinstall` and `predev` build it automatically; `app/tsconfig.app.json` has it as a TS project reference so composite builds work.
-- `app/` (`@recrest/app`) — React 19 + Vite + Tailwind v4 frontend, and the Rust Tauri backend in `app/src-tauri/`.
+- `app/` (`@recrest/app`) — React 19 + Vite + MUI v9 + Emotion frontend, and the Rust Tauri backend in `app/src-tauri/`.
- `tests/` (`@recrest/tests`) — Playwright E2E.
Do **not** add path aliases pointing `@recrest/shared` at the source files. `shared/` has `composite: true` and emits to `dist/`; the rest of the repo resolves it via `node_modules` (yarn symlink → shared's `package.json` main/types). Source imports would break `tsc -b`. For Vitest we instead use explicit `resolve.alias` in `app/vitest.config.ts`, because `vite-tsconfig-paths` would pick up the Solution `tsconfig.json` (which holds only references) and miss the app's real paths.
@@ -70,8 +70,9 @@ Rust commands are registered in `app/src-tauri/src/lib.rs::run()`. DTOs use `#[s
- TypeScript is strict with `noUncheckedIndexedAccess` and `noImplicitOverride`. Array index access returns `T | undefined` — guard or coalesce.
- Imports are sorted by `@trivago/prettier-plugin-sort-imports`; don't reorder manually (prettier will overwrite).
- React components avoid nested interactive elements. Row selectors use `
` with keyboard handlers so action buttons inside rows stay legal.
-- Do not reintroduce `postcss.config.js` or `autoprefixer` / `postcss` as deps — Tailwind v4 runs through `@tailwindcss/vite` and handles vendor prefixes internally.
+- Styling goes through MUI v9 + Emotion `styled()` components only. Never use the `sx` prop — every style collection must live in a `styled()` component (see `feedback_no_sx_always_styled` memory). Tailwind, PostCSS, and Autoprefixer were removed in the Phase 2 migration — do not reintroduce them.
- When adding a Tauri command: declare it in the matching `commands/*.rs`, wire it into `generate_handler![...]` in `lib.rs`, mirror the return type as a TS DTO on the `@recrest/shared` side, and consume it through `invoke
` in a thunk (not directly in components).
+- **No magic strings.** Every `data-testid`, `recrest:*` storage key, Tauri command name, and IPC event channel must come from a constant in `app/src/lib/constants/` (or `@recrest/shared`). ESLint's `no-restricted-syntax` block enforces this — see `app/src/lib/constants/README.md` for the full layering and how to add a new constant. The only sanctioned inline exception is the anti-flash `
You need to enable JavaScript to run this app.
diff --git a/app/package.json b/app/package.json
index 5518ed5..91f55ee 100644
--- a/app/package.json
+++ b/app/package.json
@@ -30,20 +30,20 @@
"dep-graph:dot": "madge --dot --extensions ts,tsx --ts-config tsconfig.app.json src/ > dependency-graph.dot"
},
"dependencies": {
+ "@emotion/react": "^11.14.0",
+ "@emotion/styled": "^11.14.1",
+ "@fontsource/fira-code": "^5.2.7",
+ "@fontsource/geist": "^5.2.9",
+ "@fontsource/geist-mono": "^5.2.8",
+ "@fontsource/ibm-plex-mono": "^5.2.7",
+ "@fontsource/ibm-plex-sans": "^5.2.8",
"@fontsource/inter": "^5.2.8",
+ "@fontsource/jetbrains-mono": "^5.2.8",
+ "@fontsource/manrope": "^5.2.8",
"@fontsource/opendyslexic": "^5.2.5",
"@fontsource/space-grotesk": "^5.2.10",
- "@radix-ui/react-alert-dialog": "^1.1.15",
- "@radix-ui/react-checkbox": "^1.3.3",
- "@radix-ui/react-dialog": "^1.1.15",
- "@radix-ui/react-dropdown-menu": "^2.1.16",
- "@radix-ui/react-label": "^2.1.8",
- "@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.8",
- "@radix-ui/react-slot": "^1.2.4",
- "@radix-ui/react-switch": "^1.2.6",
- "@radix-ui/react-tabs": "^1.1.13",
- "@radix-ui/react-tooltip": "^1.2.8",
+ "@mui/icons-material": "^9.0.1",
+ "@mui/material": "^9.0.1",
"@recrest/shared": "*",
"@reduxjs/toolkit": "^2.2.8",
"@tauri-apps/api": "^2.1.1",
@@ -58,13 +58,13 @@
"@tauri-apps/plugin-store": "^2.4.2",
"@tauri-apps/plugin-updater": "^2.10.1",
"@use-gesture/react": "^10.3.1",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"device-type-detection": "^2.1.3",
"i18next": "^23.16.0",
"i18next-browser-languagedetector": "^8.0.0",
"lucide-react": "^0.451.0",
+ "motion": "^12.40.0",
"react": "^19.0.0",
"react-country-flag": "^3.1.0",
"react-dom": "^19.0.0",
@@ -72,14 +72,12 @@
"react-redux": "^9.1.2",
"react-router-dom": "^6.27.0",
"simple-icons": "^16.17.0",
- "sonner": "^2.0.7",
- "tailwind-merge": "^2.5.4"
+ "sonner": "^2.0.7"
},
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"@storybook/addon-docs": "^10.3.5",
"@storybook/react-vite": "^10.3.5",
- "@tailwindcss/vite": "^4.0.0",
"@tauri-apps/cli": "^2.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.5.0",
@@ -100,8 +98,6 @@
"madge": "^8.0.0",
"sass": "^1.83.0",
"storybook": "^10.3.5",
- "tailwindcss": "^4.0.0",
- "tw-animate-css": "^1.2.5",
"typescript": "^5.6.3",
"typescript-eslint": "^8.8.0",
"vite": "^5.4.8",
diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock
index 79c2a68..2af4b96 100644
--- a/app/src-tauri/Cargo.lock
+++ b/app/src-tauri/Cargo.lock
@@ -4102,6 +4102,7 @@ dependencies = [
"uuid",
"walkdir",
"which",
+ "window-vibrancy 0.7.1",
"windows 0.58.0",
]
@@ -5337,7 +5338,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
- "window-vibrancy",
+ "window-vibrancy 0.6.0",
"windows 0.61.3",
]
@@ -6785,6 +6786,21 @@ dependencies = [
"windows-version",
]
+[[package]]
+name = "window-vibrancy"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "raw-window-handle",
+ "windows-sys 0.60.2",
+ "windows-version",
+]
+
[[package]]
name = "windows"
version = "0.56.0"
diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml
index c036f7a..7cca9b1 100644
--- a/app/src-tauri/Cargo.toml
+++ b/app/src-tauri/Cargo.toml
@@ -26,6 +26,13 @@ tauri-plugin-deep-link = "2.4"
tauri-plugin-process = "2.3"
tauri-plugin-single-instance = "2.3"
tauri-plugin-dialog = "2.3"
+# Native vibrancy / blurred backdrop. On macOS we always apply HudWindow
+# vibrancy on startup — when the active theme uses an opaque background it's
+# simply invisible; the Glassy theme renders translucent surfaces so the
+# vibrancy shows through. On Windows we apply Acrylic for the same reason.
+# Linux has no compositor blur, so Glassy falls back to a semi-transparent
+# surface without blur — documented as a platform limitation.
+window-vibrancy = "0.7"
sentry = "0.38"
os_info = "3"
serde = { version = "1.0", features = ["derive"] }
diff --git a/app/src-tauri/src/commands/settings.rs b/app/src-tauri/src/commands/settings.rs
index 2e19109..512397d 100644
--- a/app/src-tauri/src/commands/settings.rs
+++ b/app/src-tauri/src/commands/settings.rs
@@ -5,8 +5,8 @@ use std::collections::BTreeMap;
use crate::auth::token::TokenStore;
use crate::config::settings::{
- AppSettings, NotificationSettings, PrivacySettings, RepoImportDefaults, RepoListSort,
- RepoListViewMode, TerminalSettings,
+ AccessibilitySettings, AppSettings, AppearanceSettings, NotificationSettings, PrivacySettings,
+ RepoImportDefaults, RepoListSort, RepoListViewMode, TerminalSettings, WindowStateSettings,
};
use crate::AppState;
@@ -54,6 +54,19 @@ pub struct SettingsPatch {
pub terminal: Option,
pub commit_message_template: Option,
pub privacy: Option,
+ // Phase 2 fields: the renderer now sends the full appearance / accessibility
+ // sub-structs (themeId, followsSystem, primaryColor, font, fontSize, etc.).
+ // These used to be dropped silently because the patch struct had no slot
+ // for them — every `setThemeId` round-trip then overwrote the optimistic
+ // update with the stale on-disk values, which was the cause of the
+ // "theme always reverts to system" regression in the Tauri build.
+ pub appearance: Option,
+ pub accessibility: Option,
+ // Sidebar collapse + future window-state slots. Without this slot every
+ // `toggleSidebar` round-trip dropped the patch and the renderer's
+ // `hydrateUiFromBackend` rewound the optimistic update — visible in Tauri
+ // as the sidebar toggle "doing nothing" after one frame.
+ pub window_state: Option,
}
#[tauri::command]
@@ -134,6 +147,15 @@ pub async fn update_settings(
if let Some(value) = patch.privacy {
settings.privacy = value;
}
+ if let Some(value) = patch.appearance {
+ settings.appearance = value;
+ }
+ if let Some(value) = patch.accessibility {
+ settings.accessibility = value;
+ }
+ if let Some(value) = patch.window_state {
+ settings.window_state = value;
+ }
}
config.save(&app)?;
Ok(config.settings().clone())
diff --git a/app/src-tauri/src/config/settings.rs b/app/src-tauri/src/config/settings.rs
index 515fc47..0f579bc 100644
--- a/app/src-tauri/src/config/settings.rs
+++ b/app/src-tauri/src/config/settings.rs
@@ -25,6 +25,71 @@ impl Default for NotificationSettings {
}
}
+/// Renderer-scoped appearance + accessibility tokens that the React shell
+/// owns end-to-end. Phase-2 moves these out of `localStorage` and onto the
+/// Tauri backend so every Recrest surface (web preview included) reads them
+/// from a single source of truth.
+///
+/// All fields are `#[serde(default)]` so existing `settings.json` files
+/// migrate cleanly: missing fields fall back to the renderer's defaults.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AppearanceSettings {
+ /// Renderer-side theme variant — extends the legacy `theme` (light/dark/system)
+ /// with "oled" and "glassy" without breaking the older field. When the
+ /// renderer dispatches `setThemeId`, this slot wins over `theme`.
+ pub theme_id: String,
+ /// True ⇔ the renderer should track `prefers-color-scheme`. Mirrors the
+ /// legacy `theme === "system"` semantic but kept explicit so the renderer
+ /// can persist "user picked Light" vs. "user is on Light because OS says so".
+ pub follows_system: bool,
+ /// Accent / brand color (named scheme, not hex). One of: default, blue,
+ /// green, purple, pink, orange.
+ pub primary_color: String,
+ /// Renderer font slot — "inter" | "opendyslexic" | future additions.
+ pub font: String,
+ /// Renderer font size token — "sm" | "md" | "lg".
+ pub font_size: String,
+}
+
+impl Default for AppearanceSettings {
+ fn default() -> Self {
+ Self {
+ theme_id: "light".into(),
+ follows_system: true,
+ primary_color: "default".into(),
+ font: "inter".into(),
+ font_size: "md".into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AccessibilitySettings {
+ /// Legacy boolean kept in sync with `appearance.font === "opendyslexic"`.
+ /// Tests written against the pre-Phase-2 shape keep working.
+ #[serde(default)]
+ pub dyslexia_font: bool,
+ #[serde(default)]
+ pub high_contrast: bool,
+ #[serde(default)]
+ pub reduced_motion: bool,
+ #[serde(default)]
+ pub underline_links: bool,
+}
+
+/// Tiny window-state slice persisted alongside settings (the sidebar lives
+/// here because it survives across sessions exactly like an appearance
+/// preference). Future window-state bits (panel splits, last-active route)
+/// land in the same struct.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WindowStateSettings {
+ #[serde(default)]
+ pub sidebar_collapsed: bool,
+}
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacySettings {
@@ -139,6 +204,14 @@ pub struct AppSettings {
pub commit_message_template: String,
#[serde(default)]
pub privacy: PrivacySettings,
+
+ // ---- Phase 2: renderer-scoped preferences moved off localStorage ----
+ #[serde(default)]
+ pub appearance: AppearanceSettings,
+ #[serde(default)]
+ pub accessibility: AccessibilitySettings,
+ #[serde(default)]
+ pub window_state: WindowStateSettings,
}
fn default_auto_update() -> String {
@@ -184,6 +257,9 @@ impl Default for AppSettings {
terminal: TerminalSettings::default(),
commit_message_template: default_commit_message_template(),
privacy: PrivacySettings::default(),
+ appearance: AppearanceSettings::default(),
+ accessibility: AccessibilitySettings::default(),
+ window_state: WindowStateSettings::default(),
}
}
}
diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs
index 15e1f11..e708d9b 100644
--- a/app/src-tauri/src/lib.rs
+++ b/app/src-tauri/src/lib.rs
@@ -468,14 +468,27 @@ pub fn run() {
#[cfg(target_os = "macos")]
{
use tauri::TitleBarStyle;
+ use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
if let Some(window) = handle.get_webview_window("main") {
let _ = window.set_decorations(true);
let _ = window.set_title_bar_style(TitleBarStyle::Overlay);
+ // Vibrancy is applied unconditionally — the Glassy theme makes
+ // the React surfaces translucent so it shows through; opaque
+ // themes simply cover it.
+ let _ = apply_vibrancy(&window, NSVisualEffectMaterial::HudWindow, None, None);
}
set_macos_app_icon();
observe_macos_appearance();
}
+ #[cfg(target_os = "windows")]
+ {
+ use window_vibrancy::apply_acrylic;
+ if let Some(window) = handle.get_webview_window("main") {
+ let _ = apply_acrylic(&window, None);
+ }
+ }
+
// Initial Windows icon swap runs AFTER the tray is created
// further down so `apply_windows_theme_icon` finds both the
// webview window and the tray. The tray block below calls it.
diff --git a/app/src-tauri/tauri.macos.conf.json b/app/src-tauri/tauri.macos.conf.json
index 24def0c..88a1a6e 100644
--- a/app/src-tauri/tauri.macos.conf.json
+++ b/app/src-tauri/tauri.macos.conf.json
@@ -15,7 +15,7 @@
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
- "trafficLightPosition": { "x": 14, "y": 14 },
+ "trafficLightPosition": { "x": 14, "y": 21 },
"dragDropEnabled": true,
"acceptFirstMouse": true
}
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 3a994e1..6419a78 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -1,36 +1,39 @@
-import { Navigate, Route, Routes } from "react-router-dom";
+import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AppRoute } from "@recrest/shared";
-import { AppShell } from "@/components/organisms/layout/AppShell";
-import { ActivityPage } from "@/pages/ActivityPage";
-import { BranchesPage } from "@/pages/BranchesPage";
-import { DashboardPage } from "@/pages/DashboardPage";
-import { MergeRequestsPage } from "@/pages/MergeRequestsPage";
-import { RepoDetailPage } from "@/pages/RepoDetailPage";
-import { ReposPage } from "@/pages/ReposPage";
-import { SettingsPage } from "@/pages/SettingsPage";
+import { AppLayout } from "@/layouts/AppLayout";
+import ActivityPage from "@/pages/app/Activity";
+import BranchesPage from "@/pages/app/Branches";
+import ChangesPage from "@/pages/app/Changes";
+import DashboardPage from "@/pages/app/Dashboard";
+import MergeRequestsPage from "@/pages/app/MergeRequests";
+import RepoDetailPage from "@/pages/app/RepoDetail";
+import ReposPage from "@/pages/app/Repos";
+import SettingsPage from "@/pages/app/Settings";
export default function App() {
return (
-
+
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- {/* Legacy path — keep working until deep links settle. */}
- }
- />
- } />
- } />
- } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+
+ } />
-
+
);
}
diff --git a/app/src/Welcome.stories.tsx b/app/src/Welcome.stories.tsx
deleted file mode 100644
index d03fc7a..0000000
--- a/app/src/Welcome.stories.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-function Welcome() {
- return (
-
-
Recrest Component Library
-
Browse atoms, molecules, and organisms via the sidebar.
-
- );
-}
-
-const meta: Meta = {
- title: "Welcome",
- component: Welcome,
-};
-
-export default meta;
-
-export const Default: StoryObj = {};
diff --git a/app/src/assets/icons/BrandIcon/index.tsx b/app/src/assets/icons/BrandIcon/index.tsx
new file mode 100644
index 0000000..8c532e8
--- /dev/null
+++ b/app/src/assets/icons/BrandIcon/index.tsx
@@ -0,0 +1,32 @@
+import { type SVGProps } from "react";
+
+import { PROVIDER_BRAND_ICONS, type ProviderId } from "@/lib/constants/providers.constants";
+
+interface BrandIconProps extends Omit, "name"> {
+ slug: ProviderId;
+ size?: number;
+ color?: "currentColor" | "brand" | string;
+ title?: string;
+}
+
+function BrandIcon({ slug, size = 16, color = "currentColor", title, ...rest }: BrandIconProps) {
+ const icon = PROVIDER_BRAND_ICONS[slug];
+ const fill = color === "brand" ? `#${icon.hex}` : color;
+ return (
+
+
+
+ );
+}
+
+export default BrandIcon;
diff --git a/app/src/assets/icons/IdeIcon/index.tsx b/app/src/assets/icons/IdeIcon/index.tsx
new file mode 100644
index 0000000..bf504f0
--- /dev/null
+++ b/app/src/assets/icons/IdeIcon/index.tsx
@@ -0,0 +1,84 @@
+import { type CSSProperties, type FC, type SVGProps } from "react";
+
+import { siCursor } from "simple-icons";
+
+import IntellijIdeaLogo from "@/assets/icons/ides/intellij-idea.svg?react";
+import JetbrainsLogo from "@/assets/icons/ides/jetbrains.svg?react";
+import VSCodeLogo from "@/assets/icons/ides/visual-studio-code.svg?react";
+import WebstormLogo from "@/assets/icons/ides/webstorm.svg?react";
+import { IDE_UI, type IdeId, type IdeLogoSlug } from "@/lib/constants/ides.constants";
+
+/**
+ * Official IDE logos inlined as React SVG components (via vite-plugin-svgr).
+ * No runtime CDN fetch — required for Tauri's strict CSP. Cursor stays inline
+ * from `simple-icons`. VS Code Insiders reuses the VS Code mark with a
+ * hue-rotate filter (their visual differentiation in marketing material) —
+ * the filter degree comes from `IDE_UI`.
+ */
+const LOGO_COMPONENTS: Partial>>> = {
+ vscode: VSCodeLogo,
+ webstorm: WebstormLogo,
+ intellij: IntellijIdeaLogo,
+ "jetbrains-toolbox": JetbrainsLogo,
+};
+
+interface IdeIconProps {
+ id: IdeId;
+ size?: number;
+ /** `"brand"` keeps official colours, `"currentColor"` greys out for disabled rows. */
+ color?: "brand" | "currentColor";
+ title?: string;
+ style?: CSSProperties;
+}
+
+function IdeIcon({ id, size = 16, color = "brand", title, style }: IdeIconProps) {
+ const mono = color === "currentColor";
+ const ui = IDE_UI[id];
+
+ if (ui.logo === "cursor") {
+ const fill = mono ? "currentColor" : `#${siCursor.hex}`;
+ return (
+
+
+
+ );
+ }
+
+ const LogoComponent = LOGO_COMPONENTS[ui.logo];
+ if (!LogoComponent) return null;
+
+ const filterParts: string[] = [];
+ if (mono) filterParts.push("grayscale(1)");
+ if (ui.filterHue !== null) filterParts.push(`hue-rotate(${ui.filterHue}deg)`, "saturate(0.9)");
+
+ const iconStyle: CSSProperties = {
+ flexShrink: 0,
+ ...(filterParts.length > 0 ? { filter: filterParts.join(" ") } : null),
+ ...(mono ? { opacity: 0.55 } : null),
+ ...style,
+ };
+
+ return (
+
+ );
+}
+
+export default IdeIcon;
diff --git a/app/src/assets/icons/ShellIcon/index.tsx b/app/src/assets/icons/ShellIcon/index.tsx
new file mode 100644
index 0000000..6d35784
--- /dev/null
+++ b/app/src/assets/icons/ShellIcon/index.tsx
@@ -0,0 +1,90 @@
+import { type CSSProperties, type FC, type SVGProps } from "react";
+
+import type { ShellId } from "@recrest/shared";
+
+import { Terminal as LucideTerminal } from "lucide-react";
+import { siFishshell, siGitforwindows, siGnubash, siNushell, siZsh } from "simple-icons";
+
+import CmdLogo from "@/assets/icons/shells/cmd.svg?react";
+import PowershellCoreLogo from "@/assets/icons/shells/powershell-core.svg?react";
+import WindowsPowershellLogo from "@/assets/icons/shells/windows-powershell.svg?react";
+import WslLogo from "@/assets/icons/shells/wsl.svg?react";
+
+/**
+ * Brand marks for every shell Recrest knows about. Same shape as
+ * `TerminalIcon`: vendored `.svg?react` for Microsoft / WSL marks
+ * (no simple-icons entry), `simple-icons` for everything else, Lucide
+ * terminal glyph as last-resort fallback for obscure shells.
+ */
+
+type SimpleIcon = { hex: string; path: string; title: string };
+
+const SI_MARK: Partial> = {
+ zsh: siZsh as SimpleIcon,
+ bash: siGnubash as SimpleIcon,
+ fish: siFishshell as SimpleIcon,
+ nu: siNushell as SimpleIcon,
+ "git-bash": siGitforwindows as SimpleIcon,
+};
+
+const VENDOR_LOGO: Partial>>> = {
+ "powershell-core": PowershellCoreLogo,
+ "windows-powershell": WindowsPowershellLogo,
+ cmd: CmdLogo,
+ wsl: WslLogo,
+};
+
+interface ShellIconProps {
+ id: ShellId;
+ size?: number;
+ color?: "brand" | "currentColor";
+ title?: string;
+ style?: CSSProperties;
+}
+
+function ShellIcon({ id, size = 16, color = "brand", title, style }: ShellIconProps) {
+ const mono = color === "currentColor";
+ const baseStyle: CSSProperties = {
+ flexShrink: 0,
+ ...(mono ? { opacity: 0.55 } : null),
+ ...style,
+ };
+
+ const VendorLogo = VENDOR_LOGO[id];
+ if (VendorLogo) {
+ return (
+
+ );
+ }
+
+ const si = SI_MARK[id];
+ if (si) {
+ const fill = mono ? "currentColor" : `#${si.hex}`;
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
+
+export default ShellIcon;
diff --git a/app/src/assets/icons/TerminalIcon/index.tsx b/app/src/assets/icons/TerminalIcon/index.tsx
new file mode 100644
index 0000000..a0c51e5
--- /dev/null
+++ b/app/src/assets/icons/TerminalIcon/index.tsx
@@ -0,0 +1,111 @@
+import { type CSSProperties, type FC, type SVGProps } from "react";
+
+import type { TerminalId } from "@recrest/shared";
+
+import { Terminal as LucideTerminal } from "lucide-react";
+import {
+ siAlacritty,
+ siGhostty,
+ siGnometerminal,
+ siHyper,
+ siIterm2,
+ siWarp,
+ siWezterm,
+} from "simple-icons";
+
+import AppleTerminalLogo from "@/assets/icons/terminals/apple-terminal.svg?react";
+import CmdLogo from "@/assets/icons/terminals/cmd.svg?react";
+import KittyLogo from "@/assets/icons/terminals/kitty.svg?react";
+import KonsoleLogo from "@/assets/icons/terminals/konsole.svg?react";
+import PowershellLogo from "@/assets/icons/terminals/powershell.svg?react";
+import TilixLogo from "@/assets/icons/terminals/tilix.svg?react";
+import WindowsTerminalLogo from "@/assets/icons/terminals/windows-terminal.svg?react";
+import XtermLogo from "@/assets/icons/terminals/xterm.svg?react";
+
+/**
+ * Brand marks for every terminal emulator Recrest knows about. Mirrors the
+ * `IdeIcon` pattern: vendored `.svg?react` assets for marks not in
+ * simple-icons (Microsoft products, kitty, KDE, etc.), `simple-icons` for the
+ * rest. Falls back to a generic Lucide terminal glyph for anything obscure
+ * we haven't authored an asset for yet — this should never actually fire
+ * given the current `TERMINAL_IDS` set, but it keeps the component honest.
+ */
+
+type SimpleIcon = { hex: string; path: string; title: string };
+
+const SI_MARK: Partial> = {
+ iterm2: siIterm2 as SimpleIcon,
+ warp: siWarp as SimpleIcon,
+ wezterm: siWezterm as SimpleIcon,
+ alacritty: siAlacritty as SimpleIcon,
+ hyper: siHyper as SimpleIcon,
+ ghostty: siGhostty as SimpleIcon,
+ "gnome-terminal": siGnometerminal as SimpleIcon,
+};
+
+const VENDOR_LOGO: Partial>>> = {
+ "apple-terminal": AppleTerminalLogo,
+ "windows-terminal": WindowsTerminalLogo,
+ powershell: PowershellLogo,
+ cmd: CmdLogo,
+ kitty: KittyLogo,
+ konsole: KonsoleLogo,
+ xterm: XtermLogo,
+ tilix: TilixLogo,
+};
+
+interface TerminalIconProps {
+ id: TerminalId;
+ size?: number;
+ /** `"brand"` keeps official colours, `"currentColor"` greys for disabled rows. */
+ color?: "brand" | "currentColor";
+ title?: string;
+ style?: CSSProperties;
+}
+
+function TerminalIcon({ id, size = 16, color = "brand", title, style }: TerminalIconProps) {
+ const mono = color === "currentColor";
+ const baseStyle: CSSProperties = {
+ flexShrink: 0,
+ ...(mono ? { opacity: 0.55 } : null),
+ ...style,
+ };
+
+ const VendorLogo = VENDOR_LOGO[id];
+ if (VendorLogo) {
+ return (
+
+ );
+ }
+
+ const si = SI_MARK[id];
+ if (si) {
+ const fill = mono ? "currentColor" : `#${si.hex}`;
+ return (
+
+
+
+ );
+ }
+
+ return ;
+}
+
+export default TerminalIcon;
diff --git a/app/src/components/atoms/IdeIcon/logos/intellij-idea.svg b/app/src/assets/icons/ides/intellij-idea.svg
similarity index 100%
rename from app/src/components/atoms/IdeIcon/logos/intellij-idea.svg
rename to app/src/assets/icons/ides/intellij-idea.svg
diff --git a/app/src/components/atoms/IdeIcon/logos/jetbrains.svg b/app/src/assets/icons/ides/jetbrains.svg
similarity index 100%
rename from app/src/components/atoms/IdeIcon/logos/jetbrains.svg
rename to app/src/assets/icons/ides/jetbrains.svg
diff --git a/app/src/components/atoms/IdeIcon/logos/visual-studio-code.svg b/app/src/assets/icons/ides/visual-studio-code.svg
similarity index 100%
rename from app/src/components/atoms/IdeIcon/logos/visual-studio-code.svg
rename to app/src/assets/icons/ides/visual-studio-code.svg
diff --git a/app/src/components/atoms/IdeIcon/logos/webstorm.svg b/app/src/assets/icons/ides/webstorm.svg
similarity index 100%
rename from app/src/components/atoms/IdeIcon/logos/webstorm.svg
rename to app/src/assets/icons/ides/webstorm.svg
diff --git a/app/src/assets/icons/shells/cmd.svg b/app/src/assets/icons/shells/cmd.svg
new file mode 100644
index 0000000..40c3c93
--- /dev/null
+++ b/app/src/assets/icons/shells/cmd.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/shells/powershell-core.svg b/app/src/assets/icons/shells/powershell-core.svg
new file mode 100644
index 0000000..6df77c5
--- /dev/null
+++ b/app/src/assets/icons/shells/powershell-core.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/shells/windows-powershell.svg b/app/src/assets/icons/shells/windows-powershell.svg
new file mode 100644
index 0000000..37718dd
--- /dev/null
+++ b/app/src/assets/icons/shells/windows-powershell.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/shells/wsl.svg b/app/src/assets/icons/shells/wsl.svg
new file mode 100644
index 0000000..9fce8e3
--- /dev/null
+++ b/app/src/assets/icons/shells/wsl.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/apple-terminal.svg b/app/src/assets/icons/terminals/apple-terminal.svg
new file mode 100644
index 0000000..6095048
--- /dev/null
+++ b/app/src/assets/icons/terminals/apple-terminal.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/cmd.svg b/app/src/assets/icons/terminals/cmd.svg
new file mode 100644
index 0000000..40c3c93
--- /dev/null
+++ b/app/src/assets/icons/terminals/cmd.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/kitty.svg b/app/src/assets/icons/terminals/kitty.svg
new file mode 100644
index 0000000..cebcd7c
--- /dev/null
+++ b/app/src/assets/icons/terminals/kitty.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/konsole.svg b/app/src/assets/icons/terminals/konsole.svg
new file mode 100644
index 0000000..e43f21c
--- /dev/null
+++ b/app/src/assets/icons/terminals/konsole.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/powershell.svg b/app/src/assets/icons/terminals/powershell.svg
new file mode 100644
index 0000000..6b34c32
--- /dev/null
+++ b/app/src/assets/icons/terminals/powershell.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/tilix.svg b/app/src/assets/icons/terminals/tilix.svg
new file mode 100644
index 0000000..dad7bc7
--- /dev/null
+++ b/app/src/assets/icons/terminals/tilix.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/windows-terminal.svg b/app/src/assets/icons/terminals/windows-terminal.svg
new file mode 100644
index 0000000..b3e0ab5
--- /dev/null
+++ b/app/src/assets/icons/terminals/windows-terminal.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/icons/terminals/xterm.svg b/app/src/assets/icons/terminals/xterm.svg
new file mode 100644
index 0000000..33c0322
--- /dev/null
+++ b/app/src/assets/icons/terminals/xterm.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/assets/recrest-icon-dark.svg b/app/src/assets/logos/recrest-icon-dark.svg
similarity index 100%
rename from app/src/assets/recrest-icon-dark.svg
rename to app/src/assets/logos/recrest-icon-dark.svg
diff --git a/app/src/assets/recrest-icon-dev-dark.svg b/app/src/assets/logos/recrest-icon-dev-dark.svg
similarity index 100%
rename from app/src/assets/recrest-icon-dev-dark.svg
rename to app/src/assets/logos/recrest-icon-dev-dark.svg
diff --git a/app/src/assets/recrest-icon-dev-light.svg b/app/src/assets/logos/recrest-icon-dev-light.svg
similarity index 100%
rename from app/src/assets/recrest-icon-dev-light.svg
rename to app/src/assets/logos/recrest-icon-dev-light.svg
diff --git a/app/src/assets/recrest-icon-dev.svg b/app/src/assets/logos/recrest-icon-dev.svg
similarity index 75%
rename from app/src/assets/recrest-icon-dev.svg
rename to app/src/assets/logos/recrest-icon-dev.svg
index 1183b44..44570ce 100644
--- a/app/src/assets/recrest-icon-dev.svg
+++ b/app/src/assets/logos/recrest-icon-dev.svg
@@ -1,7 +1,7 @@
Recrest — Dev build
- Recrest icon, development variant: white chevrons with an orange </> badge in the lower-right corner.
-
+ Recrest icon, development variant: transparent background, orange chevrons, orange </> badge in the lower-right corner.
+
diff --git a/app/src/assets/recrest-icon-light.svg b/app/src/assets/logos/recrest-icon-light.svg
similarity index 100%
rename from app/src/assets/recrest-icon-light.svg
rename to app/src/assets/logos/recrest-icon-light.svg
diff --git a/app/src/assets/recrest-icon-transparent-dark.svg b/app/src/assets/logos/recrest-icon-transparent-dark.svg
similarity index 100%
rename from app/src/assets/recrest-icon-transparent-dark.svg
rename to app/src/assets/logos/recrest-icon-transparent-dark.svg
diff --git a/app/src/assets/recrest-icon-transparent-white.svg b/app/src/assets/logos/recrest-icon-transparent-white.svg
similarity index 100%
rename from app/src/assets/recrest-icon-transparent-white.svg
rename to app/src/assets/logos/recrest-icon-transparent-white.svg
diff --git a/app/src/components/atoms/AheadBehind/AheadBehind.stories.tsx b/app/src/components/atoms/AheadBehind/AheadBehind.stories.tsx
deleted file mode 100644
index a3eefb2..0000000
--- a/app/src/components/atoms/AheadBehind/AheadBehind.stories.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { AheadBehind } from "@/components/atoms/AheadBehind";
-
-const meta: Meta = {
- title: "Atoms/AheadBehind",
- component: AheadBehind,
-};
-
-export default meta;
-
-export const Even: StoryObj = { args: { ahead: 0, behind: 0 } };
-export const Ahead: StoryObj = { args: { ahead: 3, behind: 0 } };
-export const Behind: StoryObj = { args: { ahead: 0, behind: 2 } };
-export const Diverged: StoryObj = { args: { ahead: 3, behind: 2 } };
diff --git a/app/src/components/atoms/AheadBehind/AheadBehind.test.tsx b/app/src/components/atoms/AheadBehind/AheadBehind.test.tsx
deleted file mode 100644
index cbaa90f..0000000
--- a/app/src/components/atoms/AheadBehind/AheadBehind.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { AheadBehind } from "@/components/atoms/AheadBehind";
-
-describe("AheadBehind", () => {
- it("rendert ohne Crash bei leerem Stand", () => {
- const { container } = render( );
- expect(container.textContent).toContain("↕ 0");
- });
-
- it("gibt im compact Mode bei gleichem Stand null zurück", () => {
- const { container } = render( );
- expect(container.firstChild).toBeNull();
- });
-
- it("zeigt ahead-Zahl mit Aufwärtspfeil", () => {
- render( );
- expect(screen.getByText("↑3")).toBeInTheDocument();
- });
-
- it("zeigt behind-Zahl mit Abwärtspfeil", () => {
- render( );
- expect(screen.getByText("↓5")).toBeInTheDocument();
- });
-
- it("zeigt beide Zahlen wenn divergent", () => {
- render( );
- expect(screen.getByText("↑2")).toBeInTheDocument();
- expect(screen.getByText("↓4")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/atoms/AheadBehind/index.tsx b/app/src/components/atoms/AheadBehind/index.tsx
deleted file mode 100644
index 45b9041..0000000
--- a/app/src/components/atoms/AheadBehind/index.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-interface AheadBehindProps {
- ahead: number;
- behind: number;
- compact?: boolean;
-}
-
-/** Branch ahead/behind counter chip. Returns null in compact mode when the
- * branch is even with its upstream; otherwise shows a grey "↕ 0" pill. */
-export function AheadBehind({ ahead, behind, compact }: AheadBehindProps) {
- if (!ahead && !behind) {
- return compact ? null : (
-
- ↕ 0
-
- );
- }
- return (
-
- {ahead > 0 && ↑{ahead} }
- {behind > 0 && ↓{behind} }
-
- );
-}
diff --git a/app/src/components/atoms/Badge/Badge.stories.tsx b/app/src/components/atoms/Badge/Badge.stories.tsx
deleted file mode 100644
index 1746baa..0000000
--- a/app/src/components/atoms/Badge/Badge.stories.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Badge } from "@/components/atoms/Badge";
-
-const meta: Meta = {
- title: "Atoms/Badge",
- component: Badge,
-};
-
-export default meta;
-
-export const Default: StoryObj = { args: { children: "Badge" } };
-export const Secondary: StoryObj = {
- args: { children: "Secondary", variant: "secondary" },
-};
-export const Destructive: StoryObj = {
- args: { children: "Destructive", variant: "destructive" },
-};
-export const Outline: StoryObj = {
- args: { children: "Outline", variant: "outline" },
-};
diff --git a/app/src/components/atoms/Badge/Badge.test.tsx b/app/src/components/atoms/Badge/Badge.test.tsx
deleted file mode 100644
index 6793184..0000000
--- a/app/src/components/atoms/Badge/Badge.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Badge } from "@/components/atoms/Badge";
-
-describe("Badge", () => {
- it("rendert Kind-Inhalt", () => {
- render(Neu );
- expect(screen.getByText("Neu")).toBeInTheDocument();
- });
-
- it("nutzt die default-Variante ohne Prop", () => {
- render(default );
- expect(screen.getByText("default").className).toContain("bg-primary");
- });
-
- it("wendet die outline-Variante an", () => {
- render(outline );
- expect(screen.getByText("outline").className).toContain("border-border");
- });
-
- it("wendet die success-Variante an", () => {
- render(ok );
- expect(screen.getByText("ok").className).toContain("text-status-success");
- });
-
- it("merged zusätzliche className", () => {
- render(x );
- expect(screen.getByText("x").className).toContain("custom-class");
- });
-});
diff --git a/app/src/components/atoms/Badge/index.tsx b/app/src/components/atoms/Badge/index.tsx
deleted file mode 100644
index 7ebbe9d..0000000
--- a/app/src/components/atoms/Badge/index.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import { type ComponentProps } from "react";
-
-import { type VariantProps, cva } from "class-variance-authority";
-
-import { cn } from "@/lib/utils";
-
-const badgeVariants = cva(
- "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
- {
- variants: {
- variant: {
- default: "border-transparent bg-primary text-primary-foreground",
- secondary: "border-transparent bg-secondary text-secondary-foreground",
- outline: "border-border text-foreground",
- muted: "border-transparent bg-muted text-muted-foreground",
- success: "border-transparent bg-status-success/15 text-status-success",
- warning: "border-transparent bg-status-warning/15 text-status-warning",
- destructive: "border-transparent bg-status-error/15 text-status-error",
- info: "border-transparent bg-status-info/15 text-status-info",
- },
- size: {
- sm: "px-1.5 py-0 text-[10px]",
- md: "px-2 py-0.5 text-xs",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "md",
- },
- },
-);
-
-export interface BadgeProps extends ComponentProps<"span">, VariantProps {}
-
-export function Badge({ className, variant, size, ...props }: BadgeProps) {
- return ;
-}
-
-// eslint-disable-next-line react-refresh/only-export-components
-export { badgeVariants };
diff --git a/app/src/components/atoms/BranchChip/BranchChip.stories.tsx b/app/src/components/atoms/BranchChip/BranchChip.stories.tsx
deleted file mode 100644
index 7616e5c..0000000
--- a/app/src/components/atoms/BranchChip/BranchChip.stories.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { BranchChip } from "@/components/atoms/BranchChip";
-
-const meta: Meta = {
- title: "Atoms/BranchChip",
- component: BranchChip,
-};
-
-export default meta;
-
-export const Main: StoryObj = { args: { branch: "main" } };
-export const Feature: StoryObj = {
- args: { branch: "feature/new-thing" },
-};
-export const Small: StoryObj = {
- args: { branch: "main", size: "sm" },
-};
-export const Big: StoryObj = {
- args: { branch: "main", size: "big" },
-};
diff --git a/app/src/components/atoms/BranchChip/BranchChip.test.tsx b/app/src/components/atoms/BranchChip/BranchChip.test.tsx
deleted file mode 100644
index f3df3d7..0000000
--- a/app/src/components/atoms/BranchChip/BranchChip.test.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { BranchChip } from "@/components/atoms/BranchChip";
-
-describe("BranchChip", () => {
- it("rendert Branch-Namen", () => {
- render( );
- expect(screen.getByText("feature/login")).toBeInTheDocument();
- });
-
- it("hat keine Größenklasse bei md (default)", () => {
- const { container } = render( );
- const chip = container.querySelector(".a-branch-chip");
- expect(chip?.className).toBe("a-branch-chip");
- });
-
- it("fügt sm-Klasse bei size='sm' hinzu", () => {
- const { container } = render( );
- expect(container.querySelector(".a-branch-chip.sm")).not.toBeNull();
- });
-
- it("fügt big-Klasse bei size='big' hinzu", () => {
- const { container } = render( );
- expect(container.querySelector(".a-branch-chip.big")).not.toBeNull();
- });
-
- it("rendert ein SVG-Icon neben dem Namen", () => {
- const { container } = render( );
- expect(container.querySelector("svg")).not.toBeNull();
- });
-});
diff --git a/app/src/components/atoms/BranchChip/index.tsx b/app/src/components/atoms/BranchChip/index.tsx
deleted file mode 100644
index d8dd0ae..0000000
--- a/app/src/components/atoms/BranchChip/index.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Icon } from "@/components/atoms/Icon";
-
-interface BranchChipProps {
- branch: string;
- size?: "sm" | "md" | "big";
-}
-
-/** Rounded pill showing a branch name with the branch icon on the left.
- * Three visual sizes mirror the usage across dashboard (big), row lists
- * (md) and compact inline chips (sm). */
-export function BranchChip({ branch, size = "md" }: BranchChipProps) {
- const cls = `a-branch-chip${size === "sm" ? " sm" : size === "big" ? " big" : ""}`;
- const iconSize = size === "sm" ? 10 : size === "big" ? 12 : 11;
- return (
-
-
- {branch}
-
- );
-}
diff --git a/app/src/components/atoms/BrandIcon/BrandIcon.stories.tsx b/app/src/components/atoms/BrandIcon/BrandIcon.stories.tsx
deleted file mode 100644
index 6006e90..0000000
--- a/app/src/components/atoms/BrandIcon/BrandIcon.stories.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { BrandIcon } from "@/components/atoms/BrandIcon";
-
-const meta: Meta = {
- title: "Atoms/BrandIcon",
- component: BrandIcon,
- args: { size: 32, color: "brand" },
-};
-
-export default meta;
-
-export const GitHub: StoryObj = { args: { slug: "github" } };
-export const GitLab: StoryObj = { args: { slug: "gitlab" } };
-export const Bitbucket: StoryObj = { args: { slug: "bitbucket" } };
-export const Monochrome: StoryObj = {
- args: { slug: "github", color: "currentColor" },
-};
diff --git a/app/src/components/atoms/BrandIcon/BrandIcon.test.tsx b/app/src/components/atoms/BrandIcon/BrandIcon.test.tsx
deleted file mode 100644
index 02b8850..0000000
--- a/app/src/components/atoms/BrandIcon/BrandIcon.test.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { BrandIcon } from "@/components/atoms/BrandIcon";
-
-describe("BrandIcon", () => {
- it("rendert ohne Crash mit slug=github", () => {
- render( );
- expect(screen.getByRole("img")).toBeInTheDocument();
- });
-
- it("nimmt die Default-Größe 16", () => {
- const { container } = render( );
- const svg = container.querySelector("svg");
- expect(svg?.getAttribute("width")).toBe("16");
- expect(svg?.getAttribute("height")).toBe("16");
- });
-
- it("wendet custom size an", () => {
- const { container } = render( );
- expect(container.querySelector("svg")?.getAttribute("width")).toBe("32");
- });
-
- it("nutzt currentColor per Default", () => {
- const { container } = render( );
- expect(container.querySelector("svg")?.getAttribute("fill")).toBe("currentColor");
- });
-
- it("setzt Brand-Farbe bei color='brand'", () => {
- const { container } = render( );
- const fill = container.querySelector("svg")?.getAttribute("fill");
- expect(fill).toMatch(/^#[0-9a-fA-F]{3,8}$/);
- });
-
- it("nutzt title-Prop als aria-label", () => {
- render( );
- expect(screen.getByLabelText("My GitHub")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/atoms/BrandIcon/index.tsx b/app/src/components/atoms/BrandIcon/index.tsx
deleted file mode 100644
index ecf149a..0000000
--- a/app/src/components/atoms/BrandIcon/index.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { type SVGProps } from "react";
-
-import { type SimpleIcon, siBitbucket, siGithub, siGitlab } from "simple-icons";
-
-import { cn } from "@/lib/utils";
-
-export type BrandSlug = "github" | "gitlab" | "bitbucket";
-
-const BRAND_ICONS: Record = {
- github: siGithub,
- gitlab: siGitlab,
- bitbucket: siBitbucket,
-};
-
-interface BrandIconProps extends Omit, "name"> {
- slug: BrandSlug;
- size?: number;
- /** `"currentColor"` (default) picks up surrounding text colour;
- * `"brand"` uses Simple Icons' official hex. */
- color?: "currentColor" | "brand" | string;
- title?: string;
-}
-
-export function BrandIcon({
- slug,
- size = 16,
- color = "currentColor",
- title,
- className,
- ...rest
-}: BrandIconProps) {
- const icon = BRAND_ICONS[slug];
- const fill = color === "brand" ? `#${icon.hex}` : color;
- return (
-
-
-
- );
-}
-
-/** Best-effort mapping from a remote URL's host to the matching Simple Icon.
- * Self-hosted instances fall back to `null` — caller decides whether to
- * render a generic glyph or nothing. */
-// eslint-disable-next-line react-refresh/only-export-components
-export function brandFromUrl(url: string | null | undefined): BrandSlug | null {
- if (!url) return null;
- const rest = url.startsWith("git@")
- ? url.slice(4).split(":")[0]
- : (url.split("://")[1] ?? url).split("@").pop()?.split(/[/:]/)[0];
- const host = rest?.toLowerCase() ?? "";
- if (host.endsWith("github.com")) return "github";
- if (host.endsWith("gitlab.com")) return "gitlab";
- if (host.endsWith("bitbucket.org")) return "bitbucket";
- return null;
-}
diff --git a/app/src/components/atoms/Button/Button.stories.tsx b/app/src/components/atoms/Button/Button.stories.tsx
deleted file mode 100644
index 00ddb03..0000000
--- a/app/src/components/atoms/Button/Button.stories.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Button } from "@/components/atoms/Button";
-
-const meta: Meta = {
- title: "Atoms/Button",
- component: Button,
-};
-
-export default meta;
-
-export const Default: StoryObj = { args: { children: "Click me" } };
-export const Outline: StoryObj = {
- args: { children: "Outline", variant: "outline" },
-};
-export const Secondary: StoryObj = {
- args: { children: "Secondary", variant: "secondary" },
-};
-export const Ghost: StoryObj = {
- args: { children: "Ghost", variant: "ghost" },
-};
-export const Destructive: StoryObj = {
- args: { children: "Destructive", variant: "destructive" },
-};
-export const Link: StoryObj = {
- args: { children: "Link", variant: "link" },
-};
diff --git a/app/src/components/atoms/Button/Button.test.tsx b/app/src/components/atoms/Button/Button.test.tsx
deleted file mode 100644
index 297fd9f..0000000
--- a/app/src/components/atoms/Button/Button.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { Button } from "@/components/atoms/Button";
-
-describe("Button", () => {
- it("rendert Label", () => {
- render(Klick mich );
- expect(screen.getByRole("button", { name: "Klick mich" })).toBeInTheDocument();
- });
-
- it("wendet die outline-Variante an", () => {
- render(x );
- expect(screen.getByRole("button").className).toContain("border-input");
- });
-
- it("ruft onClick bei Klick auf", async () => {
- const user = userEvent.setup();
- const handler = vi.fn();
- render(ok );
- await user.click(screen.getByRole("button"));
- expect(handler).toHaveBeenCalledTimes(1);
- });
-
- it("ist disabled während loading", () => {
- render(loading );
- expect(screen.getByRole("button")).toBeDisabled();
- });
-
- it("ruft onClick nicht auf wenn disabled", async () => {
- const user = userEvent.setup();
- const handler = vi.fn();
- render(
-
- x
- ,
- );
- await user.click(screen.getByRole("button"));
- expect(handler).not.toHaveBeenCalled();
- });
-});
diff --git a/app/src/components/atoms/Button/index.tsx b/app/src/components/atoms/Button/index.tsx
deleted file mode 100644
index b741cd4..0000000
--- a/app/src/components/atoms/Button/index.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import { type ComponentProps } from "react";
-
-import { Slot } from "@radix-ui/react-slot";
-import { type VariantProps, cva } from "class-variance-authority";
-import { Loader2 } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-const buttonVariants = cva(
- "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
- destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
- outline:
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
- secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
- ghost: "hover:bg-accent hover:text-accent-foreground",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
- lg: "h-10 rounded-md px-6",
- icon: "h-9 w-9",
- "icon-sm": "h-8 w-8",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-);
-
-export interface ButtonProps extends ComponentProps<"button">, VariantProps {
- asChild?: boolean;
- loading?: boolean;
-}
-
-export function Button({
- className,
- variant,
- size,
- asChild = false,
- loading = false,
- disabled,
- children,
- ...props
-}: ButtonProps) {
- const Comp = asChild ? Slot : "button";
- return (
-
- {loading ? : null}
- {children}
-
- );
-}
-
-// eslint-disable-next-line react-refresh/only-export-components
-export { buttonVariants };
diff --git a/app/src/components/atoms/Checkbox/Checkbox.stories.tsx b/app/src/components/atoms/Checkbox/Checkbox.stories.tsx
deleted file mode 100644
index 69ffeff..0000000
--- a/app/src/components/atoms/Checkbox/Checkbox.stories.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Checkbox } from "@/components/atoms/Checkbox";
-
-const meta: Meta = {
- title: "Atoms/Checkbox",
- component: Checkbox,
-};
-
-export default meta;
-
-export const Unchecked: StoryObj = { args: { checked: false } };
-export const Checked: StoryObj = { args: { checked: true } };
-export const Indeterminate: StoryObj = { args: { checked: "indeterminate" } };
-export const Disabled: StoryObj = { args: { disabled: true } };
-export const DisabledChecked: StoryObj = {
- args: { disabled: true, checked: true },
-};
diff --git a/app/src/components/atoms/Checkbox/Checkbox.test.tsx b/app/src/components/atoms/Checkbox/Checkbox.test.tsx
deleted file mode 100644
index 339be86..0000000
--- a/app/src/components/atoms/Checkbox/Checkbox.test.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { Checkbox } from "@/components/atoms/Checkbox";
-
-describe("Checkbox", () => {
- it("rendert mit role=checkbox", () => {
- render( );
- expect(screen.getByRole("checkbox")).toBeInTheDocument();
- });
-
- it("ist ungecheckt per Default", () => {
- render( );
- expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", "unchecked");
- });
-
- it("respektiert defaultChecked", () => {
- render( );
- expect(screen.getByRole("checkbox")).toHaveAttribute("data-state", "checked");
- });
-
- it("ruft onCheckedChange bei Klick auf", async () => {
- const user = userEvent.setup();
- const handler = vi.fn();
- render( );
- await user.click(screen.getByRole("checkbox"));
- expect(handler).toHaveBeenCalledWith(true);
- });
-
- it("ist disabled wenn disabled-Prop gesetzt ist", () => {
- render( );
- expect(screen.getByRole("checkbox")).toBeDisabled();
- });
-});
diff --git a/app/src/components/atoms/Checkbox/index.tsx b/app/src/components/atoms/Checkbox/index.tsx
deleted file mode 100644
index 33ee72a..0000000
--- a/app/src/components/atoms/Checkbox/index.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
-import { Check } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-export function Checkbox({ className, ...props }: ComponentProps) {
- return (
-
-
-
-
-
- );
-}
diff --git a/app/src/components/atoms/CiDot/CiDot.stories.tsx b/app/src/components/atoms/CiDot/CiDot.stories.tsx
deleted file mode 100644
index 1a6b276..0000000
--- a/app/src/components/atoms/CiDot/CiDot.stories.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { CiDot } from "@/components/atoms/CiDot";
-
-const meta: Meta = {
- title: "Atoms/CiDot",
- component: CiDot,
-};
-
-export default meta;
-
-export const Passing: StoryObj = { args: { state: "passing" } };
-export const Failing: StoryObj = { args: { state: "failing" } };
-export const Running: StoryObj = { args: { state: "running" } };
-export const None: StoryObj = { args: { state: null } };
diff --git a/app/src/components/atoms/CiDot/CiDot.test.tsx b/app/src/components/atoms/CiDot/CiDot.test.tsx
deleted file mode 100644
index deaca88..0000000
--- a/app/src/components/atoms/CiDot/CiDot.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { CiDot } from "@/components/atoms/CiDot";
-
-describe("CiDot", () => {
- it("zeigt em-dash bei null-State", () => {
- render( );
- expect(screen.getByText("—")).toBeInTheDocument();
- });
-
- it("zeigt em-dash bei undefined-State", () => {
- render( );
- expect(screen.getByText("—")).toBeInTheDocument();
- });
-
- it("zeigt 'passing' Label bei passing-State", () => {
- render( );
- expect(screen.getByText("passing")).toBeInTheDocument();
- });
-
- it("zeigt 'failing' Label bei failing-State", () => {
- render( );
- expect(screen.getByText("failing")).toBeInTheDocument();
- });
-
- it("zeigt 'running' Label bei running-State", () => {
- render( );
- expect(screen.getByText("running")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/atoms/CiDot/index.tsx b/app/src/components/atoms/CiDot/index.tsx
deleted file mode 100644
index 9cee48f..0000000
--- a/app/src/components/atoms/CiDot/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-export type CiState = "passing" | "failing" | "running" | null | undefined;
-
-interface CiDotProps {
- state: CiState;
-}
-
-const COLORS = {
- passing: { dot: "var(--green)", label: "passing" },
- failing: { dot: "var(--red)", label: "failing" },
- running: { dot: "var(--amber)", label: "running" },
-} as const;
-
-/** Small colored CI-status pill. Grey em-dash when there's no status.
- * Running state gets a soft pulse + halo. */
-export function CiDot({ state }: CiDotProps) {
- if (!state) return — ;
- const m = COLORS[state];
- return (
-
-
- {m.label}
-
- );
-}
diff --git a/app/src/components/atoms/ConfirmDialog/index.tsx b/app/src/components/atoms/ConfirmDialog/index.tsx
deleted file mode 100644
index 1b2c316..0000000
--- a/app/src/components/atoms/ConfirmDialog/index.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { type ReactNode, useCallback, useRef, useState } from "react";
-
-import {
- ConfirmContext,
- type ConfirmFn,
- type ConfirmOptions,
-} from "@/components/atoms/ConfirmDialog/useConfirm";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/molecules/compounds/Dialog";
-
-/**
- * Plan 1 §D.3: shared confirmation-dialog primitive.
- *
- * Wraps the existing Dialog atoms with a promise-based API. Mount
- * `` near the app root, then call `useConfirm()` from any
- * component to open a modal and `await` the user's choice. The hook and
- * context live in `./useConfirm.ts` so this file stays component-only and
- * Fast Refresh keeps working.
- *
- * The provider keeps a single dialog instance, so concurrent calls are
- * serialised — a second `confirm()` resolves the first one as cancelled
- * (`false`) and opens the new dialog. The promise never rejects; callers
- * only need to check the boolean.
- */
-
-interface PendingState {
- opts: ConfirmOptions;
- resolve: (ok: boolean) => void;
-}
-
-export function ConfirmProvider({ children }: { children: ReactNode }) {
- const [pending, setPending] = useState(null);
- const pendingRef = useRef(null);
-
- const confirm = useCallback((opts) => {
- return new Promise((resolve) => {
- // If a previous confirm is still open, resolve it as cancelled. Two
- // dialogs at once would visually stack with no way for the user to
- // address the older one.
- if (pendingRef.current) {
- pendingRef.current.resolve(false);
- }
- const next: PendingState = { opts, resolve };
- pendingRef.current = next;
- setPending(next);
- });
- }, []);
-
- const close = useCallback((ok: boolean) => {
- const current = pendingRef.current;
- pendingRef.current = null;
- setPending(null);
- current?.resolve(ok);
- }, []);
-
- return (
-
- {children}
- {
- // Pressing ESC / clicking outside resolves as cancelled.
- if (!open) close(false);
- }}
- >
-
-
- {pending?.opts.title}
- {pending?.opts.description && (
- {pending.opts.description}
- )}
-
-
- close(false)}
- data-testid="confirm-dialog-cancel"
- >
- {pending?.opts.cancelLabel ?? "Cancel"}
-
- close(true)}
- autoFocus
- data-testid="confirm-dialog-confirm"
- >
- {pending?.opts.confirmLabel ?? "Confirm"}
-
-
-
-
-
- );
-}
diff --git a/app/src/components/atoms/ConfirmDialog/useConfirm.ts b/app/src/components/atoms/ConfirmDialog/useConfirm.ts
deleted file mode 100644
index da1a3c1..0000000
--- a/app/src/components/atoms/ConfirmDialog/useConfirm.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { type ReactNode, createContext, useContext } from "react";
-
-/**
- * Plan 1 §D.3: shared confirmation-dialog primitive — public API.
- *
- * The hook + context live here (instead of next to the provider component)
- * so `index.tsx` can stay component-only, which keeps Fast Refresh
- * predictable. `` remains the entry point; consumers call
- * `useConfirm()` to open the modal and `await` the user's choice:
- *
- * const confirm = useConfirm();
- * if (!(await confirm({ title: "Delete repo?" }))) return;
- */
-
-export interface ConfirmOptions {
- title: ReactNode;
- description?: ReactNode;
- /** Label for the confirm button. Defaults to "Confirm". */
- confirmLabel?: string;
- /** Label for the cancel button. Defaults to "Cancel". */
- cancelLabel?: string;
- /** When true, styles the confirm button with the destructive accent.
- * Defaults to `false`. */
- destructive?: boolean;
-}
-
-export type ConfirmFn = (opts: ConfirmOptions) => Promise;
-
-export const ConfirmContext = createContext(null);
-
-export function useConfirm(): ConfirmFn {
- const fn = useContext(ConfirmContext);
- if (!fn) {
- throw new Error("useConfirm must be used inside ");
- }
- return fn;
-}
diff --git a/app/src/components/atoms/DiffStat/DiffStat.stories.tsx b/app/src/components/atoms/DiffStat/DiffStat.stories.tsx
deleted file mode 100644
index ae51930..0000000
--- a/app/src/components/atoms/DiffStat/DiffStat.stories.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { DiffStat } from "@/components/atoms/DiffStat";
-
-const meta: Meta = {
- title: "Atoms/DiffStat",
- component: DiffStat,
-};
-
-export default meta;
-
-export const Mixed: StoryObj = { args: { added: 42, removed: 17 } };
-export const OnlyAdded: StoryObj = { args: { added: 12, removed: 0 } };
-export const OnlyRemoved: StoryObj = { args: { added: 0, removed: 53 } };
-export const Large: StoryObj = { args: { added: 1248, removed: 932 } };
-/** Returns null, so Storybook shows an empty canvas. */
-export const None: StoryObj = { args: { added: 0, removed: 0 } };
diff --git a/app/src/components/atoms/DiffStat/DiffStat.test.tsx b/app/src/components/atoms/DiffStat/DiffStat.test.tsx
deleted file mode 100644
index bed3f33..0000000
--- a/app/src/components/atoms/DiffStat/DiffStat.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { DiffStat } from "@/components/atoms/DiffStat";
-
-describe("DiffStat", () => {
- it("gibt null zurück ohne Änderungen", () => {
- const { container } = render( );
- expect(container.firstChild).toBeNull();
- });
-
- it("zeigt nur added wenn removed=0", () => {
- render( );
- expect(screen.getByText("+12")).toBeInTheDocument();
- expect(screen.queryByText(/^−/)).toBeNull();
- });
-
- it("zeigt nur removed wenn added=0", () => {
- render( );
- expect(screen.getByText("−5")).toBeInTheDocument();
- expect(screen.queryByText(/^\+/)).toBeNull();
- });
-
- it("zeigt beides wenn added und removed > 0", () => {
- render( );
- expect(screen.getByText("+12")).toBeInTheDocument();
- expect(screen.getByText("−53")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/atoms/DiffStat/index.tsx b/app/src/components/atoms/DiffStat/index.tsx
deleted file mode 100644
index fe959af..0000000
--- a/app/src/components/atoms/DiffStat/index.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-interface DiffStatProps {
- added: number;
- removed: number;
-}
-
-/** Mini "+12 −53" line stat. Returns null when there are no changes. */
-export function DiffStat({ added, removed }: DiffStatProps) {
- if (!added && !removed) return null;
- return (
-
- {added > 0 && +{added} }
- {removed > 0 && −{removed} }
-
- );
-}
diff --git a/app/src/components/atoms/Icon/Icon.stories.tsx b/app/src/components/atoms/Icon/Icon.stories.tsx
deleted file mode 100644
index a536029..0000000
--- a/app/src/components/atoms/Icon/Icon.stories.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Icon } from "@/components/atoms/Icon";
-
-const meta: Meta = {
- title: "Atoms/Icon",
- component: Icon,
- args: { size: 24 },
-};
-
-export default meta;
-
-export const Search: StoryObj = { args: { name: "search" } };
-export const Branch: StoryObj = { args: { name: "branch" } };
-export const Settings: StoryObj = { args: { name: "settings" } };
-export const Scale: StoryObj = { args: { name: "scale" } };
diff --git a/app/src/components/atoms/Icon/Icon.test.tsx b/app/src/components/atoms/Icon/Icon.test.tsx
deleted file mode 100644
index 15f778c..0000000
--- a/app/src/components/atoms/Icon/Icon.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Icon } from "@/components/atoms/Icon";
-
-describe("Icon", () => {
- it("rendert ein SVG", () => {
- const { container } = render( );
- expect(container.querySelector("svg")).not.toBeNull();
- });
-
- it("hat Default-Größe 16", () => {
- const { container } = render( );
- const svg = container.querySelector("svg");
- expect(svg?.getAttribute("width")).toBe("16");
- expect(svg?.getAttribute("height")).toBe("16");
- });
-
- it("wendet custom size an", () => {
- const { container } = render( );
- expect(container.querySelector("svg")?.getAttribute("width")).toBe("24");
- });
-
- it("nutzt currentColor per Default als stroke", () => {
- const { container } = render( );
- expect(container.querySelector("svg")?.getAttribute("stroke")).toBe("currentColor");
- });
-
- it("wendet custom color als stroke an", () => {
- const { container } = render( );
- expect(container.querySelector("svg")?.getAttribute("stroke")).toBe("#ff0000");
- });
-});
diff --git a/app/src/components/atoms/Icon/index.tsx b/app/src/components/atoms/Icon/index.tsx
deleted file mode 100644
index e493309..0000000
--- a/app/src/components/atoms/Icon/index.tsx
+++ /dev/null
@@ -1,298 +0,0 @@
-import type { ReactElement, SVGProps } from "react";
-
-import { cn } from "@/lib/utils";
-
-export type IconName =
- | "search"
- | "plus"
- | "refresh"
- | "branch"
- | "git"
- | "folder"
- | "terminal"
- | "code"
- | "external"
- | "copy"
- | "more"
- | "chev"
- | "chevDown"
- | "chevLeft"
- | "check"
- | "x"
- | "arrowUp"
- | "arrowDown"
- | "play"
- | "pull"
- | "dot"
- | "filter"
- | "sort"
- | "pin"
- | "box"
- | "pr"
- | "ci"
- | "sliders"
- | "activity"
- | "settings"
- | "collapse"
- | "expand"
- | "maximize"
- | "camera"
- | "inbox"
- | "star"
- | "trash"
- | "home"
- | "user"
- | "key"
- | "license"
- | "scale"
- | "repo"
- | "edit"
- | "wrench";
-
-interface IconProps extends Omit, "name"> {
- name: IconName;
- size?: number;
- color?: string;
-}
-
-const PATHS: Record = {
- search: (
- <>
-
-
- >
- ),
- plus: ,
- refresh: (
- <>
-
-
-
-
- >
- ),
- branch: (
- <>
-
-
-
-
- >
- ),
- git: (
- <>
-
-
- >
- ),
- folder: ,
- terminal: (
- <>
-
-
-
- >
- ),
- code: (
- <>
-
-
- >
- ),
- external: (
- <>
-
-
-
- >
- ),
- copy: (
- <>
-
-
- >
- ),
- more: (
- <>
-
-
-
- >
- ),
- chev: ,
- chevDown: ,
- chevLeft: ,
- check: ,
- x: ,
- arrowUp: ,
- arrowDown: ,
- play: ,
- pull: ,
- dot: ,
- filter: ,
- sort: ,
- pin: (
- <>
-
-
- >
- ),
- box: (
- <>
-
-
-
- >
- ),
- pr: (
- <>
-
-
-
-
- >
- ),
- ci: (
- <>
-
-
- >
- ),
- sliders: (
- <>
-
-
-
-
-
-
-
-
-
- >
- ),
- activity: ,
- settings: (
- <>
-
-
- >
- ),
- collapse: (
- <>
-
-
- >
- ),
- expand: (
- <>
-
-
- >
- ),
- /** Classic "enter fullscreen" glyph: four corner brackets pointing
- * outward. Used by the DetailPane's Open-full-view CTA. */
- maximize: (
- <>
-
-
-
-
- >
- ),
- camera: (
- <>
-
-
- >
- ),
- inbox: (
- <>
-
-
- >
- ),
- star: (
-
- ),
- trash: (
- <>
-
-
- >
- ),
- home: (
-
- ),
- user: (
- <>
-
-
- >
- ),
- key: (
- <>
-
-
-
-
- >
- ),
- /** Certificate / license: parchment with an official seal + ribbon. */
- license: (
- <>
-
-
-
-
- >
- ),
- /** GitHub-style repo glyph: hardcover book with a ribbon bookmark. */
- repo: (
- <>
-
-
- >
- ),
- /** Pencil on a page — used for "Changes" / uncommitted work. */
- edit: (
- <>
-
-
- >
- ),
- /** Wrench — developer / tooling affordance. Matches lucide's `wrench`. */
- wrench: (
-
- ),
- /** Scales of justice — matches Octicon `law`, GitHub's license glyph. */
- scale: (
- <>
-
-
-
-
-
- >
- ),
-};
-
-export function Icon({ name, size = 16, color = "currentColor", className, ...rest }: IconProps) {
- return (
-
- {PATHS[name]}
-
- );
-}
diff --git a/app/src/components/atoms/IdeIcon/IdeIcon.stories.tsx b/app/src/components/atoms/IdeIcon/IdeIcon.stories.tsx
deleted file mode 100644
index 5ccc1ba..0000000
--- a/app/src/components/atoms/IdeIcon/IdeIcon.stories.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { IdeIcon } from "@/components/atoms/IdeIcon";
-
-const meta: Meta = {
- title: "Atoms/IdeIcon",
- component: IdeIcon,
- args: { size: 32, color: "brand" },
-};
-
-export default meta;
-
-export const VsCode: StoryObj = { args: { id: "vscode" } };
-export const VsCodeInsiders: StoryObj = {
- args: { id: "vscode-insiders" },
-};
-export const Cursor: StoryObj = { args: { id: "cursor" } };
-export const WebStorm: StoryObj = { args: { id: "webstorm" } };
-export const IntelliJIDEA: StoryObj = { args: { id: "idea" } };
-export const JetBrainsToolbox: StoryObj = {
- args: { id: "jetbrains-toolbox" },
-};
-export const Disabled: StoryObj = {
- args: { id: "webstorm", color: "currentColor" },
-};
diff --git a/app/src/components/atoms/IdeIcon/IdeIcon.test.tsx b/app/src/components/atoms/IdeIcon/IdeIcon.test.tsx
deleted file mode 100644
index c54ba1e..0000000
--- a/app/src/components/atoms/IdeIcon/IdeIcon.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { IdeIcon } from "@/components/atoms/IdeIcon";
-
-/**
- * IDE logos are inlined as SVGs via `vite-plugin-svgr` (`.svg?react`), so
- * they render deterministically in jsdom. Tests cover the Cursor path
- * (inline SVG from `simple-icons`) plus a smoke check that every official
- * IDE id mounts without crashing.
- */
-describe("IdeIcon", () => {
- it("renders the Cursor logo inline with the expected aria-label", () => {
- render( );
- expect(screen.getByLabelText("Cursor")).toBeInTheDocument();
- });
-
- it("applies the custom size to the Cursor SVG", () => {
- const { container } = render( );
- const svg = container.querySelector("svg");
- expect(svg?.getAttribute("width")).toBe("40");
- expect(svg?.getAttribute("height")).toBe("40");
- });
-
- it("mounts without crashing for every official IDE id", () => {
- const ids = ["vscode", "vscode-insiders", "webstorm", "idea", "jetbrains-toolbox"] as const;
- for (const id of ids) {
- const { container, unmount } = render( );
- expect(container.firstChild).not.toBeNull();
- unmount();
- }
- });
-});
diff --git a/app/src/components/atoms/IdeIcon/index.tsx b/app/src/components/atoms/IdeIcon/index.tsx
deleted file mode 100644
index 632d48c..0000000
--- a/app/src/components/atoms/IdeIcon/index.tsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { type CSSProperties, type FC, type SVGProps } from "react";
-
-import { siCursor } from "simple-icons";
-
-import type { IdeId } from "@recrest/shared";
-
-import IntellijIdeaLogo from "@/components/atoms/IdeIcon/logos/intellij-idea.svg?react";
-import JetbrainsLogo from "@/components/atoms/IdeIcon/logos/jetbrains.svg?react";
-import VSCodeLogo from "@/components/atoms/IdeIcon/logos/visual-studio-code.svg?react";
-import WebstormLogo from "@/components/atoms/IdeIcon/logos/webstorm.svg?react";
-import { cn } from "@/lib/utils";
-
-/**
- * Official IDE logos inlined from the Iconify `logos` set (committed as
- * static SVGs in `./logos/`). Imported via `vite-plugin-svgr`'s `?react`
- * suffix so they become real React components at build time — no runtime
- * fetch to any CDN, which is what Tauri's strict CSP requires.
- * Cursor stays inline from `simple-icons` (the `logos` set doesn't ship it).
- * VS Code Insiders reuses the VS Code mark with a hue-rotate filter.
- */
-const LOGO_COMPONENT: Partial>>> = {
- vscode: VSCodeLogo,
- "vscode-insiders": VSCodeLogo,
- webstorm: WebstormLogo,
- idea: IntellijIdeaLogo,
- "jetbrains-toolbox": JetbrainsLogo,
-};
-
-interface IdeIconProps {
- id: IdeId;
- size?: number;
- /** `"brand"` (default) = official colours; `"currentColor"` = greyed out
- * (used for disabled items in the dropdown). */
- color?: "brand" | "currentColor";
- title?: string;
- style?: CSSProperties;
- className?: string;
-}
-
-export function IdeIcon({ id, size = 16, color = "brand", title, style, className }: IdeIconProps) {
- const mono = color === "currentColor";
-
- if (id === "cursor") {
- return (
-
- );
- }
-
- const LogoComponent = LOGO_COMPONENT[id];
- if (!LogoComponent) return null;
-
- const filterParts: string[] = [];
- if (mono) filterParts.push("grayscale(1)");
- if (id === "vscode-insiders") filterParts.push("hue-rotate(140deg)", "saturate(0.9)");
-
- const iconStyle: CSSProperties = {
- flexShrink: 0,
- ...(filterParts.length > 0 ? { filter: filterParts.join(" ") } : null),
- ...(mono ? { opacity: 0.55 } : null),
- ...style,
- };
-
- return (
-
- );
-}
-
-/** Cursor logo from simple-icons — Iconify's logos set doesn't cover the IDE yet. */
-function CursorGlyph({
- size,
- mono,
- title,
- style,
- className,
-}: {
- size: number;
- mono: boolean;
- title?: string;
- style?: CSSProperties;
- className?: string;
-}) {
- const fill = mono ? "currentColor" : `#${siCursor.hex}`;
- return (
-
-
-
- );
-}
diff --git a/app/src/components/atoms/Input/Input.stories.tsx b/app/src/components/atoms/Input/Input.stories.tsx
deleted file mode 100644
index 06643ff..0000000
--- a/app/src/components/atoms/Input/Input.stories.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Input } from "@/components/atoms/Input";
-
-const meta: Meta = {
- title: "Atoms/Input",
- component: Input,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: { placeholder: "Type something…" },
-};
-export const Password: StoryObj = {
- args: { type: "password", placeholder: "Secret" },
-};
-export const Disabled: StoryObj = {
- args: { disabled: true, value: "Disabled" },
-};
diff --git a/app/src/components/atoms/Input/Input.test.tsx b/app/src/components/atoms/Input/Input.test.tsx
deleted file mode 100644
index 0588435..0000000
--- a/app/src/components/atoms/Input/Input.test.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { Input } from "@/components/atoms/Input";
-
-describe("Input", () => {
- it("rendert input mit placeholder", () => {
- render( );
- expect(screen.getByPlaceholderText("Suchen…")).toBeInTheDocument();
- });
-
- it("ruft onChange bei Eingabe auf", async () => {
- const user = userEvent.setup();
- const handler = vi.fn();
- render( );
- await user.type(screen.getByRole("textbox"), "abc");
- expect(handler).toHaveBeenCalled();
- });
-
- it("setzt aria-invalid wenn invalid=true", () => {
- render( );
- expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
- });
-
- it("setzt kein aria-invalid wenn invalid=false", () => {
- render( );
- expect(screen.getByRole("textbox")).not.toHaveAttribute("aria-invalid");
- });
-
- it("ist disabled wenn disabled-Prop gesetzt", () => {
- render( );
- expect(screen.getByRole("textbox")).toBeDisabled();
- });
-
- it("leitet ref auf das input-Element weiter", () => {
- const ref = { current: null as HTMLInputElement | null };
- render( );
- expect(ref.current).toBeInstanceOf(HTMLInputElement);
- });
-});
diff --git a/app/src/components/atoms/Input/index.tsx b/app/src/components/atoms/Input/index.tsx
deleted file mode 100644
index b227ef5..0000000
--- a/app/src/components/atoms/Input/index.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { type ComponentProps, forwardRef } from "react";
-
-import { cn } from "@/lib/utils";
-
-export interface InputProps extends ComponentProps<"input"> {
- invalid?: boolean;
-}
-
-export const Input = forwardRef(
- ({ className, type, invalid, ...props }, ref) => {
- return (
-
- );
- },
-);
-
-Input.displayName = "Input";
diff --git a/app/src/components/atoms/Kbd/Kbd.stories.tsx b/app/src/components/atoms/Kbd/Kbd.stories.tsx
deleted file mode 100644
index 507f7f1..0000000
--- a/app/src/components/atoms/Kbd/Kbd.stories.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Kbd } from "@/components/atoms/Kbd";
-
-const meta: Meta = {
- title: "Atoms/Kbd",
- component: Kbd,
-};
-
-export default meta;
-
-export const Single: StoryObj = { args: { children: "K" } };
-export const ModifierKey: StoryObj = { args: { children: "\u2318K" } };
-export const Combo: StoryObj = { args: { children: "Ctrl+Shift+P" } };
-export const Escape: StoryObj = { args: { children: "Esc" } };
diff --git a/app/src/components/atoms/Kbd/Kbd.test.tsx b/app/src/components/atoms/Kbd/Kbd.test.tsx
deleted file mode 100644
index f479a77..0000000
--- a/app/src/components/atoms/Kbd/Kbd.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Kbd } from "@/components/atoms/Kbd";
-
-describe("Kbd", () => {
- it("rendert children", () => {
- render(Esc );
- expect(screen.getByText("Esc")).toBeInTheDocument();
- });
-
- it("setzt die kbd-Klasse", () => {
- render(Ctrl );
- expect(screen.getByText("Ctrl")).toHaveClass("kbd");
- });
-
- it("rendert komplexere ReactNode-Kinder", () => {
- render(
-
- ⌘ K
- ,
- );
- expect(screen.getByTestId("inner")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/atoms/Kbd/index.tsx b/app/src/components/atoms/Kbd/index.tsx
deleted file mode 100644
index 32fa396..0000000
--- a/app/src/components/atoms/Kbd/index.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import type { ReactNode } from "react";
-
-interface KbdProps {
- children: ReactNode;
-}
-
-/** Inline keyboard-key rendering (``-like visual). */
-export function Kbd({ children }: KbdProps) {
- return {children} ;
-}
diff --git a/app/src/components/atoms/Label/Label.stories.tsx b/app/src/components/atoms/Label/Label.stories.tsx
deleted file mode 100644
index d5529a3..0000000
--- a/app/src/components/atoms/Label/Label.stories.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Label } from "@/components/atoms/Label";
-
-const meta: Meta = {
- title: "Atoms/Label",
- component: Label,
-};
-
-export default meta;
-
-export const Default: StoryObj = { args: { children: "Field label" } };
-export const WithHtmlFor: StoryObj = {
- args: { children: "Personal access token", htmlFor: "pat-input" },
-};
-export const LongText: StoryObj = {
- args: {
- children: "Automatically fetch pull requests every few minutes while the app is open",
- },
-};
diff --git a/app/src/components/atoms/Label/Label.test.tsx b/app/src/components/atoms/Label/Label.test.tsx
deleted file mode 100644
index 21d9377..0000000
--- a/app/src/components/atoms/Label/Label.test.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Label } from "@/components/atoms/Label";
-
-describe("Label", () => {
- it("rendert Text", () => {
- render(Benutzername );
- expect(screen.getByText("Benutzername")).toBeInTheDocument();
- });
-
- it("wendet Basisklassen an", () => {
- render(Email );
- expect(screen.getByText("Email").className).toContain("text-sm");
- expect(screen.getByText("Email").className).toContain("font-medium");
- });
-
- it("merged zusätzliche className", () => {
- render(X );
- expect(screen.getByText("X")).toHaveClass("custom-label");
- });
-
- it("unterstützt htmlFor-Prop", () => {
- render(Email );
- expect(screen.getByText("Email")).toHaveAttribute("for", "email-input");
- });
-});
diff --git a/app/src/components/atoms/Label/index.tsx b/app/src/components/atoms/Label/index.tsx
deleted file mode 100644
index 3c7bf55..0000000
--- a/app/src/components/atoms/Label/index.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as LabelPrimitive from "@radix-ui/react-label";
-
-import { cn } from "@/lib/utils";
-
-export function Label({ className, ...props }: ComponentProps) {
- return (
-
- );
-}
diff --git a/app/src/components/atoms/LangDot/LangDot.stories.tsx b/app/src/components/atoms/LangDot/LangDot.stories.tsx
deleted file mode 100644
index c6b9b84..0000000
--- a/app/src/components/atoms/LangDot/LangDot.stories.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { LangDot } from "@/components/atoms/LangDot";
-
-const meta: Meta = {
- title: "Atoms/LangDot",
- component: LangDot,
-};
-
-export default meta;
-
-export const Rust: StoryObj = { args: { lang: "rs" } };
-export const TypeScript: StoryObj = { args: { lang: "ts" } };
-export const Python: StoryObj = { args: { lang: "Python" } };
-export const Go: StoryObj = { args: { lang: "go" } };
-export const Unknown: StoryObj = { args: { lang: null } };
diff --git a/app/src/components/atoms/LangDot/LangDot.test.tsx b/app/src/components/atoms/LangDot/LangDot.test.tsx
deleted file mode 100644
index 6385567..0000000
--- a/app/src/components/atoms/LangDot/LangDot.test.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { LangDot } from "@/components/atoms/LangDot";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-
-function renderDot(lang: string | null | undefined) {
- return render(
-
-
- ,
- );
-}
-
-describe("LangDot", () => {
- it("rendert mit lang-dot Klasse", () => {
- const { container } = renderDot("rs");
- expect(container.querySelector(".lang-dot")).not.toBeNull();
- });
-
- it("rendert bei null-lang (Fallback)", () => {
- const { container } = renderDot(null);
- expect(container.querySelector(".lang-dot")).not.toBeNull();
- });
-
- it("rendert bei undefined-lang (Fallback)", () => {
- const { container } = renderDot(undefined);
- expect(container.querySelector(".lang-dot")).not.toBeNull();
- });
-
- it("hat ein aria-label mit Sprachenbezeichnung", () => {
- const { container } = renderDot("Rust");
- expect(container.querySelector(".lang-dot")?.getAttribute("aria-label")).toBeTruthy();
- });
-
- it("setzt background-style aus langMeta", () => {
- const { container } = renderDot("Rust");
- const dot = container.querySelector(".lang-dot");
- expect(dot?.style.background).not.toBe("");
- });
-});
diff --git a/app/src/components/atoms/LangDot/index.tsx b/app/src/components/atoms/LangDot/index.tsx
deleted file mode 100644
index 75a873f..0000000
--- a/app/src/components/atoms/LangDot/index.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip";
-import { langMeta } from "@/lib/languages";
-
-interface LangDotProps {
- lang: string | null | undefined;
-}
-
-/** Colored 8px dot for a language identifier. Accepts either a file
- * extension ("rs") or a canonical linguist name ("Rust"). */
-export function LangDot({ lang }: LangDotProps) {
- const meta = langMeta(lang);
- return (
-
-
-
-
- {meta.label}
-
- );
-}
diff --git a/app/src/components/atoms/Mascot/Mascot.stories.tsx b/app/src/components/atoms/Mascot/Mascot.stories.tsx
deleted file mode 100644
index 415995f..0000000
--- a/app/src/components/atoms/Mascot/Mascot.stories.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Mascot, type MascotVariant } from "@/components/atoms/Mascot";
-
-const meta: Meta = {
- title: "Atoms/Mascot",
- component: Mascot,
- args: {
- variant: "snoozing",
- size: 128,
- },
- argTypes: {
- variant: {
- control: { type: "select" },
- options: [
- "snoozing",
- "celebrating",
- "searching",
- "waving",
- "shrugging",
- ] satisfies MascotVariant[],
- },
- size: { control: { type: "number", min: 48, max: 256, step: 8 } },
- },
-};
-
-export default meta;
-
-type Story = StoryObj;
-
-export const Snoozing: Story = { args: { variant: "snoozing" } };
-export const Celebrating: Story = { args: { variant: "celebrating" } };
-export const Searching: Story = { args: { variant: "searching" } };
-export const Waving: Story = { args: { variant: "waving" } };
-export const Shrugging: Story = { args: { variant: "shrugging" } };
-
-export const AllPoses: Story = {
- render: () => (
-
- {(["snoozing", "celebrating", "searching", "waving", "shrugging"] as MascotVariant[]).map(
- (v) => (
-
-
- {v}
-
- ),
- )}
-
- ),
-};
diff --git a/app/src/components/atoms/Separator/Separator.stories.tsx b/app/src/components/atoms/Separator/Separator.stories.tsx
deleted file mode 100644
index 0fb38a0..0000000
--- a/app/src/components/atoms/Separator/Separator.stories.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Separator } from "@/components/atoms/Separator";
-
-const meta: Meta = {
- title: "Atoms/Separator",
- component: Separator,
-};
-
-export default meta;
-
-export const Horizontal: StoryObj = {};
-export const Vertical: StoryObj = {
- args: { orientation: "vertical" },
- decorators: [
- (Story) => (
-
- Left
-
- Right
-
- ),
- ],
-};
diff --git a/app/src/components/atoms/Separator/Separator.test.tsx b/app/src/components/atoms/Separator/Separator.test.tsx
deleted file mode 100644
index b1f228c..0000000
--- a/app/src/components/atoms/Separator/Separator.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Separator } from "@/components/atoms/Separator";
-
-describe("Separator", () => {
- it("rendert horizontal per Default", () => {
- const { container } = render( );
- const el = container.firstElementChild as HTMLElement | null;
- expect(el?.className).toContain("h-px");
- expect(el?.className).toContain("w-full");
- });
-
- it("rendert vertikal bei orientation='vertical'", () => {
- const { container } = render( );
- const el = container.firstElementChild as HTMLElement | null;
- expect(el?.className).toContain("h-full");
- expect(el?.className).toContain("w-px");
- });
-
- it("merged custom className", () => {
- const { container } = render( );
- expect((container.firstElementChild as HTMLElement).className).toContain("my-sep");
- });
-
- it("ist per Default decorative (role=none)", () => {
- const { container } = render( );
- const role = container.firstElementChild?.getAttribute("role");
- expect(role === "none" || role === null).toBe(true);
- });
-});
diff --git a/app/src/components/atoms/Separator/index.tsx b/app/src/components/atoms/Separator/index.tsx
deleted file mode 100644
index eebebe9..0000000
--- a/app/src/components/atoms/Separator/index.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as SeparatorPrimitive from "@radix-ui/react-separator";
-
-import { cn } from "@/lib/utils";
-
-export function Separator({
- className,
- orientation = "horizontal",
- decorative = true,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
diff --git a/app/src/components/atoms/Skeleton/Skeleton.stories.tsx b/app/src/components/atoms/Skeleton/Skeleton.stories.tsx
deleted file mode 100644
index 4aaa043..0000000
--- a/app/src/components/atoms/Skeleton/Skeleton.stories.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Skeleton } from "@/components/atoms/Skeleton";
-
-const meta: Meta = {
- title: "Atoms/Skeleton",
- component: Skeleton,
-};
-
-export default meta;
-
-export const Line: StoryObj = { args: { className: "h-3 w-48" } };
-export const LongLine: StoryObj = { args: { className: "h-3 w-full" } };
-export const Block: StoryObj = { args: { className: "h-16 w-64" } };
-export const Circle: StoryObj = { args: { className: "h-8 w-8 rounded-full" } };
-export const Avatar: StoryObj = {
- args: { className: "h-10 w-10 rounded-full" },
-};
diff --git a/app/src/components/atoms/Skeleton/Skeleton.test.tsx b/app/src/components/atoms/Skeleton/Skeleton.test.tsx
deleted file mode 100644
index 7eaccce..0000000
--- a/app/src/components/atoms/Skeleton/Skeleton.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Skeleton } from "@/components/atoms/Skeleton";
-
-describe("Skeleton", () => {
- it("rendert einen div", () => {
- const { container } = render( );
- expect(container.firstElementChild?.tagName).toBe("DIV");
- });
-
- it("wendet Pulse- und Muted-Klassen an", () => {
- const { container } = render( );
- const el = container.firstElementChild as HTMLElement;
- expect(el.className).toContain("animate-pulse");
- expect(el.className).toContain("bg-muted");
- });
-
- it("merged zusätzliche className", () => {
- const { container } = render( );
- const el = container.firstElementChild as HTMLElement;
- expect(el.className).toContain("h-8");
- expect(el.className).toContain("w-40");
- });
-
- it("wendet inline-style an", () => {
- const { container } = render( );
- const el = container.firstElementChild as HTMLElement;
- expect(el.style.width).toBe("120px");
- });
-
- it("ist aria-hidden", () => {
- const { container } = render( );
- expect(container.firstElementChild).toHaveAttribute("aria-hidden");
- });
-});
diff --git a/app/src/components/atoms/Skeleton/index.tsx b/app/src/components/atoms/Skeleton/index.tsx
deleted file mode 100644
index c0876a6..0000000
--- a/app/src/components/atoms/Skeleton/index.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { CSSProperties } from "react";
-
-import { cn } from "@/lib/utils";
-
-interface SkeletonProps {
- className?: string;
- /** Inline style is handy when the parent lays out with
- * `gridTemplateColumns` inline — we need to sit in that grid and
- * still animate. */
- style?: CSSProperties;
-}
-
-/** Base shimmer placeholder. Sized and positioned by the caller. Use the
- * variants in `molecules/skeletons/*` for composed row shapes. */
-export function Skeleton({ className, style }: SkeletonProps) {
- return (
-
- );
-}
diff --git a/app/src/components/atoms/Sparkline/Sparkline.stories.tsx b/app/src/components/atoms/Sparkline/Sparkline.stories.tsx
deleted file mode 100644
index f090c4a..0000000
--- a/app/src/components/atoms/Sparkline/Sparkline.stories.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Sparkline } from "@/components/atoms/Sparkline";
-
-const meta: Meta = {
- title: "Atoms/Sparkline",
- component: Sparkline,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: { data: [3, 5, 2, 8, 12, 4, 6, 9, 1, 0, 7, 3, 5, 10] },
-};
-
-export const Empty: StoryObj = {
- args: { data: [0, 0, 0, 0, 0, 0, 0] },
-};
-
-export const Active: StoryObj = {
- args: { data: [3, 5, 2, 8, 12, 4, 6], active: true },
-};
diff --git a/app/src/components/atoms/Sparkline/Sparkline.test.tsx b/app/src/components/atoms/Sparkline/Sparkline.test.tsx
deleted file mode 100644
index 01fb629..0000000
--- a/app/src/components/atoms/Sparkline/Sparkline.test.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Sparkline } from "@/components/atoms/Sparkline";
-
-describe("Sparkline", () => {
- it("rendert .spark container", () => {
- const { container } = render( );
- expect(container.querySelector(".spark")).not.toBeNull();
- });
-
- it("rendert einen Balken pro Datenpunkt", () => {
- const { container } = render( );
- const bars = container.querySelectorAll(".spark > span");
- expect(bars.length).toBe(5);
- });
-
- it("markiert Null-Werte mit .zero Klasse", () => {
- const { container } = render( );
- expect(container.querySelectorAll(".zero").length).toBe(2);
- });
-
- it("fügt .active Klasse im active-Mode hinzu", () => {
- const { container } = render( );
- expect(container.querySelector(".spark.active")).not.toBeNull();
- });
-
- it("wendet width/height aus Props an", () => {
- const { container } = render( );
- const el = container.querySelector(".spark");
- expect(el?.style.width).toBe("100px");
- expect(el?.style.height).toBe("24px");
- });
-});
diff --git a/app/src/components/atoms/Sparkline/index.tsx b/app/src/components/atoms/Sparkline/index.tsx
deleted file mode 100644
index 531f13c..0000000
--- a/app/src/components/atoms/Sparkline/index.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-interface SparklineProps {
- data: number[];
- active?: boolean;
- width?: number;
- height?: number;
-}
-
-/** Tiny commit-activity bar chart. `data` is an arbitrary-length numeric
- * series; bars auto-scale to the series max. Used in repo row sparklines
- * and the dashboard 14-day activity strip. */
-export function Sparkline({ data, active, width = 64, height = 18 }: SparklineProps) {
- const max = Math.max(...data, 1);
- const barW = Math.floor((width - (data.length - 1) * 2) / data.length);
- return (
-
- {data.map((v, i) => (
-
- ))}
-
- );
-}
diff --git a/app/src/components/atoms/Spinner/Spinner.stories.tsx b/app/src/components/atoms/Spinner/Spinner.stories.tsx
deleted file mode 100644
index ee0f7c6..0000000
--- a/app/src/components/atoms/Spinner/Spinner.stories.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Spinner } from "@/components/atoms/Spinner";
-
-const meta: Meta = {
- title: "Atoms/Spinner",
- component: Spinner,
-};
-
-export default meta;
-
-export const Default: StoryObj = {};
-export const Small: StoryObj = { args: { size: "sm" } };
-export const Large: StoryObj = { args: { size: "lg" } };
-export const WithLabel: StoryObj = {
- args: { size: "md", label: "Loading repositories" },
-};
diff --git a/app/src/components/atoms/Spinner/Spinner.test.tsx b/app/src/components/atoms/Spinner/Spinner.test.tsx
deleted file mode 100644
index e779d7c..0000000
--- a/app/src/components/atoms/Spinner/Spinner.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { Spinner } from "@/components/atoms/Spinner";
-
-describe("Spinner", () => {
- it("rendert mit role=status", () => {
- render( );
- expect(screen.getByRole("status")).toBeInTheDocument();
- });
-
- it("nutzt die md-Größe per Default", () => {
- render( );
- expect(screen.getByRole("status").getAttribute("class")).toContain("h-4");
- });
-
- it("wendet sm-Größe an", () => {
- render( );
- expect(screen.getByRole("status").getAttribute("class")).toContain("h-3.5");
- });
-
- it("wendet lg-Größe an", () => {
- render( );
- expect(screen.getByRole("status").getAttribute("class")).toContain("h-6");
- });
-
- it("setzt aria-label aus label-Prop", () => {
- render( );
- expect(screen.getByRole("status")).toHaveAttribute("aria-label", "Lade Daten");
- });
-});
diff --git a/app/src/components/atoms/Spinner/index.tsx b/app/src/components/atoms/Spinner/index.tsx
deleted file mode 100644
index 490eee7..0000000
--- a/app/src/components/atoms/Spinner/index.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { Loader2 } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-interface SpinnerProps {
- className?: string;
- size?: "sm" | "md" | "lg";
- label?: string;
-}
-
-const SIZES = {
- sm: "h-3.5 w-3.5",
- md: "h-4 w-4",
- lg: "h-6 w-6",
-} as const;
-
-export function Spinner({ className, size = "md", label }: SpinnerProps) {
- return (
-
- );
-}
diff --git a/app/src/components/atoms/StatusDot/StatusDot.stories.tsx b/app/src/components/atoms/StatusDot/StatusDot.stories.tsx
deleted file mode 100644
index eedbcaf..0000000
--- a/app/src/components/atoms/StatusDot/StatusDot.stories.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { StatusDot } from "@/components/atoms/StatusDot";
-
-const meta: Meta = {
- title: "Atoms/StatusDot",
- component: StatusDot,
-};
-
-export default meta;
-
-export const Clean: StoryObj = { args: { kind: "clean" } };
-export const Dirty: StoryObj = { args: { kind: "dirty" } };
-export const Behind: StoryObj = { args: { kind: "behind" } };
diff --git a/app/src/components/atoms/StatusDot/StatusDot.test.tsx b/app/src/components/atoms/StatusDot/StatusDot.test.tsx
deleted file mode 100644
index f9fe35c..0000000
--- a/app/src/components/atoms/StatusDot/StatusDot.test.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { render } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { StatusDot } from "@/components/atoms/StatusDot";
-
-describe("StatusDot", () => {
- it("rendert span mit status-dot und clean-Klasse", () => {
- const { container } = render( );
- const el = container.firstElementChild;
- expect(el?.className).toBe("status-dot clean");
- });
-
- it("wendet dirty-Kind an", () => {
- const { container } = render( );
- expect(container.firstElementChild?.className).toBe("status-dot dirty");
- });
-
- it("wendet behind-Kind an", () => {
- const { container } = render( );
- expect(container.firstElementChild?.className).toBe("status-dot behind");
- });
-});
diff --git a/app/src/components/atoms/StatusDot/index.tsx b/app/src/components/atoms/StatusDot/index.tsx
deleted file mode 100644
index ef8d404..0000000
--- a/app/src/components/atoms/StatusDot/index.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-export type StatusKind = "clean" | "dirty" | "behind";
-
-interface StatusDotProps {
- kind: StatusKind;
-}
-
-/** Coloured status dot used as a prefix in repo rows and detail summaries. */
-export function StatusDot({ kind }: StatusDotProps) {
- return ;
-}
diff --git a/app/src/components/atoms/Switch/Switch.stories.tsx b/app/src/components/atoms/Switch/Switch.stories.tsx
deleted file mode 100644
index d9ded3d..0000000
--- a/app/src/components/atoms/Switch/Switch.stories.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Switch } from "@/components/atoms/Switch";
-
-const meta: Meta = {
- title: "Atoms/Switch",
- component: Switch,
-};
-
-export default meta;
-
-export const Off: StoryObj = { args: { checked: false } };
-export const On: StoryObj = { args: { checked: true } };
-export const Disabled: StoryObj = { args: { disabled: true, checked: false } };
-export const DisabledOn: StoryObj = { args: { disabled: true, checked: true } };
diff --git a/app/src/components/atoms/Switch/Switch.test.tsx b/app/src/components/atoms/Switch/Switch.test.tsx
deleted file mode 100644
index 5e8dc48..0000000
--- a/app/src/components/atoms/Switch/Switch.test.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { Switch } from "@/components/atoms/Switch";
-
-describe("Switch", () => {
- it("rendert mit role=switch", () => {
- render( );
- expect(screen.getByRole("switch")).toBeInTheDocument();
- });
-
- it("ist per Default unchecked", () => {
- render( );
- expect(screen.getByRole("switch")).toHaveAttribute("data-state", "unchecked");
- });
-
- it("respektiert defaultChecked", () => {
- render( );
- expect(screen.getByRole("switch")).toHaveAttribute("data-state", "checked");
- });
-
- it("ruft onCheckedChange bei Klick auf", async () => {
- const user = userEvent.setup();
- const handler = vi.fn();
- render( );
- await user.click(screen.getByRole("switch"));
- expect(handler).toHaveBeenCalledWith(true);
- });
-
- it("ist disabled wenn disabled-Prop gesetzt", () => {
- render( );
- expect(screen.getByRole("switch")).toBeDisabled();
- });
-});
diff --git a/app/src/components/atoms/Switch/index.tsx b/app/src/components/atoms/Switch/index.tsx
deleted file mode 100644
index 4f6efc7..0000000
--- a/app/src/components/atoms/Switch/index.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as SwitchPrimitive from "@radix-ui/react-switch";
-
-import { cn } from "@/lib/utils";
-
-/**
- * Brand-tinted Radix switch. The checked state uses the Recrest accent
- * (coral by default, picks up `data-accent` overrides), so the active
- * pill stands out in both light and dark mode without falling back to
- * the raw `--ink-0` swap that previously rendered as either solid black
- * (light) or solid white (dark) — both of which read as "broken" against
- * the surrounding card.
- *
- * The thumb stays opaque white in both states so the iOS-style contrast
- * holds up over the accent fill.
- */
-export function Switch({ className, ...props }: ComponentProps) {
- return (
-
-
-
- );
-}
diff --git a/app/src/components/atoms/avatars/AuthorAvatar/AuthorAvatar.stories.tsx b/app/src/components/atoms/avatars/AuthorAvatar/AuthorAvatar.stories.tsx
new file mode 100644
index 0000000..29657a6
--- /dev/null
+++ b/app/src/components/atoms/avatars/AuthorAvatar/AuthorAvatar.stories.tsx
@@ -0,0 +1,17 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import AuthorAvatar from "@/components/atoms/avatars/AuthorAvatar";
+
+const meta = {
+ title: "Atoms/Avatars/AuthorAvatar",
+ component: AuthorAvatar,
+ args: { name: "Valentin Röhle", email: "valentin@example.com", size: 40 },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Small: Story = { args: { size: 20 } };
diff --git a/app/src/components/atoms/avatars/AuthorAvatar/AuthorAvatar.test.tsx b/app/src/components/atoms/avatars/AuthorAvatar/AuthorAvatar.test.tsx
new file mode 100644
index 0000000..e07d2ea
--- /dev/null
+++ b/app/src/components/atoms/avatars/AuthorAvatar/AuthorAvatar.test.tsx
@@ -0,0 +1,27 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import AuthorAvatar from "@/components/atoms/avatars/AuthorAvatar";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("AuthorAvatar", () => {
+ it("renders an avatar tile", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.authorAvatar.wrap).children.length).toBe(1);
+ });
+
+ it("renders even when name is empty", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.authorAvatar.wrap).children.length).toBe(1);
+ });
+});
diff --git a/app/src/components/atoms/avatars/AuthorAvatar/index.tsx b/app/src/components/atoms/avatars/AuthorAvatar/index.tsx
new file mode 100644
index 0000000..04c7fca
--- /dev/null
+++ b/app/src/components/atoms/avatars/AuthorAvatar/index.tsx
@@ -0,0 +1,69 @@
+import { useEffect, useState } from "react";
+
+import GeneralAvatar from "@/components/atoms/avatars/GeneralAvatar";
+import { gravatarHash, gravatarUrl } from "@/lib/utils/gravatar.utils";
+import { hashCode } from "@/lib/utils/hash.utils";
+
+const AUTHOR_GRADIENTS: ReadonlyArray = [
+ ["#4f8cff", "#7b2ff7"],
+ ["#10b981", "#0ea5a3"],
+ ["#ff7a59", "#d6336c"],
+ ["#f59e0b", "#ef4444"],
+ ["#06b6d4", "#3b82f6"],
+ ["#ec4899", "#8b5cf6"],
+ ["#22c55e", "#14b8a6"],
+ ["#a855f7", "#ec4899"],
+ ["#0ea5e9", "#14b8a6"],
+ ["#7c3aed", "#2563eb"],
+ ["#f97316", "#eab308"],
+ ["#6366f1", "#06b6d4"],
+];
+
+function gradientForAuthor(id: string): readonly [string, string] {
+ const idx = hashCode(id.toLowerCase()) % AUTHOR_GRADIENTS.length;
+ return AUTHOR_GRADIENTS[idx] ?? AUTHOR_GRADIENTS[0]!;
+}
+
+interface Props {
+ name: string;
+ email?: string;
+ size?: number;
+}
+
+function AuthorAvatar({ name, email, size = 24 }: Props) {
+ const id = (email || name).trim();
+ const [c1, c2] = gradientForAuthor(id);
+ const gradient = `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`;
+ const letter = (name.trim().charAt(0) || "?").toUpperCase();
+
+ const [imageUrl, setImageUrl] = useState(null);
+ const [failed, setFailed] = useState(false);
+
+ useEffect(() => {
+ if (!email || failed) {
+ setImageUrl(null);
+ return;
+ }
+ let alive = true;
+ void gravatarHash(email).then((h) => {
+ if (alive) setImageUrl(gravatarUrl(h, size));
+ });
+ return () => {
+ alive = false;
+ };
+ }, [email, size, failed]);
+
+ return (
+ setFailed(true)}
+ />
+ );
+}
+
+export default AuthorAvatar;
diff --git a/app/src/components/atoms/avatars/GeneralAvatar/GeneralAvatar.stories.tsx b/app/src/components/atoms/avatars/GeneralAvatar/GeneralAvatar.stories.tsx
new file mode 100644
index 0000000..e7d8bbc
--- /dev/null
+++ b/app/src/components/atoms/avatars/GeneralAvatar/GeneralAvatar.stories.tsx
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralAvatar from "@/components/atoms/avatars/GeneralAvatar";
+
+const meta = {
+ title: "Atoms/Avatars/GeneralAvatar",
+ component: GeneralAvatar,
+ args: {
+ size: 40,
+ radius: 8,
+ gradient: "linear-gradient(135deg, #4f8cff 0%, #7b2ff7 100%)",
+ letter: "R",
+ label: "Recrest",
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Circle: Story = {
+ args: { radius: 20 },
+};
diff --git a/app/src/components/atoms/avatars/GeneralAvatar/GeneralAvatar.test.tsx b/app/src/components/atoms/avatars/GeneralAvatar/GeneralAvatar.test.tsx
new file mode 100644
index 0000000..427fd07
--- /dev/null
+++ b/app/src/components/atoms/avatars/GeneralAvatar/GeneralAvatar.test.tsx
@@ -0,0 +1,24 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import GeneralAvatar from "@/components/atoms/avatars/GeneralAvatar";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralAvatar", () => {
+ it("mounts inside its wrapper", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.avatar.wrap).children.length).toBe(1);
+ });
+});
diff --git a/app/src/components/atoms/avatars/GeneralAvatar/index.tsx b/app/src/components/atoms/avatars/GeneralAvatar/index.tsx
new file mode 100644
index 0000000..92ea765
--- /dev/null
+++ b/app/src/components/atoms/avatars/GeneralAvatar/index.tsx
@@ -0,0 +1,96 @@
+import { Box } from "@mui/material";
+import { styled, useTheme } from "@mui/material/styles";
+
+export interface GeneralAvatarProps {
+ size: number;
+ radius: number;
+ gradient: string;
+ letter: string;
+ label?: string;
+ imageUrl?: string | null;
+ onImageError?: () => void;
+}
+
+interface TileProps {
+ size: number;
+ radius: number;
+ gradient: string;
+ hasImage: boolean;
+ neutralBg: string;
+}
+
+const FORWARD = (p: PropertyKey) =>
+ p !== "size" && p !== "radius" && p !== "gradient" && p !== "hasImage" && p !== "neutralBg";
+
+// When an `imageUrl` is supplied the tile drops the gradient + letter
+// completely — repo logos are often transparent SVGs and would otherwise sit
+// on top of the coloured gradient. A neutral surface background covers the
+// transparent regions without bleeding any of the gradient through.
+const Tile = styled(Box, { shouldForwardProp: FORWARD })(
+ ({ theme, size, radius, gradient, hasImage, neutralBg }) => ({
+ position: "relative",
+ width: size,
+ height: size,
+ borderRadius: radius,
+ background: hasImage ? neutralBg : gradient,
+ border: `1px solid ${theme.palette.divider}`,
+ color: "#ffffff",
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: Math.round(size * 0.5),
+ fontWeight: 700,
+ letterSpacing: "-0.02em",
+ flexShrink: 0,
+ fontFamily: "Inter, -apple-system, sans-serif",
+ textShadow: hasImage ? "none" : "0 1px 2px rgba(0,0,0,0.18)",
+ boxShadow: hasImage ? "none" : "inset 0 1px 0 rgba(255,255,255,0.12)",
+ overflow: "hidden",
+ }),
+);
+
+const Image = styled(Box)({
+ position: "absolute",
+ inset: 0,
+ width: "100%",
+ height: "100%",
+ objectFit: "cover",
+ display: "block",
+}) as typeof Box;
+
+function GeneralAvatar({
+ size,
+ radius,
+ gradient,
+ letter,
+ label,
+ imageUrl,
+ onImageError,
+}: GeneralAvatarProps) {
+ const theme = useTheme();
+ const hasImage = Boolean(imageUrl);
+ return (
+
+ {!hasImage && letter}
+ {imageUrl && (
+
+ )}
+
+ );
+}
+
+export default GeneralAvatar;
diff --git a/app/src/components/atoms/avatars/RepoAvatar/RepoAvatar.stories.tsx b/app/src/components/atoms/avatars/RepoAvatar/RepoAvatar.stories.tsx
new file mode 100644
index 0000000..0967cd0
--- /dev/null
+++ b/app/src/components/atoms/avatars/RepoAvatar/RepoAvatar.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import RepoAvatar from "@/components/atoms/avatars/RepoAvatar";
+
+const meta = {
+ title: "Atoms/Avatars/RepoAvatar",
+ component: RepoAvatar,
+ args: { repo: { id: "recrest", name: "Recrest" }, size: 40, radius: 8 },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const PreservesNonLetterPrefix: Story = {
+ args: { repo: { id: "x", name: "_dotfiles" } },
+};
diff --git a/app/src/components/atoms/avatars/RepoAvatar/RepoAvatar.test.tsx b/app/src/components/atoms/avatars/RepoAvatar/RepoAvatar.test.tsx
new file mode 100644
index 0000000..7b94def
--- /dev/null
+++ b/app/src/components/atoms/avatars/RepoAvatar/RepoAvatar.test.tsx
@@ -0,0 +1,18 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import RepoAvatar from "@/components/atoms/avatars/RepoAvatar";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("RepoAvatar", () => {
+ it("renders an avatar tile", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.repoAvatar.wrap).children.length).toBe(1);
+ });
+});
diff --git a/app/src/components/atoms/avatars/RepoAvatar/index.tsx b/app/src/components/atoms/avatars/RepoAvatar/index.tsx
new file mode 100644
index 0000000..60ce94f
--- /dev/null
+++ b/app/src/components/atoms/avatars/RepoAvatar/index.tsx
@@ -0,0 +1,85 @@
+import { useState } from "react";
+
+import GeneralAvatar from "@/components/atoms/avatars/GeneralAvatar";
+import { useRepoLogo } from "@/hooks/useRepoLogo";
+
+/**
+ * Curated two-stop gradients. Each repo gets a stable slot so colours don't
+ * shuffle between renders within a session.
+ */
+const REPO_GRADIENTS: ReadonlyArray = [
+ ["#ff7a59", "#d6336c"],
+ ["#4f8cff", "#7b2ff7"],
+ ["#10b981", "#0ea5a3"],
+ ["#f59e0b", "#ef4444"],
+ ["#ec4899", "#8b5cf6"],
+ ["#06b6d4", "#3b82f6"],
+ ["#22c55e", "#14b8a6"],
+ ["#f97316", "#eab308"],
+ ["#a855f7", "#ec4899"],
+ ["#0ea5e9", "#14b8a6"],
+ ["#e11d48", "#f97316"],
+ ["#6366f1", "#06b6d4"],
+ ["#84cc16", "#10b981"],
+ ["#d946ef", "#6366f1"],
+ ["#f43f5e", "#a855f7"],
+ ["#059669", "#0284c7"],
+ ["#fb7185", "#fbbf24"],
+ ["#7c3aed", "#2563eb"],
+ ["#16a34a", "#65a30d"],
+ ["#be185d", "#4c1d95"],
+ ["#0891b2", "#4338ca"],
+ ["#ea580c", "#b91c1c"],
+ ["#15803d", "#0d9488"],
+ ["#9333ea", "#db2777"],
+];
+
+const ASSIGNMENTS = new Map();
+let nextSlot = 0;
+
+function gradientFor(id: string): readonly [string, string] {
+ let slot = ASSIGNMENTS.get(id);
+ if (slot == null) {
+ slot = nextSlot++;
+ ASSIGNMENTS.set(id, slot);
+ }
+ const idx = slot % REPO_GRADIENTS.length;
+ return REPO_GRADIENTS[idx] ?? REPO_GRADIENTS[0]!;
+}
+
+interface RepoLike {
+ id: string;
+ name: string;
+ /** Auto-detected logo paths from the repo scanner — when present the avatar
+ * renders the actual image and falls back to the gradient on load error. */
+ logoPath?: string | null;
+ logoDarkPath?: string | null;
+}
+
+interface Props {
+ repo: RepoLike;
+ size?: number;
+ radius?: number;
+}
+
+function RepoAvatar({ repo, size = 24, radius = 6 }: Props) {
+ const [c1, c2] = gradientFor(repo.id || repo.name);
+ const gradient = `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`;
+ const cleaned = repo.name.replace(/^[\W_]+/, "") || repo.name;
+ const letter = cleaned.charAt(0).toUpperCase();
+ const logoUri = useRepoLogo(repo.logoPath, repo.logoDarkPath);
+ const [failed, setFailed] = useState(false);
+ return (
+ setFailed(true)}
+ />
+ );
+}
+
+export default RepoAvatar;
diff --git a/app/src/components/atoms/brand/Logo/Logo.stories.tsx b/app/src/components/atoms/brand/Logo/Logo.stories.tsx
new file mode 100644
index 0000000..78029ec
--- /dev/null
+++ b/app/src/components/atoms/brand/Logo/Logo.stories.tsx
@@ -0,0 +1,25 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import Logo from "@/components/atoms/brand/Logo";
+
+const Stage = styled(Box)({ width: 64, height: 64 });
+
+const meta: Meta = {
+ title: "Atoms/Brand/Logo",
+ component: Logo,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+ ),
+};
diff --git a/app/src/components/atoms/brand/Logo/Logo.test.tsx b/app/src/components/atoms/brand/Logo/Logo.test.tsx
new file mode 100644
index 0000000..b5f3adb
--- /dev/null
+++ b/app/src/components/atoms/brand/Logo/Logo.test.tsx
@@ -0,0 +1,19 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import Logo from "@/components/atoms/brand/Logo";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("Logo", () => {
+ it("mounts inside its wrapper", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const wrap = getByTestId(COMPONENT_TEST_IDS.atoms.logo.wrap);
+ expect(wrap.querySelector("svg")).not.toBeNull();
+ });
+});
diff --git a/app/src/components/atoms/brand/Logo/index.tsx b/app/src/components/atoms/brand/Logo/index.tsx
new file mode 100644
index 0000000..c8f6d95
--- /dev/null
+++ b/app/src/components/atoms/brand/Logo/index.tsx
@@ -0,0 +1,62 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import IconDev from "@/assets/logos/recrest-icon-dev.svg?react";
+import IconTransparentDark from "@/assets/logos/recrest-icon-transparent-dark.svg?react";
+import IconTransparentWhite from "@/assets/logos/recrest-icon-transparent-white.svg?react";
+
+export interface LogoProps {
+ className?: string;
+ title?: string;
+}
+
+const Root = styled(Box)({
+ position: "relative",
+ display: "inline-block",
+ lineHeight: 0,
+}) as typeof Box;
+
+const DevMark = styled(IconDev)({
+ display: "block",
+ width: "100%",
+ height: "100%",
+});
+
+const LightVariant = styled(IconTransparentDark)({
+ display: "block",
+ width: "100%",
+ height: "100%",
+ 'html[data-theme="dark"] &': {
+ display: "none",
+ },
+});
+
+const DarkVariant = styled(IconTransparentWhite)({
+ display: "none",
+ width: "100%",
+ height: "100%",
+ 'html[data-theme="dark"] &': {
+ display: "block",
+ },
+});
+
+// `import.meta.env.DEV` is true for both `yarn tauri:dev` and `yarn dev:web`,
+// so the orange `>` dev badge appears in every dev workflow — the favicon
+// gate in `useFaviconSync` uses the same flag so tab + sidebar stay in sync.
+function Logo({ className, title = "Recrest" }: LogoProps) {
+ if (import.meta.env.DEV) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+
+ );
+}
+
+export default Logo;
diff --git a/app/src/components/atoms/brand/Mascot/Mascot.stories.tsx b/app/src/components/atoms/brand/Mascot/Mascot.stories.tsx
new file mode 100644
index 0000000..2a2fa6d
--- /dev/null
+++ b/app/src/components/atoms/brand/Mascot/Mascot.stories.tsx
@@ -0,0 +1,25 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import Mascot from "@/components/atoms/brand/Mascot";
+
+const meta = {
+ title: "Atoms/Brand/Mascot",
+ component: Mascot,
+ args: { size: 128 },
+ argTypes: {
+ variant: {
+ control: "select",
+ options: ["shrugging", "snoozing", "celebrating", "searching", "waving"],
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Shrugging: Story = { args: { variant: "shrugging" } };
+export const Snoozing: Story = { args: { variant: "snoozing" } };
+export const Celebrating: Story = { args: { variant: "celebrating" } };
+export const Searching: Story = { args: { variant: "searching" } };
+export const Waving: Story = { args: { variant: "waving" } };
diff --git a/app/src/components/atoms/brand/Mascot/Mascot.test.tsx b/app/src/components/atoms/brand/Mascot/Mascot.test.tsx
new file mode 100644
index 0000000..faf8329
--- /dev/null
+++ b/app/src/components/atoms/brand/Mascot/Mascot.test.tsx
@@ -0,0 +1,18 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import Mascot from "@/components/atoms/brand/Mascot";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("Mascot", () => {
+ it("renders an svg inside its wrapper", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.mascot.wrap).querySelector("svg")).not.toBeNull();
+ });
+});
diff --git a/app/src/components/atoms/Mascot/index.tsx b/app/src/components/atoms/brand/Mascot/index.tsx
similarity index 66%
rename from app/src/components/atoms/Mascot/index.tsx
rename to app/src/components/atoms/brand/Mascot/index.tsx
index 5b89f3f..cc9bd64 100644
--- a/app/src/components/atoms/Mascot/index.tsx
+++ b/app/src/components/atoms/brand/Mascot/index.tsx
@@ -1,4 +1,4 @@
-import { cn } from "@/lib/utils";
+import { useTheme } from "@mui/material/styles";
/**
* Recrest's empty-state mascot. A rounded-square character echoing the app
@@ -8,7 +8,8 @@ import { cn } from "@/lib/utils";
* shrugging = generic empty).
*
* Stroke inherits `currentColor` so callers control the ink via CSS `color`.
- * The crest uses `--accent` directly for a stable brand touch across themes.
+ * Accent colours are pulled from the MUI theme so dark/light/oled/glassy all
+ * tint the crest + cheeks consistently with the rest of the UI.
*/
export type MascotVariant = "snoozing" | "celebrating" | "searching" | "waving" | "shrugging";
@@ -19,7 +20,22 @@ interface MascotProps {
title?: string;
}
-export function Mascot({ variant = "shrugging", size = 112, className, title }: MascotProps) {
+interface MascotPalette {
+ accent: string;
+ accentWeak: string;
+ accentInk: string;
+ surface: string;
+}
+
+function Mascot({ variant = "shrugging", size = 112, className, title }: MascotProps) {
+ const theme = useTheme();
+ const palette: MascotPalette = {
+ accent: theme.palette.primary.main,
+ accentWeak: `color-mix(in srgb, ${theme.palette.primary.main} 16%, transparent)`,
+ accentInk: theme.palette.primary.dark,
+ surface: theme.palette.surface.interface.base,
+ };
+
return (
-
-
-
-
-
+
+
+
+
+
);
}
-/* ───────── Body: rounded-square torso that also houses the head ───────── */
-
-function MascotBody() {
+function MascotBody({ palette }: { palette: MascotPalette }) {
return (
<>
- {/* Soft shadow puddle */}
- {/* Torso/head combo */}
- {/* Subtle belly hairline to give depth */}
-
+
);
case "searching":
@@ -124,11 +129,9 @@ function MascotFace({ variant }: { variant: MascotVariant }) {
- {/* open, cheerful mouth */}
- {/* blush */}
-
-
+
+
);
case "shrugging":
@@ -143,26 +146,30 @@ function MascotFace({ variant }: { variant: MascotVariant }) {
}
}
-/* ───────── Arms: different gestures per mood ───────── */
-
-function MascotArms({ variant }: { variant: MascotVariant }) {
- const base = {
- stroke: "currentColor",
- strokeWidth: 4,
- fill: "var(--accent-weak)",
- } as const;
+function MascotArms({ variant, palette }: { variant: MascotVariant; palette: MascotPalette }) {
+ const armFill = palette.accentWeak;
switch (variant) {
case "snoozing":
- // Arms crossed, resting on the belly
return (
-
-
+
+
);
case "celebrating":
- // Arms up in the air, little "hand" circles at the tips
return (
-
-
+
+
);
case "searching":
- // One arm holds a magnifying glass out to the side
return (
- {/* Left arm resting */}
- {/* Right arm holds the magnifier */}
- {/* Glass shine */}
);
case "waving":
- // One arm waving overhead, other at the side
return (
-
+
);
case "shrugging":
default:
- // Arms out to the sides, palms up
return (
-
-
+
+
);
}
}
-/* ───────── Decor: the little floaty extras (z's, sparks, dots) ───────── */
-
-function MascotDecor({ variant }: { variant: MascotVariant }) {
+function MascotDecor({ variant, palette }: { variant: MascotVariant; palette: MascotPalette }) {
switch (variant) {
case "snoozing":
return (
);
case "celebrating":
- // Spark bursts either side
return (
-
+
-
-
+
+
);
case "searching":
- // Tiny question mark / dotted trail above
return (
@@ -307,9 +304,14 @@ function MascotDecor({ variant }: { variant: MascotVariant }) {
);
case "waving":
- // Little motion lines near the raised hand
return (
-
+
@@ -319,3 +321,5 @@ function MascotDecor({ variant }: { variant: MascotVariant }) {
return null;
}
}
+
+export default Mascot;
diff --git a/app/src/components/atoms/buttons/GeneralButton/GeneralButton.stories.tsx b/app/src/components/atoms/buttons/GeneralButton/GeneralButton.stories.tsx
new file mode 100644
index 0000000..fc5b70c
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralButton/GeneralButton.stories.tsx
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralButton from "@/components/atoms/buttons/GeneralButton";
+
+const meta = {
+ title: "Atoms/Buttons/GeneralButton",
+ component: GeneralButton,
+ args: { children: "Click me" },
+ argTypes: {
+ variant: {
+ control: "select",
+ options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
+ },
+ size: { control: "select", options: ["default", "sm", "lg"] },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Destructive: Story = { args: { variant: "destructive", children: "Delete" } };
+export const Outline: Story = { args: { variant: "outline" } };
+export const Secondary: Story = { args: { variant: "secondary" } };
+export const Ghost: Story = { args: { variant: "ghost" } };
+export const Link: Story = { args: { variant: "link" } };
+export const Loading: Story = { args: { loading: true } };
+export const Small: Story = { args: { size: "sm" } };
+export const Large: Story = { args: { size: "lg" } };
diff --git a/app/src/components/atoms/buttons/GeneralButton/GeneralButton.test.tsx b/app/src/components/atoms/buttons/GeneralButton/GeneralButton.test.tsx
new file mode 100644
index 0000000..9030834
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralButton/GeneralButton.test.tsx
@@ -0,0 +1,28 @@
+import { fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import GeneralButton from "@/components/atoms/buttons/GeneralButton";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralButton", () => {
+ it("fires onClick", () => {
+ const onClick = vi.fn();
+ const { getByTestId } = renderWithTheme(
+
+ Save
+ ,
+ );
+ fireEvent.click(getByTestId(COMPONENT_TEST_IDS.atoms.button.root));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("is disabled while loading", () => {
+ const { getByTestId } = renderWithTheme(
+
+ Save
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.button.root)).toBeDisabled();
+ });
+});
diff --git a/app/src/components/atoms/buttons/GeneralButton/index.tsx b/app/src/components/atoms/buttons/GeneralButton/index.tsx
new file mode 100644
index 0000000..6a9c3d7
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralButton/index.tsx
@@ -0,0 +1,100 @@
+import { forwardRef } from "react";
+
+import { Button, type ButtonProps as MuiButtonProps } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import GeneralCircularLoader, {
+ CircularLoaderSize,
+} from "@/components/atoms/loaders/GeneralCircularLoader";
+
+export type GeneralButtonVariant =
+ | "default"
+ | "destructive"
+ | "outline"
+ | "secondary"
+ | "ghost"
+ | "link";
+
+export type GeneralButtonSize = "default" | "sm" | "lg";
+
+export interface GeneralButtonProps extends Omit {
+ variant?: GeneralButtonVariant;
+ size?: GeneralButtonSize;
+ loading?: boolean;
+}
+
+interface StyledButtonProps {
+ variantKind?: GeneralButtonVariant;
+}
+
+function mapVariant(variant: GeneralButtonVariant): {
+ muiVariant: MuiButtonProps["variant"];
+ muiColor: MuiButtonProps["color"];
+} {
+ switch (variant) {
+ case "destructive":
+ return { muiVariant: "contained", muiColor: "error" };
+ case "outline":
+ return { muiVariant: "outlined", muiColor: "primary" };
+ case "secondary":
+ return { muiVariant: "contained", muiColor: "secondary" };
+ case "ghost":
+ return { muiVariant: "text", muiColor: "inherit" };
+ case "link":
+ return { muiVariant: "text", muiColor: "primary" };
+ case "default":
+ default:
+ return { muiVariant: "contained", muiColor: "primary" };
+ }
+}
+
+function mapSize(size: GeneralButtonSize): MuiButtonProps["size"] {
+ if (size === "sm") return "small";
+ if (size === "lg") return "large";
+ return "medium";
+}
+
+const StyledButton = styled(Button, {
+ shouldForwardProp: (p) => p !== "variantKind",
+})(({ variantKind }) => ({
+ textTransform: "none",
+ ...(variantKind === "link"
+ ? {
+ textDecoration: "underline",
+ "&:hover": { textDecoration: "underline" },
+ }
+ : {}),
+}));
+
+const GeneralButton = forwardRef(function GeneralButton(
+ {
+ variant = "default",
+ size = "default",
+ loading = false,
+ disabled,
+ children,
+ startIcon,
+ ...rest
+ },
+ ref,
+) {
+ const { muiVariant, muiColor } = mapVariant(variant);
+ return (
+ : startIcon
+ }
+ {...rest}
+ >
+ {children}
+
+ );
+});
+
+export default GeneralButton;
diff --git a/app/src/components/atoms/buttons/GeneralButtonGroup/GeneralButtonGroup.stories.tsx b/app/src/components/atoms/buttons/GeneralButtonGroup/GeneralButtonGroup.stories.tsx
new file mode 100644
index 0000000..b91999b
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralButtonGroup/GeneralButtonGroup.stories.tsx
@@ -0,0 +1,35 @@
+import { useState } from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralButtonGroup, {
+ GeneralButtonGroupItem,
+} from "@/components/atoms/buttons/GeneralButtonGroup";
+
+function DefaultDemo() {
+ const [value, setValue] = useState("a");
+ return (
+ v && setValue(v)}
+ >
+ All
+ Active
+ Archived
+
+ );
+}
+
+const meta: Meta = {
+ title: "Atoms/Buttons/GeneralButtonGroup",
+ component: GeneralButtonGroup,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ,
+};
diff --git a/app/src/components/atoms/buttons/GeneralButtonGroup/GeneralButtonGroup.test.tsx b/app/src/components/atoms/buttons/GeneralButtonGroup/GeneralButtonGroup.test.tsx
new file mode 100644
index 0000000..f4c8787
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralButtonGroup/GeneralButtonGroup.test.tsx
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+
+import GeneralButtonGroup, {
+ GeneralButtonGroupItem,
+} from "@/components/atoms/buttons/GeneralButtonGroup";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralButtonGroup", () => {
+ it("renders all segment items", () => {
+ const { getByTestId } = renderWithTheme(
+ {}}
+ >
+
+ All
+
+
+ Active
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.buttonGroup.root)).toBeInTheDocument();
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.buttonGroup.segA)).toBeInTheDocument();
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.buttonGroup.segB)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/buttons/GeneralButtonGroup/index.tsx b/app/src/components/atoms/buttons/GeneralButtonGroup/index.tsx
new file mode 100644
index 0000000..44200db
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralButtonGroup/index.tsx
@@ -0,0 +1,149 @@
+import { Children, cloneElement, isValidElement } from "react";
+
+import { ToggleButton, ToggleButtonGroup, type ToggleButtonGroupProps } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+export type GeneralButtonGroupShape = "pill" | "square";
+export type GeneralButtonGroupSize = "md" | "sm" | "xs";
+
+export interface GeneralButtonGroupProps extends ToggleButtonGroupProps {
+ shape?: GeneralButtonGroupShape;
+ /**
+ * `md` (default): 38px buttons — header / page toolbars.
+ * `sm`: 32px buttons — popovers, scope rows.
+ * `xs`: 30px buttons — page toolbars that sit next to `FilterButton` etc.
+ */
+ density?: GeneralButtonGroupSize;
+}
+
+const HEIGHT_BY_DENSITY: Record = {
+ md: 38,
+ sm: 32,
+ xs: 30,
+};
+
+const PADDING_BY_DENSITY: Record = {
+ md: "0 14px",
+ sm: "0 12px",
+ xs: "0 10px",
+};
+
+interface StyledProps {
+ shape?: GeneralButtonGroupShape;
+ density?: GeneralButtonGroupSize;
+}
+
+const SHOULD_FORWARD = (prop: PropertyKey) => prop !== "shape" && prop !== "density";
+
+/**
+ * Segmented button group with **one continuous border** around the whole
+ * tile and 1px vertical dividers between adjacent segments. The active
+ * segment is signalled only by a subtle background fill — the outer border
+ * stays the same colour regardless of selection so the tile reads as one
+ * coherent control, not "buttons next to each other".
+ *
+ * Works with any number of segments (2, 5, 6, …). Adjacent segments share
+ * a single 1px divider via a `border-left` on every segment except the
+ * first. The outer border is owned by the group element so segment
+ * borders never appear at the outer edge.
+ */
+const StyledGroup = styled(ToggleButtonGroup, { shouldForwardProp: SHOULD_FORWARD })(
+ ({ theme, shape = "square", density = "md" }) => ({
+ display: "inline-flex",
+ alignItems: "stretch",
+ gap: 0,
+ height: HEIGHT_BY_DENSITY[density],
+ backgroundColor: theme.palette.background.paper,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: shape === "square" ? 8 : 999,
+ padding: 0,
+ fontFamily: "inherit",
+ flexWrap: "nowrap",
+ overflow: "hidden",
+ transition: "border-color 150ms ease",
+ "&:hover": {
+ borderColor: theme.palette.border.hover,
+ },
+ "&.MuiToggleButtonGroup-vertical": {
+ flexDirection: "column",
+ height: "auto",
+ width: HEIGHT_BY_DENSITY[density],
+ },
+ }),
+);
+
+const StyledToggle = styled(ToggleButton, { shouldForwardProp: SHOULD_FORWARD })(
+ ({ theme, density = "md" }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 6,
+ height: "100%",
+ padding: PADDING_BY_DENSITY[density],
+ // No outer borders — those belong to the group. Only a 1px left
+ // divider between adjacent siblings to mark segment boundaries.
+ border: 0,
+ borderRadius: 0,
+ backgroundColor: "transparent",
+ color: theme.palette.text.secondary,
+ fontFamily: "inherit",
+ fontSize: 12,
+ fontWeight: 500,
+ lineHeight: 1,
+ cursor: "pointer",
+ whiteSpace: "nowrap",
+ textTransform: "none",
+ transition: "color 150ms ease, background-color 150ms ease",
+
+ // Horizontal group: 1px left divider on every segment except the first.
+ ".MuiToggleButtonGroup-horizontal &:not(:first-of-type)": {
+ borderLeft: `1px solid ${theme.palette.divider}`,
+ },
+ // Vertical group: 1px top divider on every segment except the first.
+ ".MuiToggleButtonGroup-vertical &:not(:first-of-type)": {
+ borderTop: `1px solid ${theme.palette.divider}`,
+ },
+
+ "&:hover": {
+ color: theme.palette.text.primary,
+ backgroundColor: theme.palette.surface.interface.active,
+ },
+ "&.Mui-selected": {
+ // Active = subtle fill only. No border-colour change, no font-weight
+ // jump — the tile still reads as one continuous control.
+ color: theme.palette.text.primary,
+ backgroundColor: theme.palette.surface.interface.active,
+ },
+ "&.Mui-selected:hover": {
+ backgroundColor: theme.palette.surface.interface.active,
+ },
+ "&.Mui-disabled": {
+ opacity: 0.45,
+ cursor: "default",
+ },
+ }),
+);
+
+function GeneralButtonGroup({
+ shape = "square",
+ density = "md",
+ children,
+ ...rest
+}: GeneralButtonGroupProps) {
+ const decorated = Children.map(children, (child) => {
+ if (!isValidElement(child)) return child;
+ return cloneElement(child as React.ReactElement, {
+ shape: (child.props as StyledProps).shape ?? shape,
+ density: (child.props as StyledProps).density ?? density,
+ });
+ });
+
+ return (
+
+ {decorated}
+
+ );
+}
+
+export { StyledToggle as GeneralButtonGroupItem };
+export default GeneralButtonGroup;
diff --git a/app/src/components/atoms/buttons/GeneralIconButton/GeneralIconButton.stories.tsx b/app/src/components/atoms/buttons/GeneralIconButton/GeneralIconButton.stories.tsx
new file mode 100644
index 0000000..18b26c9
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralIconButton/GeneralIconButton.stories.tsx
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { X } from "lucide-react";
+
+import GeneralIconButton, {
+ IconButtonShape,
+ IconButtonSize,
+ IconButtonTone,
+ IconButtonVariant,
+} from "@/components/atoms/buttons/GeneralIconButton";
+
+const meta = {
+ title: "Atoms/Buttons/GeneralIconButton",
+ component: GeneralIconButton,
+ args: {
+ icon: ,
+ "aria-label": "Close",
+ },
+ argTypes: {
+ size: { control: "select", options: Object.values(IconButtonSize) },
+ variant: { control: "select", options: Object.values(IconButtonVariant) },
+ shape: { control: "select", options: Object.values(IconButtonShape) },
+ tone: { control: "select", options: Object.values(IconButtonTone) },
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Outline: Story = { args: { variant: IconButtonVariant.OUTLINE } };
+export const Ghost: Story = { args: { variant: IconButtonVariant.GHOST } };
+export const Circle: Story = { args: { shape: IconButtonShape.CIRCLE } };
+export const Danger: Story = { args: { tone: IconButtonTone.DANGER } };
+export const Large: Story = { args: { size: IconButtonSize.LG } };
diff --git a/app/src/components/atoms/buttons/GeneralIconButton/GeneralIconButton.test.tsx b/app/src/components/atoms/buttons/GeneralIconButton/GeneralIconButton.test.tsx
new file mode 100644
index 0000000..5ee9a2b
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralIconButton/GeneralIconButton.test.tsx
@@ -0,0 +1,23 @@
+import { fireEvent } from "@testing-library/react";
+import { X } from "lucide-react";
+import { describe, expect, it, vi } from "vitest";
+
+import GeneralIconButton from "@/components/atoms/buttons/GeneralIconButton";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralIconButton", () => {
+ it("fires onClick", () => {
+ const onClick = vi.fn();
+ const { getByTestId } = renderWithTheme(
+ }
+ />,
+ );
+ fireEvent.click(getByTestId(COMPONENT_TEST_IDS.atoms.iconButton.root));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/components/atoms/buttons/GeneralIconButton/index.tsx b/app/src/components/atoms/buttons/GeneralIconButton/index.tsx
new file mode 100644
index 0000000..165c6cc
--- /dev/null
+++ b/app/src/components/atoms/buttons/GeneralIconButton/index.tsx
@@ -0,0 +1,178 @@
+import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from "react";
+
+import { styled } from "@mui/material/styles";
+
+/**
+ * Size of the icon-button hitbox. The icon itself sits in the middle — pass
+ * the matching `size` to your Lucide icon (see `ICON_BUTTON_ICON_SIZES`).
+ *
+ * Adding a size: append the literal to the const, add an entry to both records.
+ */
+export const IconButtonSize = {
+ XS: "xs",
+ SM: "sm",
+ MD: "md",
+ LG: "lg",
+} as const;
+
+export type IconButtonSize = (typeof IconButtonSize)[keyof typeof IconButtonSize];
+
+/** Pixel hitbox per size. */
+export const ICON_BUTTON_HITBOX: Record = {
+ [IconButtonSize.XS]: 16,
+ [IconButtonSize.SM]: 22,
+ [IconButtonSize.MD]: 28,
+ [IconButtonSize.LG]: 36,
+};
+
+/** Suggested icon `size` prop (lucide-react) that visually centres in each hitbox. */
+export const ICON_BUTTON_ICON_SIZES: Record = {
+ [IconButtonSize.XS]: 11,
+ [IconButtonSize.SM]: 13,
+ [IconButtonSize.MD]: 14,
+ [IconButtonSize.LG]: 16,
+};
+
+export const IconButtonVariant = {
+ /** Transparent surface, hover changes icon colour only. */
+ GHOST: "ghost",
+ /** Transparent surface, hover adds a subtle background tint. Default. */
+ SUBTLE: "subtle",
+ /** Persistent border, hover lifts background + border. */
+ OUTLINE: "outline",
+} as const;
+
+export type IconButtonVariant = (typeof IconButtonVariant)[keyof typeof IconButtonVariant];
+
+export const IconButtonShape = {
+ /** Pill / round button — fits free-floating clear/close glyphs. */
+ CIRCLE: "circle",
+ /** Rounded-square — fits toolbar rows / chrome buttons. */
+ SQUARE: "square",
+} as const;
+
+export type IconButtonShape = (typeof IconButtonShape)[keyof typeof IconButtonShape];
+
+export const IconButtonTone = {
+ NEUTRAL: "neutral",
+ PRIMARY: "primary",
+ DANGER: "danger",
+} as const;
+
+export type IconButtonTone = (typeof IconButtonTone)[keyof typeof IconButtonTone];
+
+interface RootProps {
+ $size: IconButtonSize;
+ $variant: IconButtonVariant;
+ $shape: IconButtonShape;
+ $tone: IconButtonTone;
+}
+
+// eslint-disable-next-line no-restricted-syntax -- native required for accessibility (focus, keyboard, form-association)
+const Root = styled("button", {
+ shouldForwardProp: (p) => p !== "$size" && p !== "$variant" && p !== "$shape" && p !== "$tone",
+})(({ theme, $size, $variant, $shape, $tone }) => {
+ const hit = ICON_BUTTON_HITBOX[$size];
+ const baseColor =
+ $tone === IconButtonTone.PRIMARY
+ ? theme.palette.primary.main
+ : $tone === IconButtonTone.DANGER
+ ? theme.palette.error.main
+ : theme.palette.text.information;
+ const hoverColor =
+ $tone === IconButtonTone.PRIMARY
+ ? theme.palette.primary.dark
+ : $tone === IconButtonTone.DANGER
+ ? theme.palette.error.dark
+ : theme.palette.text.primary;
+
+ return {
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: hit,
+ height: hit,
+ padding: 0,
+ cursor: "pointer",
+ fontFamily: "inherit",
+ flexShrink: 0,
+ borderRadius: $shape === IconButtonShape.CIRCLE ? "50%" : 8,
+ border: $variant === IconButtonVariant.OUTLINE ? `1px solid ${theme.palette.divider}` : 0,
+ backgroundColor:
+ $variant === IconButtonVariant.OUTLINE ? theme.palette.surface.interface.base : "transparent",
+ color: baseColor,
+ transition: "background-color 120ms ease, color 120ms ease, border-color 120ms ease",
+ "&:hover:not(:disabled)": {
+ color: hoverColor,
+ backgroundColor:
+ $variant === IconButtonVariant.GHOST
+ ? "transparent"
+ : theme.palette.surface.interface.active,
+ borderColor: $variant === IconButtonVariant.OUTLINE ? theme.palette.border.hover : undefined,
+ },
+ "&:focus-visible": {
+ outline: `2px solid ${theme.palette.primary.main}`,
+ outlineOffset: 2,
+ },
+ "&:disabled": {
+ opacity: 0.5,
+ cursor: "not-allowed",
+ },
+ 'html[data-reduced-motion="true"] &': {
+ transition: "none",
+ },
+ };
+});
+
+export interface GeneralIconButtonProps extends Omit<
+ ButtonHTMLAttributes,
+ "ref" | "children"
+> {
+ /** The icon to render inside the hitbox. Pass the Lucide (or any other) icon
+ * element pre-sized via the `iconSize` lookup `ICON_BUTTON_ICON_SIZES[size]`. */
+ icon: ReactNode;
+ size?: IconButtonSize;
+ variant?: IconButtonVariant;
+ shape?: IconButtonShape;
+ tone?: IconButtonTone;
+ /** Required accessibility label — icon-only buttons must announce their purpose. */
+ "aria-label": string;
+}
+
+/**
+ * Single canonical primitive for icon-only buttons. Every pure-icon button
+ * across the app — search clear, dialog close, tooltip trigger, row actions,
+ * sidebar collapse — composes this. Custom inline `styled("button")` icon
+ * buttons are forbidden; extend `IconButtonSize`/`IconButtonVariant` if a new
+ * shape is needed.
+ */
+const GeneralIconButton = forwardRef(
+ function GeneralIconButton(
+ {
+ icon,
+ size = IconButtonSize.MD,
+ variant = IconButtonVariant.SUBTLE,
+ shape = IconButtonShape.SQUARE,
+ tone = IconButtonTone.NEUTRAL,
+ type = "button",
+ ...rest
+ },
+ ref,
+ ) {
+ return (
+
+ {icon}
+
+ );
+ },
+);
+
+export default GeneralIconButton;
diff --git a/app/src/components/atoms/buttons/OpenInIdeButton/OpenInIdeButton.stories.tsx b/app/src/components/atoms/buttons/OpenInIdeButton/OpenInIdeButton.stories.tsx
new file mode 100644
index 0000000..e326814
--- /dev/null
+++ b/app/src/components/atoms/buttons/OpenInIdeButton/OpenInIdeButton.stories.tsx
@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import OpenInIdeButton, { OpenInIdeVariant } from "@/components/atoms/buttons/OpenInIdeButton";
+
+const meta: Meta = {
+ title: "Atoms/Buttons/OpenInIdeButton",
+ component: OpenInIdeButton,
+ parameters: { layout: "centered" },
+ args: { repoId: "demo-repo" },
+ argTypes: {
+ variant: {
+ control: { type: "inline-radio" },
+ options: [OpenInIdeVariant.ICON, OpenInIdeVariant.BUTTON],
+ },
+ ideId: {
+ control: { type: "select" },
+ options: ["vscode", "vscode-insiders", "cursor", "webstorm", "idea", "jetbrains-toolbox"],
+ },
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Icon: Story = { args: { variant: OpenInIdeVariant.ICON } };
+export const Button: Story = { args: { variant: OpenInIdeVariant.BUTTON } };
+export const IntelliJ: Story = {
+ args: { variant: OpenInIdeVariant.BUTTON, ideId: "idea" },
+};
diff --git a/app/src/components/atoms/buttons/OpenInIdeButton/OpenInIdeButton.test.tsx b/app/src/components/atoms/buttons/OpenInIdeButton/OpenInIdeButton.test.tsx
new file mode 100644
index 0000000..efb9fcf
--- /dev/null
+++ b/app/src/components/atoms/buttons/OpenInIdeButton/OpenInIdeButton.test.tsx
@@ -0,0 +1,19 @@
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import OpenInIdeButton, { OpenInIdeVariant } from "@/components/atoms/buttons/OpenInIdeButton";
+import { renderWithTheme } from "@/test/utils";
+
+describe("OpenInIdeButton", () => {
+ it("renders an icon-only trigger by default", () => {
+ renderWithTheme( );
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ });
+
+ it("renders a labelled button in BUTTON variant", () => {
+ renderWithTheme(
+ ,
+ );
+ expect(screen.getByRole("button", { name: /open in ide/i })).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/buttons/OpenInIdeButton/index.tsx b/app/src/components/atoms/buttons/OpenInIdeButton/index.tsx
new file mode 100644
index 0000000..3d5ec36
--- /dev/null
+++ b/app/src/components/atoms/buttons/OpenInIdeButton/index.tsx
@@ -0,0 +1,87 @@
+import { useTranslation } from "react-i18next";
+
+import { TauriCommand } from "@recrest/shared";
+
+import { toast } from "sonner";
+
+import IdeIcon from "@/assets/icons/IdeIcon";
+import GeneralButton from "@/components/atoms/buttons/GeneralButton";
+import GeneralIconButton, { IconButtonSize } from "@/components/atoms/buttons/GeneralIconButton";
+import GeneralTooltip from "@/components/atoms/feedback/GeneralTooltip";
+import { I18nNamespace } from "@/lib/constants/i18n.constants";
+import type { IdeId } from "@/lib/constants/ides.constants";
+import { invoke, isTauri } from "@/lib/tauri";
+
+export const OpenInIdeVariant = {
+ /** Compact icon-only chip — used in repo rows / cards. */
+ ICON: "icon",
+ /** Labelled button — used as primary CTA in detail headers. */
+ BUTTON: "button",
+} as const;
+
+export type OpenInIdeVariant = (typeof OpenInIdeVariant)[keyof typeof OpenInIdeVariant];
+
+export interface OpenInIdeButtonProps {
+ repoId: string;
+ variant?: OpenInIdeVariant;
+ /** IDE slug used by `IdeIcon`. Defaults to `vscode` until the settings-driven
+ * IDE preference is wired through; passing this explicitly overrides it. */
+ ideId?: IdeId;
+ /** Label shown in tooltip (icon variant) or as button text (button variant).
+ * Defaults to the i18n key `actions.open_in_ide`. */
+ label?: string;
+ iconSize?: IconButtonSize;
+ className?: string;
+ "data-testid"?: string;
+}
+
+function OpenInIdeButton({
+ repoId,
+ variant = OpenInIdeVariant.ICON,
+ ideId = "vscode",
+ label,
+ iconSize = IconButtonSize.MD,
+ className,
+ "data-testid": testId,
+}: OpenInIdeButtonProps) {
+ const { t } = useTranslation();
+ const resolvedLabel = label ?? t("actions.open_in_ide");
+
+ const onClick = async () => {
+ if (!isTauri()) return;
+ try {
+ await invoke(TauriCommand.OPEN_IN_IDE, { repoId });
+ } catch (err) {
+ toast.error((err as { message?: string })?.message ?? `${resolvedLabel} failed`);
+ }
+ };
+
+ if (variant === OpenInIdeVariant.BUTTON) {
+ return (
+ void onClick()}
+ className={className}
+ data-testid={testId}
+ startIcon={ }
+ >
+ {resolvedLabel}
+
+ );
+ }
+
+ return (
+
+ void onClick()}
+ icon={ }
+ className={className}
+ data-testid={testId}
+ />
+
+ );
+}
+
+export default OpenInIdeButton;
diff --git a/app/src/components/atoms/buttons/ScopeButtonGroup/ScopeButtonGroup.stories.tsx b/app/src/components/atoms/buttons/ScopeButtonGroup/ScopeButtonGroup.stories.tsx
new file mode 100644
index 0000000..45ec87a
--- /dev/null
+++ b/app/src/components/atoms/buttons/ScopeButtonGroup/ScopeButtonGroup.stories.tsx
@@ -0,0 +1,28 @@
+import { useState } from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import ScopeButtonGroup from "@/components/atoms/buttons/ScopeButtonGroup";
+import { RepoAddScope } from "@/lib/constants/repoAddScope.constants";
+
+function ExpandedDemo() {
+ const [v, setV] = useState(RepoAddScope.LOCAL);
+ return ;
+}
+
+function CollapsedDemo() {
+ const [v, setV] = useState(RepoAddScope.LOCAL);
+ return ;
+}
+
+const meta: Meta = {
+ title: "Atoms/Buttons/ScopeButtonGroup",
+ component: ScopeButtonGroup,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Expanded: Story = { render: () => };
+export const Collapsed: Story = { render: () => };
diff --git a/app/src/components/atoms/buttons/ScopeButtonGroup/ScopeButtonGroup.test.tsx b/app/src/components/atoms/buttons/ScopeButtonGroup/ScopeButtonGroup.test.tsx
new file mode 100644
index 0000000..8b2666f
--- /dev/null
+++ b/app/src/components/atoms/buttons/ScopeButtonGroup/ScopeButtonGroup.test.tsx
@@ -0,0 +1,18 @@
+import { fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import ScopeButtonGroup from "@/components/atoms/buttons/ScopeButtonGroup";
+import { RepoAddScope } from "@/lib/constants/repoAddScope.constants";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("ScopeButtonGroup", () => {
+ it("calls onChange with the next scope when the user toggles", () => {
+ const onChange = vi.fn();
+ const { getByTestId } = renderWithProviders(
+ ,
+ );
+ fireEvent.click(getByTestId(TEST_IDS.repos.addScope.global));
+ expect(onChange).toHaveBeenCalledWith(RepoAddScope.GLOBAL);
+ });
+});
diff --git a/app/src/components/atoms/buttons/ScopeButtonGroup/index.tsx b/app/src/components/atoms/buttons/ScopeButtonGroup/index.tsx
new file mode 100644
index 0000000..74898d2
--- /dev/null
+++ b/app/src/components/atoms/buttons/ScopeButtonGroup/index.tsx
@@ -0,0 +1,130 @@
+import { useTranslation } from "react-i18next";
+
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import { Globe, Monitor } from "lucide-react";
+
+import GeneralButtonGroup, {
+ GeneralButtonGroupItem,
+} from "@/components/atoms/buttons/GeneralButtonGroup";
+import GeneralTooltip from "@/components/atoms/feedback/GeneralTooltip";
+import { I18nNamespace } from "@/lib/constants/i18n.constants";
+import { RepoAddScope } from "@/lib/constants/repoAddScope.constants";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+
+export { RepoAddScope } from "@/lib/constants/repoAddScope.constants";
+
+interface Props {
+ value: RepoAddScope;
+ onChange: (next: RepoAddScope) => void;
+ /**
+ * `expanded` (default) renders a horizontal Local / Global segment with
+ * icon + label.
+ * `collapsed` renders a vertical 2-button stack of icon-only toggles so the
+ * control fits inside a 38px-wide collapsed sidebar.
+ */
+ variant?: "expanded" | "collapsed";
+}
+
+const FullWidthGroup = styled(GeneralButtonGroup)({
+ width: "100%",
+ display: "flex",
+ "& .MuiToggleButtonGroup-grouped": {
+ flex: "1 1 0",
+ justifyContent: "center",
+ },
+});
+
+const StackedGroup = styled(GeneralButtonGroup)(({ theme }) => ({
+ "& .MuiToggleButtonGroup-grouped": {
+ padding: 0,
+ width: theme.spacing(4),
+ height: theme.spacing(4),
+ justifyContent: "center",
+ },
+}));
+
+function ScopeButtonGroup({ value, onChange, variant = "expanded" }: Props) {
+ const { t } = useTranslation();
+ const localLabel = t("actions.add_scope.local");
+ const globalLabel = t("actions.add_scope.global");
+ const groupLabel = t("scope.group", { ns: I18nNamespace.ARIA });
+
+ const handleChange = (_: unknown, next: RepoAddScope | null) => {
+ if (next) onChange(next);
+ };
+
+ if (variant === "collapsed") {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {localLabel}
+
+
+
+ {globalLabel}
+
+
+ );
+}
+
+export default ScopeButtonGroup;
diff --git a/app/src/components/atoms/cards/GeneralCard/GeneralCard.stories.tsx b/app/src/components/atoms/cards/GeneralCard/GeneralCard.stories.tsx
new file mode 100644
index 0000000..aae3c1d
--- /dev/null
+++ b/app/src/components/atoms/cards/GeneralCard/GeneralCard.stories.tsx
@@ -0,0 +1,35 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralCard from "@/components/atoms/cards/GeneralCard";
+
+const Body = styled(Box)({ height: 120 });
+
+const meta: Meta = {
+ title: "Atoms/Cards/GeneralCard",
+ component: GeneralCard,
+ args: {
+ title: "Activity",
+ sub: "Last 30 days",
+ children: Card body,
+ },
+ argTypes: {
+ skeleton: {
+ control: "select",
+ options: ["bars", "donut", "rows", "line", "heatmap", "radial"],
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const LoadingRows: Story = { args: { loading: true, skeleton: "rows" } };
+export const LoadingBars: Story = { args: { loading: true, skeleton: "bars" } };
+export const LoadingDonut: Story = { args: { loading: true, skeleton: "donut" } };
+export const LoadingHeatmap: Story = { args: { loading: true, skeleton: "heatmap" } };
+export const NoHead: Story = { args: { title: undefined, sub: undefined } };
diff --git a/app/src/components/atoms/cards/GeneralCard/GeneralCard.test.tsx b/app/src/components/atoms/cards/GeneralCard/GeneralCard.test.tsx
new file mode 100644
index 0000000..5f143d3
--- /dev/null
+++ b/app/src/components/atoms/cards/GeneralCard/GeneralCard.test.tsx
@@ -0,0 +1,29 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import GeneralCard from "@/components/atoms/cards/GeneralCard";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralCard", () => {
+ it("renders the card surface", () => {
+ const { getByTestId } = renderWithTheme(
+
+ body
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.card.root)).toBeInTheDocument();
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.card.body)).toBeInTheDocument();
+ });
+
+ it("hides the body and renders a skeleton when loading", () => {
+ const { getByTestId, queryByTestId } = renderWithTheme(
+
+ body
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.card.root)).toBeInTheDocument();
+ expect(queryByTestId(COMPONENT_TEST_IDS.atoms.card.body)).toBeNull();
+ });
+});
diff --git a/app/src/components/atoms/cards/GeneralCard/index.tsx b/app/src/components/atoms/cards/GeneralCard/index.tsx
new file mode 100644
index 0000000..838de10
--- /dev/null
+++ b/app/src/components/atoms/cards/GeneralCard/index.tsx
@@ -0,0 +1,226 @@
+import { type ReactNode } from "react";
+
+import { Box, Typography } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import GeneralSkeletonLoader, {
+ SkeletonShape,
+} from "@/components/atoms/loaders/GeneralSkeletonLoader";
+
+export type GeneralCardSkeleton = "bars" | "donut" | "rows" | "line" | "heatmap" | "radial";
+
+export interface GeneralCardProps {
+ /** Card heading. Omit to render just the surface + body (no head row). */
+ title?: string;
+ sub?: string | null;
+ /** Slot rendered at the top-right of the title row (filter chip, legend, …). */
+ right?: ReactNode;
+ children?: ReactNode;
+ loading?: boolean;
+ skeleton?: GeneralCardSkeleton;
+ /** Forwarded as `data-testid` on the outer card. */
+ testId?: string;
+ /** Outer wrapper class — wrap in a `styled(GeneralCard)` for layout overrides (e.g. `gridColumn`). */
+ className?: string;
+ /** Overrides the default `14px 16px 12px` padding when callers need a tighter or roomier surface. */
+ padding?: string | number;
+ /** Disable the default `height: 100%` so the card hugs its content instead of stretching. */
+ flushHeight?: boolean;
+}
+
+interface RootProps {
+ padding: string | number;
+ flushHeight: boolean;
+}
+
+const Root = styled(Box, {
+ shouldForwardProp: (p) => p !== "padding" && p !== "flushHeight",
+})(({ theme, padding, flushHeight }) => ({
+ backgroundColor: theme.palette.surface.interface.base,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: 8,
+ padding,
+ display: "flex",
+ flexDirection: "column",
+ gap: 10,
+ minWidth: 0,
+ height: flushHeight ? undefined : "100%",
+}));
+
+const Head = styled(Box)({
+ display: "flex",
+ alignItems: "baseline",
+ justifyContent: "space-between",
+ gap: 10,
+});
+
+const HeadLeft = styled(Box)({
+ minWidth: 0,
+});
+
+const Title = styled(Typography)(({ theme }) => ({
+ margin: 0,
+ fontSize: 13,
+ fontWeight: 700,
+ color: theme.palette.text.primary,
+ letterSpacing: "-0.1px",
+})) as typeof Typography;
+
+const Sub = styled(Box)(({ theme }) => ({
+ marginTop: 2,
+ fontSize: 11.5,
+ color: theme.palette.text.information,
+ fontVariantNumeric: "tabular-nums",
+}));
+
+const BarsSkel = styled(Box)({
+ display: "flex",
+ alignItems: "flex-end",
+ gap: 4,
+ height: 180,
+});
+
+const BarsSkelCol = styled(GeneralSkeletonLoader, {
+ shouldForwardProp: (p) => p !== "h",
+})<{ h: number }>(({ h }) => ({
+ flex: 1,
+ height: `${h}%`,
+ borderRadius: "8px 8px 0 0",
+ transform: "none",
+}));
+
+const RowsSkel = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ gap: 6,
+ padding: "6px 0",
+});
+
+const DonutSkel = styled(Box)({
+ display: "grid",
+ gridTemplateColumns: "auto 1fr",
+ alignItems: "center",
+ gap: 16,
+ padding: "8px 0",
+});
+
+const DonutSkelLegend = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ gap: 8,
+});
+
+const HeatmapSkel = styled(Box)({
+ display: "grid",
+ gridTemplateColumns: "repeat(24, 1fr)",
+ gridTemplateRows: "repeat(7, 1fr)",
+ gap: 3,
+ padding: "8px 0",
+});
+
+const LegendSkel = styled(GeneralSkeletonLoader)({
+ borderRadius: 8,
+});
+
+const HeatCellSkel = styled(GeneralSkeletonLoader)({
+ width: "100%",
+ borderRadius: 8,
+});
+
+const LineSkel = styled(GeneralSkeletonLoader)({
+ width: "100%",
+ borderRadius: 8,
+});
+
+const RadialSkel = styled(GeneralSkeletonLoader)({
+ margin: "0 auto",
+});
+
+const RowSkel = styled(GeneralSkeletonLoader)({
+ width: "100%",
+ borderRadius: 8,
+});
+
+function CardSkeleton({ shape }: { shape: GeneralCardSkeleton }) {
+ if (shape === "bars") {
+ const heights = [35, 70, 55, 20, 65, 45, 80, 30, 60, 50, 75, 40, 55, 25];
+ return (
+
+ {heights.map((h, i) => (
+
+ ))}
+
+ );
+ }
+ if (shape === "donut") {
+ return (
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+ );
+ }
+ if (shape === "heatmap") {
+ return (
+
+ {Array.from({ length: 7 * 24 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+ if (shape === "line") {
+ return ;
+ }
+ if (shape === "radial") {
+ return (
+
+ );
+ }
+ return (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function GeneralCard({
+ title,
+ sub,
+ right,
+ children,
+ loading,
+ skeleton = "rows",
+ testId,
+ className,
+ padding = "14px 16px 12px",
+ flushHeight = false,
+}: GeneralCardProps) {
+ const showHead = title !== undefined || right !== undefined;
+ return (
+
+ {showHead && (
+
+
+ {title !== undefined && (
+
+ {title}
+
+ )}
+ {sub && {sub} }
+
+ {right}
+
+ )}
+ {loading ? : children}
+
+ );
+}
+
+export default GeneralCard;
diff --git a/app/src/components/atoms/chips/BranchFilterChip/BranchFilterChip.stories.tsx b/app/src/components/atoms/chips/BranchFilterChip/BranchFilterChip.stories.tsx
new file mode 100644
index 0000000..ff8d2b9
--- /dev/null
+++ b/app/src/components/atoms/chips/BranchFilterChip/BranchFilterChip.stories.tsx
@@ -0,0 +1,24 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import BranchFilterChip from "@/components/atoms/chips/BranchFilterChip";
+
+const meta: Meta = {
+ title: "Atoms/Chips/BranchFilterChip",
+ component: BranchFilterChip,
+ args: {
+ tone: "current",
+ children: "current",
+ },
+ argTypes: {
+ tone: { control: "inline-radio", options: ["current", "dirty", "clean", "remote"] },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Current: Story = {};
+export const Dirty: Story = { args: { tone: "dirty", children: "dirty" } };
+export const Clean: Story = { args: { tone: "clean", children: "clean" } };
+export const Remote: Story = { args: { tone: "remote", children: "remote" } };
diff --git a/app/src/components/atoms/chips/BranchFilterChip/BranchFilterChip.test.tsx b/app/src/components/atoms/chips/BranchFilterChip/BranchFilterChip.test.tsx
new file mode 100644
index 0000000..eec8a19
--- /dev/null
+++ b/app/src/components/atoms/chips/BranchFilterChip/BranchFilterChip.test.tsx
@@ -0,0 +1,31 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import BranchFilterChip from "@/components/atoms/chips/BranchFilterChip";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+const ROOT = COMPONENT_TEST_IDS.atoms.branchFilterChip.root;
+
+describe("BranchFilterChip", () => {
+ it("renders the provided label", () => {
+ const { getByTestId } = renderWithTheme(
+
+ current
+ ,
+ );
+ expect(getByTestId(ROOT).textContent).toBe("current");
+ });
+
+ it("renders each tone variant", () => {
+ const { getByTestId } = renderWithTheme(
+
+ dirty
+ clean
+ remote
+ ,
+ );
+ expect(getByTestId(ROOT).textContent).toBe("dirtycleanremote");
+ });
+});
diff --git a/app/src/components/atoms/chips/BranchFilterChip/index.tsx b/app/src/components/atoms/chips/BranchFilterChip/index.tsx
new file mode 100644
index 0000000..1812063
--- /dev/null
+++ b/app/src/components/atoms/chips/BranchFilterChip/index.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from "react";
+
+import { styled } from "@mui/material/styles";
+
+export type BranchFilterChipTone = "current" | "dirty" | "clean" | "remote";
+
+export interface BranchFilterChipProps {
+ tone: BranchFilterChipTone;
+ /** Visible label — translated by the consumer. */
+ children: ReactNode;
+ className?: string;
+}
+
+const FORWARD = (p: PropertyKey) => p !== "tone";
+
+// eslint-disable-next-line no-restricted-syntax -- inline-status pill; keeps it inline inside the branch name cell without a block wrapper
+const Root = styled("span", { shouldForwardProp: FORWARD })<{ tone: BranchFilterChipTone }>(
+ ({ theme, tone }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ fontSize: 9.5,
+ fontWeight: 700,
+ textTransform: "uppercase",
+ letterSpacing: "0.05em",
+ padding: "2px 7px",
+ borderRadius: 100,
+ ...(tone === "current" && {
+ backgroundColor: `color-mix(in srgb, ${theme.palette.primary.main} 14%, transparent)`,
+ color: theme.palette.primary.dark,
+ }),
+ ...(tone === "dirty" && {
+ backgroundColor: `color-mix(in srgb, ${theme.palette.warning.main} 18%, transparent)`,
+ color: theme.palette.warning.dark,
+ }),
+ ...(tone === "clean" && {
+ backgroundColor: theme.palette.surface.interface.backElevation,
+ color: theme.palette.text.information,
+ }),
+ ...(tone === "remote" && {
+ backgroundColor: theme.palette.surface.interface.backElevation,
+ color: theme.palette.text.secondary,
+ }),
+ }),
+);
+
+function BranchFilterChip({ tone, children, className }: BranchFilterChipProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default BranchFilterChip;
diff --git a/app/src/components/atoms/chips/MrChip/MrChip.stories.tsx b/app/src/components/atoms/chips/MrChip/MrChip.stories.tsx
new file mode 100644
index 0000000..3cc59a2
--- /dev/null
+++ b/app/src/components/atoms/chips/MrChip/MrChip.stories.tsx
@@ -0,0 +1,28 @@
+import { PrState } from "@recrest/shared";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import MrChip from "@/components/atoms/chips/MrChip";
+
+const meta: Meta = {
+ title: "Atoms/Chips/MrChip",
+ component: MrChip,
+ args: {
+ state: PrState.OPEN,
+ draft: false,
+ children: "open",
+ },
+ argTypes: {
+ state: { control: "inline-radio", options: [PrState.OPEN, PrState.MERGED, PrState.CLOSED] },
+ draft: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Open: Story = {};
+export const Merged: Story = { args: { state: PrState.MERGED, children: "merged" } };
+export const Closed: Story = { args: { state: PrState.CLOSED, children: "closed" } };
+export const Draft: Story = { args: { draft: true, children: "draft" } };
diff --git a/app/src/components/atoms/chips/MrChip/MrChip.test.tsx b/app/src/components/atoms/chips/MrChip/MrChip.test.tsx
new file mode 100644
index 0000000..516cc06
--- /dev/null
+++ b/app/src/components/atoms/chips/MrChip/MrChip.test.tsx
@@ -0,0 +1,43 @@
+import { Box } from "@mui/material";
+
+import { PrState } from "@recrest/shared";
+
+import { describe, expect, it } from "vitest";
+
+import MrChip from "@/components/atoms/chips/MrChip";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+const ROOT = COMPONENT_TEST_IDS.atoms.mrChip.root;
+
+describe("MrChip", () => {
+ it("renders the provided label", () => {
+ const { getByTestId } = renderWithTheme(
+
+ open
+ ,
+ );
+ expect(getByTestId(ROOT).textContent).toBe("open");
+ });
+
+ it("uses the draft tone regardless of state when draft is set", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ draft
+
+ ,
+ );
+ expect(getByTestId(ROOT).textContent).toBe("draft");
+ });
+
+ it("renders merged and closed labels", () => {
+ const { getByTestId } = renderWithTheme(
+
+ merged
+ closed
+ ,
+ );
+ expect(getByTestId(ROOT).textContent).toBe("mergedclosed");
+ });
+});
diff --git a/app/src/components/atoms/chips/MrChip/index.tsx b/app/src/components/atoms/chips/MrChip/index.tsx
new file mode 100644
index 0000000..c2d437b
--- /dev/null
+++ b/app/src/components/atoms/chips/MrChip/index.tsx
@@ -0,0 +1,60 @@
+import type { ReactNode } from "react";
+
+import { Typography } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import { PrState } from "@recrest/shared";
+
+export interface MrChipProps {
+ /** Underlying PR state. Ignored for colouring when `draft` is `true`. */
+ state?: PrState;
+ /** Draft PRs render in the muted draft tone regardless of `state`. */
+ draft?: boolean;
+ /** Visible label — translated by the consumer. */
+ children: ReactNode;
+ className?: string;
+}
+
+type Tone = PrState | "draft";
+
+const FORWARD = (p: PropertyKey) => p !== "tone";
+
+const Root = styled(Typography, { shouldForwardProp: FORWARD })<{ tone: Tone }>(
+ ({ theme, tone }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ padding: "1px 6px",
+ borderRadius: 8,
+ fontSize: 10,
+ fontWeight: 600,
+ textTransform: "uppercase",
+ letterSpacing: "0.05em",
+ ...(tone === "draft" && {
+ backgroundColor: theme.palette.surface.interface.backElevation,
+ color: theme.palette.text.information,
+ }),
+ ...(tone === PrState.OPEN && {
+ backgroundColor: `color-mix(in srgb, ${theme.palette.success.main} 15%, transparent)`,
+ color: theme.palette.success.dark,
+ }),
+ ...(tone === PrState.MERGED && {
+ backgroundColor: `color-mix(in srgb, ${theme.palette.primary.main} 15%, transparent)`,
+ color: theme.palette.primary.dark,
+ }),
+ ...(tone === PrState.CLOSED && {
+ backgroundColor: `color-mix(in srgb, ${theme.palette.error.main} 15%, transparent)`,
+ color: theme.palette.error.dark,
+ }),
+ }),
+);
+
+function MrChip({ state = PrState.OPEN, draft = false, children, className }: MrChipProps) {
+ const tone: Tone = draft ? "draft" : state;
+ return (
+
+ {children}
+
+ );
+}
+
+export default MrChip;
diff --git a/app/src/components/atoms/feedback/GeneralTooltip/GeneralTooltip.stories.tsx b/app/src/components/atoms/feedback/GeneralTooltip/GeneralTooltip.stories.tsx
new file mode 100644
index 0000000..8f522e6
--- /dev/null
+++ b/app/src/components/atoms/feedback/GeneralTooltip/GeneralTooltip.stories.tsx
@@ -0,0 +1,25 @@
+import { Box } from "@mui/material";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralButton from "@/components/atoms/buttons/GeneralButton";
+import GeneralTooltip from "@/components/atoms/feedback/GeneralTooltip";
+
+const meta: Meta = {
+ title: "Atoms/Feedback/GeneralTooltip",
+ component: GeneralTooltip,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ Hover me
+
+
+ ),
+};
diff --git a/app/src/components/atoms/feedback/GeneralTooltip/GeneralTooltip.test.tsx b/app/src/components/atoms/feedback/GeneralTooltip/GeneralTooltip.test.tsx
new file mode 100644
index 0000000..1a2705a
--- /dev/null
+++ b/app/src/components/atoms/feedback/GeneralTooltip/GeneralTooltip.test.tsx
@@ -0,0 +1,20 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import GeneralTooltip from "@/components/atoms/feedback/GeneralTooltip";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralTooltip", () => {
+ it("renders the child trigger", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ trigger
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.tooltip.trigger)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/feedback/GeneralTooltip/index.tsx b/app/src/components/atoms/feedback/GeneralTooltip/index.tsx
new file mode 100644
index 0000000..10d69e9
--- /dev/null
+++ b/app/src/components/atoms/feedback/GeneralTooltip/index.tsx
@@ -0,0 +1,48 @@
+import { type ComponentProps } from "react";
+
+import { Tooltip, tooltipClasses } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+/**
+ * App-wide tooltip styled to match the `bg-popover` surface from `src-old`:
+ * theme-bound background (light / dark / oled / glassy), border + small
+ * shadow, rounded-md radius, no MUI default dark-grey block. The slide+fade
+ * comes from MUI's built-in `Fade` transition (which honours user-side
+ * reduced-motion via the same CSS toggle we use elsewhere).
+ *
+ * Use this anywhere we previously reached for raw `@mui/material/Tooltip`.
+ * Same prop surface as the underlying component so it's a drop-in swap.
+ */
+type Props = ComponentProps;
+
+const Styled = styled(({ className, ...rest }: Props) => (
+
+))(({ theme }) => ({
+ [`& .${tooltipClasses.tooltip}`]: {
+ backgroundColor: theme.palette.surface.interface.base,
+ color: theme.palette.text.primary,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: 8,
+ padding: "6px 10px",
+ fontSize: 11.5,
+ fontWeight: 500,
+ lineHeight: 1.4,
+ boxShadow:
+ theme.palette.mode === "dark"
+ ? "0 8px 24px -12px rgba(0,0,0,0.7), 0 2px 6px -2px rgba(0,0,0,0.55)"
+ : "0 8px 24px -12px rgba(20,22,28,0.22), 0 2px 6px -2px rgba(20,22,28,0.10)",
+ // Tabular nums for any numerals that fall inside (commit counts, dates).
+ fontVariantNumeric: "tabular-nums",
+ maxWidth: 280,
+ },
+}));
+
+// No `enterDelay` — tooltips should appear immediately on hover, anywhere
+// on the trigger. Arrow is intentionally absent (cleaner read at the small
+// type sizes the dashboard uses; the placement+offset already communicates
+// which element the tooltip belongs to).
+function GeneralTooltip(props: Props) {
+ return ;
+}
+
+export default GeneralTooltip;
diff --git a/app/src/components/atoms/git/AheadBehind/AheadBehind.stories.tsx b/app/src/components/atoms/git/AheadBehind/AheadBehind.stories.tsx
new file mode 100644
index 0000000..377ee1b
--- /dev/null
+++ b/app/src/components/atoms/git/AheadBehind/AheadBehind.stories.tsx
@@ -0,0 +1,33 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import AheadBehind from "@/components/atoms/git/AheadBehind";
+
+const meta: Meta = {
+ title: "Atoms/Git/AheadBehind",
+ component: AheadBehind,
+ args: {
+ ahead: 5,
+ behind: 3,
+ size: "sm",
+ variant: "compact",
+ },
+ argTypes: {
+ size: { control: "inline-radio", options: ["sm", "md"] },
+ variant: { control: "inline-radio", options: ["compact", "separated"] },
+ hideZero: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Compact: Story = {};
+export const CompactMedium: Story = { args: { size: "md" } };
+export const Separated: Story = { args: { variant: "separated" } };
+export const ZeroBehind: Story = { args: { behind: 0 } };
+export const ZeroAhead: Story = { args: { ahead: 0 } };
+export const BothZero: Story = { args: { ahead: 0, behind: 0 } };
+export const BothZeroShown: Story = {
+ args: { ahead: 0, behind: 0, hideZero: false },
+};
diff --git a/app/src/components/atoms/git/AheadBehind/AheadBehind.test.tsx b/app/src/components/atoms/git/AheadBehind/AheadBehind.test.tsx
new file mode 100644
index 0000000..b1a7486
--- /dev/null
+++ b/app/src/components/atoms/git/AheadBehind/AheadBehind.test.tsx
@@ -0,0 +1,53 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import AheadBehind from "@/components/atoms/git/AheadBehind";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("AheadBehind", () => {
+ it("renders both glyphs in compact variant", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const wrap = getByTestId(COMPONENT_TEST_IDS.atoms.aheadBehind.root);
+ expect(wrap.textContent).toContain("↑5");
+ expect(wrap.textContent).toContain("↓3");
+ });
+
+ it("hides zero halves in compact variant by default", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const wrap = getByTestId(COMPONENT_TEST_IDS.atoms.aheadBehind.root);
+ expect(wrap.textContent).toContain("↑5");
+ expect(wrap.textContent).not.toContain("↓");
+ });
+
+ it("renders nothing when both sides are zero and hideZero defaults apply", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const wrap = getByTestId(COMPONENT_TEST_IDS.atoms.aheadBehind.root);
+ expect(wrap.children.length).toBe(0);
+ });
+
+ it("renders separated variant with a slash separator", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const wrap = getByTestId(COMPONENT_TEST_IDS.atoms.aheadBehind.root);
+ expect(wrap.textContent).toContain("/");
+ expect(wrap.textContent).toContain("↑");
+ expect(wrap.textContent).toContain("↓");
+ });
+});
diff --git a/app/src/components/atoms/git/AheadBehind/index.tsx b/app/src/components/atoms/git/AheadBehind/index.tsx
new file mode 100644
index 0000000..4ae6ae7
--- /dev/null
+++ b/app/src/components/atoms/git/AheadBehind/index.tsx
@@ -0,0 +1,89 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+export type AheadBehindSize = "sm" | "md";
+export type AheadBehindVariant = "compact" | "separated";
+
+export interface AheadBehindProps {
+ ahead: number;
+ behind: number;
+ size?: AheadBehindSize;
+ variant?: AheadBehindVariant;
+ /** When `true` and one side is `0`, hide that side entirely. Defaults to
+ * `false` for the `separated` variant (Dashboard KPI), `true` for the
+ * `compact` variant (Repo rows). */
+ hideZero?: boolean;
+ className?: string;
+}
+
+interface RootProps {
+ size: AheadBehindSize;
+ variant: AheadBehindVariant;
+}
+
+const FORWARD = (p: PropertyKey) => p !== "size" && p !== "variant";
+
+const Root = styled(Box, { shouldForwardProp: FORWARD })(({ theme, size, variant }) => ({
+ display: "inline-flex",
+ alignItems: variant === "separated" ? "baseline" : "center",
+ gap: variant === "separated" ? 4 : size === "md" ? 10 : 5,
+ fontSize: size === "md" ? 13 : 11,
+ fontWeight: variant === "separated" ? 600 : size === "md" ? 700 : 400,
+ color: theme.palette.text.information,
+ fontVariantNumeric: "tabular-nums",
+ lineHeight: variant === "separated" ? 1 : undefined,
+ flexShrink: 0,
+}));
+
+const Arrow = styled(Box)(({ theme }) => ({
+ fontSize: 22,
+ fontWeight: 600,
+ color: theme.palette.text.information,
+})) as typeof Box;
+
+const Sep = styled(Box)(({ theme }) => ({
+ fontWeight: 400,
+ color: theme.palette.text.informationLight,
+ margin: "0 6px",
+})) as typeof Box;
+
+/**
+ * Renders the ↑ahead / ↓behind glyph pair that summarises a branch's
+ * relationship to its upstream. Replaces three near-identical inline copies
+ * (Dashboard KPI, RepoRow, RepoCard, DetailPane). The `separated` variant
+ * mimics the Dashboard's display ("↑ 5 / ↓ 3" with a centred slash); the
+ * `compact` variant is the inline form used inside narrow cells.
+ */
+function AheadBehind({
+ ahead,
+ behind,
+ size = "sm",
+ variant = "compact",
+ hideZero = variant === "compact",
+ className,
+}: AheadBehindProps) {
+ const showAhead = !hideZero || ahead > 0;
+ const showBehind = !hideZero || behind > 0;
+ if (!showAhead && !showBehind) return null;
+
+ if (variant === "separated") {
+ return (
+
+ ↑
+ {ahead}
+ /
+ ↓
+ {behind}
+
+ );
+ }
+
+ return (
+
+ {showAhead && ↑{ahead} }
+ {showBehind && ↓{behind} }
+
+ );
+}
+
+export default AheadBehind;
diff --git a/app/src/components/atoms/inputs/GeneralSearchInput/GeneralSearchInput.stories.tsx b/app/src/components/atoms/inputs/GeneralSearchInput/GeneralSearchInput.stories.tsx
new file mode 100644
index 0000000..444b4b5
--- /dev/null
+++ b/app/src/components/atoms/inputs/GeneralSearchInput/GeneralSearchInput.stories.tsx
@@ -0,0 +1,43 @@
+import { useState } from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralSearchInput from "@/components/atoms/inputs/GeneralSearchInput";
+
+function DefaultDemo() {
+ const [v, setV] = useState("");
+ return (
+
+ );
+}
+
+function WithValueDemo() {
+ const [v, setV] = useState("recrest");
+ return (
+
+ );
+}
+
+const meta: Meta = {
+ title: "Atoms/Inputs/GeneralSearchInput",
+ component: GeneralSearchInput,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = { render: () => };
+export const WithValue: Story = { render: () => };
diff --git a/app/src/components/atoms/inputs/GeneralSearchInput/GeneralSearchInput.test.tsx b/app/src/components/atoms/inputs/GeneralSearchInput/GeneralSearchInput.test.tsx
new file mode 100644
index 0000000..15dd142
--- /dev/null
+++ b/app/src/components/atoms/inputs/GeneralSearchInput/GeneralSearchInput.test.tsx
@@ -0,0 +1,41 @@
+import { fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import GeneralSearchInput from "@/components/atoms/inputs/GeneralSearchInput";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralSearchInput", () => {
+ it("fires onChange with the new value", () => {
+ const onChange = vi.fn();
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ fireEvent.change(getByTestId(COMPONENT_TEST_IDS.atoms.searchInput.root), {
+ target: { value: "hello" },
+ });
+ expect(onChange).toHaveBeenCalledWith("hello");
+ });
+
+ it("renders the clear button when value is non-empty and clears on click", () => {
+ const onChange = vi.fn();
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ fireEvent.click(getByTestId(COMPONENT_TEST_IDS.atoms.searchInput.clear));
+ expect(onChange).toHaveBeenCalledWith("");
+ });
+});
diff --git a/app/src/components/atoms/inputs/GeneralSearchInput/index.tsx b/app/src/components/atoms/inputs/GeneralSearchInput/index.tsx
new file mode 100644
index 0000000..1141895
--- /dev/null
+++ b/app/src/components/atoms/inputs/GeneralSearchInput/index.tsx
@@ -0,0 +1,108 @@
+import { type Ref, forwardRef } from "react";
+
+import { styled } from "@mui/material/styles";
+
+import { X as ClearIcon, Search as SearchIcon } from "lucide-react";
+
+import GeneralIconButton, {
+ IconButtonShape,
+ IconButtonSize,
+} from "@/components/atoms/buttons/GeneralIconButton";
+
+interface WrapperProps {
+ width?: number | string;
+ height?: number;
+}
+
+// eslint-disable-next-line no-restricted-syntax -- a wraps icon + input so clicking the icon focuses the input
+const Wrapper = styled("label", {
+ shouldForwardProp: (p) => p !== "width" && p !== "height",
+})(({ theme, width, height = 30 }) => ({
+ display: "flex",
+ alignItems: "center",
+ gap: 7,
+ height,
+ width,
+ padding: "0 10px",
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: 8,
+ backgroundColor: theme.palette.surface.interface.base,
+ color: theme.palette.text.information,
+ fontSize: 12,
+ "&:focus-within": {
+ borderColor: theme.palette.border.hover,
+ },
+}));
+
+// eslint-disable-next-line no-restricted-syntax -- native form control required for accessibility / autofocus / IME
+const NativeInput = styled("input")(({ theme }) => ({
+ flex: 1,
+ minWidth: 0,
+ background: "transparent",
+ border: 0,
+ outline: "none",
+ fontSize: 12,
+ color: theme.palette.text.primary,
+ fontFamily: "inherit",
+ "&::placeholder": { color: theme.palette.text.information },
+}));
+
+export interface GeneralSearchInputProps {
+ value: string;
+ onChange: (next: string) => void;
+ placeholder?: string;
+ width?: number | string;
+ height?: number;
+ hideIcon?: boolean;
+ /** Accessible label for the clear (X) button. Required so screen readers
+ * announce the action — pull from the `aria` i18n namespace at the call site. */
+ clearLabel: string;
+ /** Accessible label for the input itself. Required because the icon-only
+ * visual gives no other affordance for assistive tech. */
+ "aria-label": string;
+ "data-testid"?: string;
+ clearTestId?: string;
+}
+
+const GeneralSearchInput = forwardRef(function GeneralSearchInput(
+ {
+ value,
+ onChange,
+ placeholder,
+ width = 240,
+ height,
+ hideIcon = false,
+ clearLabel,
+ "aria-label": ariaLabel,
+ "data-testid": testId,
+ clearTestId,
+ }: GeneralSearchInputProps,
+ ref: Ref,
+) {
+ return (
+
+ {!hideIcon && }
+ onChange(e.target.value)}
+ data-testid={testId}
+ />
+ {value && (
+ onChange("")}
+ data-testid={clearTestId}
+ icon={ }
+ />
+ )}
+
+ );
+});
+
+export default GeneralSearchInput;
diff --git a/app/src/components/atoms/inputs/GeneralSwitchInput/GeneralSwitchInput.stories.tsx b/app/src/components/atoms/inputs/GeneralSwitchInput/GeneralSwitchInput.stories.tsx
new file mode 100644
index 0000000..80cb845
--- /dev/null
+++ b/app/src/components/atoms/inputs/GeneralSwitchInput/GeneralSwitchInput.stories.tsx
@@ -0,0 +1,23 @@
+import { useState } from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralSwitchInput from "@/components/atoms/inputs/GeneralSwitchInput";
+
+function DefaultDemo() {
+ const [v, setV] = useState(false);
+ return ;
+}
+
+const meta: Meta = {
+ title: "Atoms/Inputs/GeneralSwitchInput",
+ component: GeneralSwitchInput,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = { render: () => };
+export const On: Story = { render: () => };
+export const Disabled: Story = { render: () => };
diff --git a/app/src/components/atoms/inputs/GeneralSwitchInput/GeneralSwitchInput.test.tsx b/app/src/components/atoms/inputs/GeneralSwitchInput/GeneralSwitchInput.test.tsx
new file mode 100644
index 0000000..d5b83fc
--- /dev/null
+++ b/app/src/components/atoms/inputs/GeneralSwitchInput/GeneralSwitchInput.test.tsx
@@ -0,0 +1,22 @@
+import { Box } from "@mui/material";
+
+import { fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import GeneralSwitchInput from "@/components/atoms/inputs/GeneralSwitchInput";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralSwitchInput", () => {
+ it("emits onCheckedChange when toggled", () => {
+ const onCheckedChange = vi.fn();
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const input = getByTestId(COMPONENT_TEST_IDS.atoms.switchInput.root).querySelector("input");
+ if (input) fireEvent.click(input);
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/app/src/components/atoms/inputs/GeneralSwitchInput/index.tsx b/app/src/components/atoms/inputs/GeneralSwitchInput/index.tsx
new file mode 100644
index 0000000..d34f607
--- /dev/null
+++ b/app/src/components/atoms/inputs/GeneralSwitchInput/index.tsx
@@ -0,0 +1,80 @@
+import { forwardRef } from "react";
+
+import { type SwitchProps as MuiSwitchProps, Switch } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+export interface GeneralSwitchInputProps extends Omit {
+ onCheckedChange?: (checked: boolean) => void;
+ onChange?: MuiSwitchProps["onChange"];
+}
+
+/**
+ * Apple-style switch in monochrome — the Apple shape (wide rounded track,
+ * white thumb with a soft drop shadow, springy thumb travel) but recoloured
+ * to a black/white palette per Recrest's design language. Light theme: off =
+ * pale-gray track, on = near-black track. Dark theme inverts to white/charcoal.
+ * Pre-MUI src-old used the same monochrome treatment — restoring that here.
+ */
+const StyledSwitch = styled(Switch)(({ theme }) => ({
+ width: 42,
+ height: 26,
+ padding: 0,
+
+ "& .MuiSwitch-switchBase": {
+ padding: 0,
+ margin: 2,
+ transitionDuration: "260ms",
+ "&.Mui-checked": {
+ transform: "translateX(16px)",
+ color: theme.palette.mode === "dark" ? "#0f1115" : "#ffffff",
+ "& + .MuiSwitch-track": {
+ backgroundColor: theme.palette.mode === "dark" ? "#ffffff" : "#0f1115",
+ opacity: 1,
+ border: 0,
+ },
+ "&.Mui-disabled + .MuiSwitch-track": {
+ opacity: 0.5,
+ },
+ },
+ "&.Mui-focusVisible .MuiSwitch-thumb": {
+ color: theme.palette.mode === "dark" ? "#ffffff" : "#0f1115",
+ border: `6px solid ${theme.palette.background.paper}`,
+ },
+ "&.Mui-disabled .MuiSwitch-thumb": {
+ color: theme.palette.grey[100],
+ },
+ "&.Mui-disabled + .MuiSwitch-track": {
+ opacity: 0.7,
+ },
+ },
+ "& .MuiSwitch-thumb": {
+ boxSizing: "border-box",
+ width: 22,
+ height: 22,
+ boxShadow: "0 3px 1px rgba(0,0,0,0.06), 0 3px 8px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.04)",
+ },
+ "& .MuiSwitch-track": {
+ borderRadius: 26 / 2,
+ backgroundColor: theme.palette.mode === "dark" ? "#39393D" : "#E9E9EA",
+ opacity: 1,
+ transition: theme.transitions.create(["background-color"], { duration: 260 }),
+ },
+}));
+
+const GeneralSwitchInput = forwardRef(
+ function GeneralSwitchInput({ onCheckedChange, onChange, ...rest }, ref) {
+ return (
+ {
+ onChange?.(e, checked);
+ onCheckedChange?.(checked);
+ }}
+ {...rest}
+ />
+ );
+ },
+);
+
+export default GeneralSwitchInput;
diff --git a/app/src/components/atoms/inputs/Kbd/Kbd.stories.tsx b/app/src/components/atoms/inputs/Kbd/Kbd.stories.tsx
new file mode 100644
index 0000000..5d114f6
--- /dev/null
+++ b/app/src/components/atoms/inputs/Kbd/Kbd.stories.tsx
@@ -0,0 +1,49 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import Kbd, { KbdSize } from "@/components/atoms/inputs/Kbd";
+
+const Row = styled(Box)({
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+}) as typeof Box;
+
+const meta: Meta = {
+ title: "Atoms/Inputs/Kbd",
+ component: Kbd,
+ parameters: { layout: "centered" },
+ argTypes: {
+ size: {
+ control: { type: "inline-radio" },
+ options: [KbdSize.SM, KbdSize.MD],
+ },
+ },
+};
+export default meta;
+
+type Story = StoryObj;
+
+export const Small: Story = { args: { children: "⌘K", size: KbdSize.SM } };
+export const Medium: Story = { args: { children: "⌘K", size: KbdSize.MD } };
+
+export const Combo: Story = {
+ render: () => (
+
+ ⌘
+ Shift
+ F
+
+ ),
+};
+
+export const InlineHint: Story = {
+ render: () => (
+
+ Open search
+ ⌘K
+
+ ),
+};
diff --git a/app/src/components/atoms/inputs/Kbd/Kbd.test.tsx b/app/src/components/atoms/inputs/Kbd/Kbd.test.tsx
new file mode 100644
index 0000000..74a0f64
--- /dev/null
+++ b/app/src/components/atoms/inputs/Kbd/Kbd.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import Kbd, { KbdSize } from "@/components/atoms/inputs/Kbd";
+
+describe("Kbd", () => {
+ it("renders as a element by default", () => {
+ render(⌘K );
+ const el = screen.getByText("⌘K");
+ expect(el.tagName).toBe("KBD");
+ });
+
+ it("forwards arbitrary attributes through to the root element", () => {
+ render(X );
+ expect(screen.getByLabelText("kbd-x")).toHaveTextContent("X");
+ });
+
+ it("accepts the MD size variant", () => {
+ render(X );
+ expect(screen.getByText("X").tagName).toBe("KBD");
+ });
+});
diff --git a/app/src/components/atoms/inputs/Kbd/index.tsx b/app/src/components/atoms/inputs/Kbd/index.tsx
new file mode 100644
index 0000000..c2d4f9b
--- /dev/null
+++ b/app/src/components/atoms/inputs/Kbd/index.tsx
@@ -0,0 +1,46 @@
+import type { HTMLAttributes, ReactNode } from "react";
+
+import { styled } from "@mui/material/styles";
+
+export const KbdSize = {
+ /** Compact 18px tall — used inside chrome (header search hint, search panel). */
+ SM: "sm",
+ /** 22px tall — used in the shortcuts list where the keys are the focal element. */
+ MD: "md",
+} as const;
+
+export type KbdSize = (typeof KbdSize)[keyof typeof KbdSize];
+
+export interface KbdProps extends Omit, "size"> {
+ size?: KbdSize;
+ children?: ReactNode;
+}
+
+// eslint-disable-next-line no-restricted-syntax -- semantic element; this primitive exists to render keyboard input markup
+const Root = styled("kbd", { shouldForwardProp: (p) => p !== "size" })<{ size: KbdSize }>(
+ ({ theme, size }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ minWidth: 22,
+ height: size === KbdSize.MD ? 22 : 18,
+ padding: size === KbdSize.MD ? "0 6px" : "0 5px",
+ borderRadius: 8,
+ border: `1px solid ${theme.palette.divider}`,
+ backgroundColor: theme.palette.background.default,
+ color: size === KbdSize.MD ? theme.palette.text.primary : theme.palette.text.secondary,
+ fontSize: size === KbdSize.MD ? 10.5 : 10,
+ fontFamily: "inherit",
+ fontWeight: 600,
+ }),
+);
+
+function Kbd({ size = KbdSize.SM, children, ...rest }: KbdProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default Kbd;
diff --git a/app/src/components/atoms/loaders/GeneralCircularLoader/GeneralCircularLoader.stories.tsx b/app/src/components/atoms/loaders/GeneralCircularLoader/GeneralCircularLoader.stories.tsx
new file mode 100644
index 0000000..11fb412
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralCircularLoader/GeneralCircularLoader.stories.tsx
@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralCircularLoader, {
+ CircularLoaderSize,
+} from "@/components/atoms/loaders/GeneralCircularLoader";
+
+const meta = {
+ title: "Atoms/Loaders/GeneralCircularLoader",
+ component: GeneralCircularLoader,
+ args: { size: CircularLoaderSize.MD },
+ argTypes: { size: { control: "select", options: Object.values(CircularLoaderSize) } },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Indeterminate: Story = {};
+export const Determinate: Story = { args: { value: 65 } };
+export const Small: Story = { args: { size: CircularLoaderSize.SM } };
+export const Large: Story = { args: { size: CircularLoaderSize.LG } };
diff --git a/app/src/components/atoms/loaders/GeneralCircularLoader/GeneralCircularLoader.test.tsx b/app/src/components/atoms/loaders/GeneralCircularLoader/GeneralCircularLoader.test.tsx
new file mode 100644
index 0000000..70b3732
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralCircularLoader/GeneralCircularLoader.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import GeneralCircularLoader from "@/components/atoms/loaders/GeneralCircularLoader";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralCircularLoader", () => {
+ it("renders the spinner root", () => {
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.circularLoader.root)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/loaders/GeneralCircularLoader/index.tsx b/app/src/components/atoms/loaders/GeneralCircularLoader/index.tsx
new file mode 100644
index 0000000..bae37e4
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralCircularLoader/index.tsx
@@ -0,0 +1,65 @@
+import { CircularProgress, type CircularProgressProps } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+/**
+ * Indeterminate spinner — drop into a button, inline next to label, or center
+ * inside a card to signal "busy, no ETA". Defaults to `SM` (16px) so it sits
+ * naturally inside button labels; bump to `MD` (24) for centered loading
+ * states or `LG` (40) for full-pane spinners.
+ *
+ * Adding a size: append the literal to the const + an entry in
+ * `CIRCULAR_LOADER_SIZES`.
+ */
+export const CircularLoaderSize = {
+ XS: "xs",
+ SM: "sm",
+ MD: "md",
+ LG: "lg",
+} as const;
+
+export type CircularLoaderSize = (typeof CircularLoaderSize)[keyof typeof CircularLoaderSize];
+
+export const CIRCULAR_LOADER_SIZES: Record = {
+ [CircularLoaderSize.XS]: 12,
+ [CircularLoaderSize.SM]: 16,
+ [CircularLoaderSize.MD]: 24,
+ [CircularLoaderSize.LG]: 40,
+};
+
+interface RootProps {
+ $size: CircularLoaderSize;
+}
+
+const Root = styled(CircularProgress, {
+ shouldForwardProp: (p) => p !== "$size",
+})(({ $size }) => ({
+ width: `${CIRCULAR_LOADER_SIZES[$size]}px !important`,
+ height: `${CIRCULAR_LOADER_SIZES[$size]}px !important`,
+ flexShrink: 0,
+}));
+
+export interface GeneralCircularLoaderProps extends Omit<
+ CircularProgressProps,
+ "size" | "variant"
+> {
+ size?: CircularLoaderSize;
+ /** Determinate mode requires `value` 0–100. Omit for the default spinner. */
+ value?: number;
+}
+
+function GeneralCircularLoader({
+ size = CircularLoaderSize.SM,
+ value,
+ ...rest
+}: GeneralCircularLoaderProps) {
+ return (
+
+ );
+}
+
+export default GeneralCircularLoader;
diff --git a/app/src/components/atoms/loaders/GeneralLinearLoader/GeneralLinearLoader.stories.tsx b/app/src/components/atoms/loaders/GeneralLinearLoader/GeneralLinearLoader.stories.tsx
new file mode 100644
index 0000000..4a601d3
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralLinearLoader/GeneralLinearLoader.stories.tsx
@@ -0,0 +1,35 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralLinearLoader, {
+ LinearLoaderThickness,
+} from "@/components/atoms/loaders/GeneralLinearLoader";
+
+const Stage = styled(Box)({ width: 320 });
+
+const meta: Meta = {
+ title: "Atoms/Loaders/GeneralLinearLoader",
+ component: GeneralLinearLoader,
+ args: { thickness: LinearLoaderThickness.REGULAR },
+ argTypes: {
+ thickness: { control: "select", options: Object.values(LinearLoaderThickness) },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Indeterminate: Story = {};
+export const Determinate: Story = { args: { value: 42 } };
+export const Slim: Story = { args: { thickness: LinearLoaderThickness.SLIM } };
+export const Thick: Story = { args: { thickness: LinearLoaderThickness.THICK } };
diff --git a/app/src/components/atoms/loaders/GeneralLinearLoader/GeneralLinearLoader.test.tsx b/app/src/components/atoms/loaders/GeneralLinearLoader/GeneralLinearLoader.test.tsx
new file mode 100644
index 0000000..5e1965a
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralLinearLoader/GeneralLinearLoader.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import GeneralLinearLoader from "@/components/atoms/loaders/GeneralLinearLoader";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralLinearLoader", () => {
+ it("renders the bar root", () => {
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.linearLoader.root)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/loaders/GeneralLinearLoader/index.tsx b/app/src/components/atoms/loaders/GeneralLinearLoader/index.tsx
new file mode 100644
index 0000000..d9fdfdd
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralLinearLoader/index.tsx
@@ -0,0 +1,62 @@
+import { LinearProgress, type LinearProgressProps } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+/**
+ * Horizontal progress bar — used for clone/fetch/install operations where the
+ * caller has a useful percentage to show. Omit `value` for an indeterminate
+ * shimmer (e.g. "we triggered the operation, no ETA yet").
+ *
+ * Sits flush at full width by default; constrain via the parent.
+ */
+export const LinearLoaderThickness = {
+ SLIM: "slim",
+ REGULAR: "regular",
+ THICK: "thick",
+} as const;
+
+export type LinearLoaderThickness =
+ (typeof LinearLoaderThickness)[keyof typeof LinearLoaderThickness];
+
+export const LINEAR_LOADER_THICKNESS_PX: Record = {
+ [LinearLoaderThickness.SLIM]: 2,
+ [LinearLoaderThickness.REGULAR]: 4,
+ [LinearLoaderThickness.THICK]: 6,
+};
+
+interface RootProps {
+ $thickness: LinearLoaderThickness;
+}
+
+const Root = styled(LinearProgress, {
+ shouldForwardProp: (p) => p !== "$thickness",
+})(({ theme, $thickness }) => ({
+ height: LINEAR_LOADER_THICKNESS_PX[$thickness],
+ borderRadius: LINEAR_LOADER_THICKNESS_PX[$thickness] / 2,
+ backgroundColor: theme.palette.surface.interface.active,
+ "& .MuiLinearProgress-bar": {
+ borderRadius: LINEAR_LOADER_THICKNESS_PX[$thickness] / 2,
+ },
+}));
+
+export interface GeneralLinearLoaderProps extends Omit {
+ thickness?: LinearLoaderThickness;
+ /** Determinate mode requires `value` 0–100. Omit for an indeterminate bar. */
+ value?: number;
+}
+
+function GeneralLinearLoader({
+ thickness = LinearLoaderThickness.REGULAR,
+ value,
+ ...rest
+}: GeneralLinearLoaderProps) {
+ return (
+
+ );
+}
+
+export default GeneralLinearLoader;
diff --git a/app/src/components/atoms/loaders/GeneralLoader/GeneralLoader.stories.tsx b/app/src/components/atoms/loaders/GeneralLoader/GeneralLoader.stories.tsx
new file mode 100644
index 0000000..baa654c
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralLoader/GeneralLoader.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralLoader, { LoaderSize } from "@/components/atoms/loaders/GeneralLoader";
+
+const meta = {
+ title: "Atoms/Loaders/GeneralLoader",
+ component: GeneralLoader,
+ args: { size: LoaderSize.MD, label: "Loading" },
+ argTypes: { size: { control: "select", options: Object.values(LoaderSize) } },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Small: Story = { args: { size: LoaderSize.SM } };
+export const Large: Story = { args: { size: LoaderSize.LG } };
+export const NoLabel: Story = { args: { label: undefined } };
diff --git a/app/src/components/atoms/loaders/GeneralLoader/GeneralLoader.test.tsx b/app/src/components/atoms/loaders/GeneralLoader/GeneralLoader.test.tsx
new file mode 100644
index 0000000..b4c350d
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralLoader/GeneralLoader.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import GeneralLoader from "@/components/atoms/loaders/GeneralLoader";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralLoader", () => {
+ it("renders the loader root", () => {
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.loader.root)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/loaders/GeneralLoader/index.tsx b/app/src/components/atoms/loaders/GeneralLoader/index.tsx
new file mode 100644
index 0000000..7c2471d
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralLoader/index.tsx
@@ -0,0 +1,88 @@
+import { Box, Typography } from "@mui/material";
+import { keyframes, styled } from "@mui/material/styles";
+
+import Logo from "@/components/atoms/brand/Logo";
+
+/**
+ * Full-pane "we're booting up" loader — the Recrest logo gently pulses while
+ * the renderer wires up Redux/i18n/Tauri. Use this only for app-shell boot or
+ * top-level route transitions; inline loading states should reach for
+ * `GeneralSkeletonLoader` / `GeneralCircularLoader` / `GeneralLinearLoader`
+ * instead so the layout doesn't reflow.
+ */
+export const LoaderSize = {
+ SM: "sm",
+ MD: "md",
+ LG: "lg",
+} as const;
+
+export type LoaderSize = (typeof LoaderSize)[keyof typeof LoaderSize];
+
+export const LOADER_LOGO_PX: Record = {
+ [LoaderSize.SM]: 32,
+ [LoaderSize.MD]: 64,
+ [LoaderSize.LG]: 96,
+};
+
+const breathe = keyframes`
+ 0%, 100% { opacity: 0.55; transform: scale(0.96); }
+ 50% { opacity: 1; transform: scale(1.04); }
+`;
+
+const Root = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 16,
+ width: "100%",
+ height: "100%",
+ minHeight: 240,
+});
+
+interface MarkProps {
+ $size: LoaderSize;
+}
+
+const Mark = styled(Box, {
+ shouldForwardProp: (p) => p !== "$size",
+})(({ $size }) => ({
+ width: LOADER_LOGO_PX[$size],
+ height: LOADER_LOGO_PX[$size],
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ animation: `${breathe} 1800ms ease-in-out infinite`,
+ 'html[data-reduced-motion="true"] &': {
+ animation: "none",
+ opacity: 1,
+ transform: "scale(1)",
+ },
+}));
+
+const Label = styled(Typography)(({ theme }) => ({
+ fontSize: 12,
+ color: theme.palette.text.information,
+ letterSpacing: "0.04em",
+ textTransform: "uppercase",
+})) as typeof Typography;
+
+export interface GeneralLoaderProps {
+ size?: LoaderSize;
+ /** Optional caption shown under the mark (e.g. "Connecting to providers…"). */
+ label?: string;
+ "data-testid"?: string;
+}
+
+function GeneralLoader({ size = LoaderSize.MD, label, "data-testid": testId }: GeneralLoaderProps) {
+ return (
+
+
+
+
+ {label && {label} }
+
+ );
+}
+
+export default GeneralLoader;
diff --git a/app/src/components/atoms/loaders/GeneralSkeletonLoader/GeneralSkeletonLoader.stories.tsx b/app/src/components/atoms/loaders/GeneralSkeletonLoader/GeneralSkeletonLoader.stories.tsx
new file mode 100644
index 0000000..0534509
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralSkeletonLoader/GeneralSkeletonLoader.stories.tsx
@@ -0,0 +1,20 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralSkeletonLoader, {
+ SkeletonShape,
+} from "@/components/atoms/loaders/GeneralSkeletonLoader";
+
+const meta = {
+ title: "Atoms/Loaders/GeneralSkeletonLoader",
+ component: GeneralSkeletonLoader,
+ args: { shape: SkeletonShape.LINE, width: 240 },
+ argTypes: { shape: { control: "select", options: Object.values(SkeletonShape) } },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Line: Story = {};
+export const Block: Story = { args: { shape: SkeletonShape.BLOCK, width: 240, height: 80 } };
+export const Circle: Story = { args: { shape: SkeletonShape.CIRCLE, width: 64, height: 64 } };
diff --git a/app/src/components/atoms/loaders/GeneralSkeletonLoader/GeneralSkeletonLoader.test.tsx b/app/src/components/atoms/loaders/GeneralSkeletonLoader/GeneralSkeletonLoader.test.tsx
new file mode 100644
index 0000000..75d24c6
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralSkeletonLoader/GeneralSkeletonLoader.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import GeneralSkeletonLoader from "@/components/atoms/loaders/GeneralSkeletonLoader";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralSkeletonLoader", () => {
+ it("renders the skeleton root", () => {
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.skeletonLoader.root)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/loaders/GeneralSkeletonLoader/index.tsx b/app/src/components/atoms/loaders/GeneralSkeletonLoader/index.tsx
new file mode 100644
index 0000000..c0efb21
--- /dev/null
+++ b/app/src/components/atoms/loaders/GeneralSkeletonLoader/index.tsx
@@ -0,0 +1,41 @@
+import { Skeleton, type SkeletonProps } from "@mui/material";
+
+/**
+ * Inline content placeholder for the milliseconds-to-seconds gap between
+ * "we're loading" and "data ready". Use one of `LINE` / `BLOCK` / `CIRCLE`
+ * via the `shape` prop — the variant maps to MUI's `Skeleton variant`,
+ * `width`/`height` default per shape but can be overridden.
+ *
+ * Picks the right primitive automatically: the LINE shape draws a 1em-tall
+ * pill so it sits on the typography baseline; BLOCK draws a rectangular
+ * surface; CIRCLE draws an avatar-sized round placeholder.
+ */
+export const SkeletonShape = {
+ LINE: "line",
+ BLOCK: "block",
+ CIRCLE: "circle",
+} as const;
+
+export type SkeletonShape = (typeof SkeletonShape)[keyof typeof SkeletonShape];
+
+const SHAPE_TO_VARIANT: Record> = {
+ [SkeletonShape.LINE]: "text",
+ [SkeletonShape.BLOCK]: "rectangular",
+ [SkeletonShape.CIRCLE]: "circular",
+};
+
+export interface GeneralSkeletonLoaderProps extends Omit {
+ shape?: SkeletonShape;
+ /** Disable the shimmer (e.g. `prefers-reduced-motion`). Defaults to `wave`. */
+ animation?: "wave" | "pulse" | false;
+}
+
+function GeneralSkeletonLoader({
+ shape = SkeletonShape.LINE,
+ animation = "wave",
+ ...rest
+}: GeneralSkeletonLoaderProps) {
+ return ;
+}
+
+export default GeneralSkeletonLoader;
diff --git a/app/src/components/atoms/sparklines/GeneralSparkline/GeneralSparkline.stories.tsx b/app/src/components/atoms/sparklines/GeneralSparkline/GeneralSparkline.stories.tsx
new file mode 100644
index 0000000..467ef85
--- /dev/null
+++ b/app/src/components/atoms/sparklines/GeneralSparkline/GeneralSparkline.stories.tsx
@@ -0,0 +1,20 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralSparkline from "@/components/atoms/sparklines/GeneralSparkline";
+
+const meta = {
+ title: "Atoms/Sparklines/GeneralSparkline",
+ component: GeneralSparkline,
+ args: {
+ data: [3, 5, 2, 0, 4, 6, 8, 5, 2, 1, 0, 3, 4, 6],
+ width: 120,
+ height: 24,
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const AllZeros: Story = { args: { data: [0, 0, 0, 0, 0, 0, 0] } };
diff --git a/app/src/components/atoms/sparklines/GeneralSparkline/GeneralSparkline.test.tsx b/app/src/components/atoms/sparklines/GeneralSparkline/GeneralSparkline.test.tsx
new file mode 100644
index 0000000..52c1ac4
--- /dev/null
+++ b/app/src/components/atoms/sparklines/GeneralSparkline/GeneralSparkline.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+
+import GeneralSparkline from "@/components/atoms/sparklines/GeneralSparkline";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralSparkline", () => {
+ it("renders one bar per data point", () => {
+ const data = [1, 2, 3, 4, 5];
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ const root = getByTestId(COMPONENT_TEST_IDS.atoms.sparkline.root);
+ expect(root.children.length).toBe(data.length);
+ });
+});
diff --git a/app/src/components/atoms/sparklines/GeneralSparkline/index.tsx b/app/src/components/atoms/sparklines/GeneralSparkline/index.tsx
new file mode 100644
index 0000000..e8309e1
--- /dev/null
+++ b/app/src/components/atoms/sparklines/GeneralSparkline/index.tsx
@@ -0,0 +1,67 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+export interface GeneralSparklineProps {
+ data: readonly number[];
+ width?: number;
+ height?: number;
+ /** Bar colour. Defaults to `theme.palette.text.informationLight`. */
+ accentColor?: string;
+ /** Colour for zero-value buckets. Defaults to `theme.palette.border.default`. */
+ zeroColor?: string;
+ /** Gap between bars in pixels. Default `2`. */
+ gap?: number;
+ /** Flat-line height in pixels for zero-valued buckets. Default `2`. */
+ minBarHeight?: number;
+ testId?: string;
+}
+
+const Bars = styled(Box)({
+ display: "flex",
+ alignItems: "flex-end",
+}) as typeof Box;
+
+const Bar = styled(Box, {
+ shouldForwardProp: (p) => p !== "accent" && p !== "zero" && p !== "isZero",
+})<{ accent?: string; zero?: string; isZero: boolean }>(({ theme, accent, zero, isZero }) => ({
+ flex: 1,
+ minWidth: 0,
+ borderRadius: 1,
+ minHeight: 2,
+ backgroundColor: isZero
+ ? (zero ?? theme.palette.border.default)
+ : (accent ?? theme.palette.text.informationLight ?? theme.palette.text.secondary),
+}));
+
+function GeneralSparkline({
+ data,
+ width = 88,
+ height = 18,
+ accentColor,
+ zeroColor,
+ gap = 2,
+ minBarHeight = 2,
+ testId,
+}: GeneralSparklineProps) {
+ const peak = Math.max(1, ...data);
+ return (
+
+ {data.map((v, i) => {
+ const isZero = v === 0;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default GeneralSparkline;
diff --git a/app/src/components/atoms/transitions/PageTransition/PageTransition.stories.tsx b/app/src/components/atoms/transitions/PageTransition/PageTransition.stories.tsx
new file mode 100644
index 0000000..05c6ba9
--- /dev/null
+++ b/app/src/components/atoms/transitions/PageTransition/PageTransition.stories.tsx
@@ -0,0 +1,32 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import PageTransition from "@/components/atoms/transitions/PageTransition";
+
+const Stage = styled(Box)({ height: 240, width: 360 });
+
+const meta: Meta = {
+ title: "Atoms/Transitions/PageTransition",
+ component: PageTransition,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+ Hello, page
+
+ ),
+};
diff --git a/app/src/components/atoms/transitions/PageTransition/PageTransition.test.tsx b/app/src/components/atoms/transitions/PageTransition/PageTransition.test.tsx
new file mode 100644
index 0000000..e9fddb5
--- /dev/null
+++ b/app/src/components/atoms/transitions/PageTransition/PageTransition.test.tsx
@@ -0,0 +1,18 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import PageTransition from "@/components/atoms/transitions/PageTransition";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("PageTransition", () => {
+ it("renders its children", () => {
+ const { getByTestId } = renderWithProviders(
+
+ page body
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.pageTransition.body)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/atoms/transitions/PageTransition/index.tsx b/app/src/components/atoms/transitions/PageTransition/index.tsx
new file mode 100644
index 0000000..4c1247f
--- /dev/null
+++ b/app/src/components/atoms/transitions/PageTransition/index.tsx
@@ -0,0 +1,84 @@
+import { type ReactNode, useLayoutEffect, useState } from "react";
+
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+
+/**
+ * Page-level enter animation. Fades + translates up by 6px over 200ms; honours
+ * `useReducedMotion` (= explicit Settings toggle ∪ OS media query) by skipping
+ * the animation entirely and showing the page in its final state. Single
+ * tween, no library — emotion `styled()` + a one-shot `data-entered` flag.
+ *
+ * The animation fires on every mount, which is the natural unit for
+ * react-router page swaps. Children that animate further internally (skeleton
+ * → real content) handle that separately; PageTransition only owns the
+ * page-shell enter.
+ */
+interface PageTransitionProps {
+ children: ReactNode;
+ className?: string;
+ /** Delay before the enter animation starts. Tiny defaults (0–60ms) help
+ * pages that mount inside an outer transition feel smoother. */
+ delay?: number;
+}
+
+const Root = styled(Box, {
+ shouldForwardProp: (p) => p !== "entered" && p !== "skipAnimation" && p !== "delay",
+})<{ entered: boolean; skipAnimation: boolean; delay: number }>(
+ ({ entered, skipAnimation, delay }) => ({
+ // `height: 100%` (not `minHeight`) so PageTransition exactly fills its
+ // scroll parent — Settings' 2-pane layout reads `height: 100%` off this
+ // element and needs a deterministic anchor. Pages that need to scroll
+ // (Activity, Branches, Repos) own their own internal scroller and let
+ // the outer ContentScroll stay quiet — see each page's Root styling.
+ height: "100%",
+ minHeight: 0,
+ width: "100%",
+ minWidth: 0,
+ display: "flex",
+ flexDirection: "column",
+ opacity: skipAnimation ? 1 : entered ? 1 : 0,
+ transform: skipAnimation
+ ? "none"
+ : entered
+ ? "translate3d(0, 0, 0)"
+ : "translate3d(0, 10px, 0)",
+ // Slightly longer + softer than the previous 200/220ms — the old timing
+ // was so short the animation read as a hard pop. 320ms with an
+ // ease-out-back-ish curve gives the page time to settle visibly.
+ transition: skipAnimation
+ ? "none"
+ : `opacity 320ms cubic-bezier(0.2, 0.8, 0.2, 1) ${delay}ms, transform 360ms cubic-bezier(0.2, 0.8, 0.2, 1) ${delay}ms`,
+ willChange: skipAnimation ? "auto" : "opacity, transform",
+ }),
+);
+
+function PageTransition({ children, className, delay = 0 }: PageTransitionProps) {
+ const reduced = useReducedMotion();
+ const [entered, setEntered] = useState(false);
+
+ // useLayoutEffect so the initial paint always happens with opacity:0 →
+ // first commit + transition kicks in on the next frame. useEffect would
+ // race the browser and occasionally let the destination state slip through.
+ useLayoutEffect(() => {
+ if (reduced) return;
+ const id = requestAnimationFrame(() => setEntered(true));
+ return () => cancelAnimationFrame(id);
+ }, [reduced]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default PageTransition;
diff --git a/app/src/components/atoms/transitions/StaggeredReveal/StaggeredReveal.stories.tsx b/app/src/components/atoms/transitions/StaggeredReveal/StaggeredReveal.stories.tsx
new file mode 100644
index 0000000..4b644ee
--- /dev/null
+++ b/app/src/components/atoms/transitions/StaggeredReveal/StaggeredReveal.stories.tsx
@@ -0,0 +1,32 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import StaggeredReveal from "@/components/atoms/transitions/StaggeredReveal";
+
+const Item = styled(Box)(({ theme }) => ({
+ padding: theme.spacing(1),
+ background: "#f0f0f0",
+ margin: theme.spacing(0.5),
+}));
+
+const meta: Meta = {
+ title: "Atoms/Transitions/StaggeredReveal",
+ component: StaggeredReveal,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+ - Item 1
+ - Item 2
+ - Item 3
+ - Item 4
+
+ ),
+};
diff --git a/app/src/components/atoms/transitions/StaggeredReveal/StaggeredReveal.test.tsx b/app/src/components/atoms/transitions/StaggeredReveal/StaggeredReveal.test.tsx
new file mode 100644
index 0000000..1ebf563
--- /dev/null
+++ b/app/src/components/atoms/transitions/StaggeredReveal/StaggeredReveal.test.tsx
@@ -0,0 +1,26 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import StaggeredReveal from "@/components/atoms/transitions/StaggeredReveal";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("StaggeredReveal", () => {
+ it("wraps each child with a stagger-index attribute", () => {
+ const { getByTestId, container } = renderWithProviders(
+
+
+ a
+ b
+ c
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.staggeredReveal.wrap)).toBeInTheDocument();
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.staggeredReveal.itemA)).toBeInTheDocument();
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.staggeredReveal.itemB)).toBeInTheDocument();
+ expect(getByTestId(COMPONENT_TEST_IDS.atoms.staggeredReveal.itemC)).toBeInTheDocument();
+ expect(container.querySelectorAll("[data-stagger-index]").length).toBe(3);
+ });
+});
diff --git a/app/src/components/atoms/transitions/StaggeredReveal/index.tsx b/app/src/components/atoms/transitions/StaggeredReveal/index.tsx
new file mode 100644
index 0000000..0c77c23
--- /dev/null
+++ b/app/src/components/atoms/transitions/StaggeredReveal/index.tsx
@@ -0,0 +1,100 @@
+import { Children, type ReactNode, isValidElement, useLayoutEffect, useState } from "react";
+
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import { useReducedMotion } from "@/hooks/useReducedMotion";
+
+/**
+ * Wrap a list of sibling elements and animate them in with a per-child
+ * stagger. Each child gets a `transition-delay` of `step * index` ms. Same
+ * 6px translate + fade as `PageTransition`, so a page that uses both reads
+ * as one continuous reveal.
+ *
+ * For very long lists (>= 12 items) we cap the stagger budget so the last
+ * card doesn't take a full second to appear — sub-frame staggers feel
+ * cheap, but you don't want the user staring at a half-loaded grid.
+ */
+interface StaggeredRevealProps {
+ children: ReactNode;
+ /** ms between consecutive children. Defaults to 40ms. */
+ step?: number;
+ /** Hard cap on the cumulative delay. Defaults to 240ms (≈6 children). */
+ maxDelay?: number;
+ className?: string;
+ /** Element used as the flex/grid container. Defaults to a plain Box. */
+ component?: React.ElementType;
+}
+
+interface ItemProps {
+ entered: boolean;
+ skipAnimation: boolean;
+ delay: number;
+}
+
+// Important: `display: contents` would skip the wrapper from layout entirely
+// (cleanest for grid parents that want their grandchildren to be tracks), but
+// then `opacity`/`transform` no longer apply — block-level containers must
+// own a generated box for transforms to take effect. So we keep the wrapper
+// as a real flex/grid child and explicitly stretch it so children like
+// `` (which shrink to content by default) fill the parent track.
+const Item = styled(Box, {
+ shouldForwardProp: (p) => p !== "entered" && p !== "skipAnimation" && p !== "delay",
+})(({ entered, skipAnimation, delay }) => ({
+ display: "flex",
+ flexDirection: "column",
+ width: "100%",
+ minWidth: 0,
+ // When the parent is a CSS grid, `align-self: stretch` is the default —
+ // but only if the item itself doesn't have a fixed height. Forcing
+ // `height: 100%` keeps the wrapper transparent for grid sizing too, so
+ // a child KpiButton spans the full grid track AND track height.
+ "& > *": { flex: 1, width: "100%", minWidth: 0 },
+ opacity: skipAnimation ? 1 : entered ? 1 : 0,
+ transform: skipAnimation ? "none" : entered ? "translate3d(0, 0, 0)" : "translate3d(0, 6px, 0)",
+ transition: skipAnimation
+ ? "none"
+ : `opacity 200ms cubic-bezier(0.22, 1, 0.36, 1) ${delay}ms, transform 220ms cubic-bezier(0.22, 1, 0.36, 1) ${delay}ms`,
+ willChange: skipAnimation ? "auto" : "opacity, transform",
+}));
+
+function StaggeredReveal({
+ children,
+ step = 40,
+ maxDelay = 240,
+ className,
+ component,
+}: StaggeredRevealProps) {
+ const reduced = useReducedMotion();
+ const [entered, setEntered] = useState(false);
+
+ useLayoutEffect(() => {
+ if (reduced) return;
+ const id = requestAnimationFrame(() => setEntered(true));
+ return () => cancelAnimationFrame(id);
+ }, [reduced]);
+
+ const arr = Children.toArray(children);
+
+ return (
+
+ {arr.map((child, i) => {
+ const delay = Math.min(maxDelay, step * i);
+ const key = isValidElement(child) && child.key != null ? child.key : i;
+ return (
+ -
+ {child}
+
+ );
+ })}
+
+ );
+}
+
+export default StaggeredReveal;
diff --git a/app/src/components/molecules/AuthorAvatar/AuthorAvatar.stories.tsx b/app/src/components/molecules/AuthorAvatar/AuthorAvatar.stories.tsx
deleted file mode 100644
index 8eab088..0000000
--- a/app/src/components/molecules/AuthorAvatar/AuthorAvatar.stories.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { AuthorAvatar } from "@/components/molecules/AuthorAvatar";
-
-const meta: Meta = {
- title: "Molecules/AuthorAvatar",
- component: AuthorAvatar,
-};
-
-export default meta;
-
-export const Initials: StoryObj = {
- args: { name: "Anna Müller" },
-};
-
-export const Single: StoryObj = { args: { name: "octocat" } };
-
-export const Large: StoryObj = {
- args: { name: "Ada Lovelace", size: 48 },
-};
-
-export const Unknown: StoryObj = { args: { name: null } };
diff --git a/app/src/components/molecules/AuthorAvatar/AuthorAvatar.test.tsx b/app/src/components/molecules/AuthorAvatar/AuthorAvatar.test.tsx
deleted file mode 100644
index eb43082..0000000
--- a/app/src/components/molecules/AuthorAvatar/AuthorAvatar.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { AuthorAvatar } from "@/components/molecules/AuthorAvatar";
-
-describe("AuthorAvatar", () => {
- it("rendert Initialen aus dem Namen", () => {
- render( );
- expect(screen.getByText("AM")).toBeInTheDocument();
- });
-
- it("nutzt das aria-label mit dem vollen Namen", () => {
- render( );
- expect(screen.getByLabelText("Alice")).toBeInTheDocument();
- });
-
- it("fällt auf '?' zurück, wenn kein Name gegeben ist", () => {
- render( );
- expect(screen.getByText("?")).toBeInTheDocument();
- });
-
- it("rendert ein Bild, wenn eine src übergeben ist", () => {
- const { container } = render( );
- // The image is presentational now — the surrounding chip carries the
- // aria-label, so we look it up via the DOM rather than by role.
- const img = container.querySelector("img");
- expect(img).not.toBeNull();
- expect(img).toHaveAttribute("src", "https://example.com/pic.png");
- expect(screen.getByLabelText("Bob")).toBeInTheDocument();
- });
-
- it("baut eine Gravatar-URL aus einer E-Mail", () => {
- // `@example.com` is suppressed on purpose (dev-seed convention — the
- // domain always 404s on Gravatar so we skip the request entirely).
- // Use a real-looking address here so the Gravatar fallback path runs.
- const { container } = render( );
- const img = container.querySelector("img");
- expect(img).not.toBeNull();
- expect(img?.getAttribute("src")).toMatch(/^https:\/\/www\.gravatar\.com\/avatar\//);
- });
-
- it("überspringt Gravatar für Dev-Seed-Mails (.example.com)", () => {
- const { container } = render( );
- expect(container.querySelector("img")).toBeNull();
- });
-});
diff --git a/app/src/components/molecules/AuthorAvatar/index.tsx b/app/src/components/molecules/AuthorAvatar/index.tsx
deleted file mode 100644
index 12215d4..0000000
--- a/app/src/components/molecules/AuthorAvatar/index.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import { type CSSProperties, useEffect, useState } from "react";
-
-import { gravatarUrl } from "@/lib/gravatar";
-import { initialsFromName } from "@/lib/initials";
-
-interface AuthorAvatarProps {
- name: string | null | undefined;
- size?: number;
- /** Explicit image URL. Wins over the `email` fallback below. */
- src?: string | null;
- /** Commit / PR author email. When provided (and `src` isn't), we derive a
- * Gravatar URL from its md5 so the avatar matches what VSCode / GitLens
- * renders for the same commit. A 404 from Gravatar falls back to the
- * coloured-initials chip — no broken-image placeholder ever ships. */
- email?: string | null;
- className?: string;
-}
-
-/** Small round avatar — explicit src → Gravatar-from-email → coloured
- * initials chip. The image is always rendered **inside a fixed-size circular
- * mask** with `object-fit: cover`, so a 2x Gravatar (which comes back as a
- * larger-than-container bitmap to stay crisp on HiDPI displays) is cleanly
- * cropped to the circle instead of stretching the layout or bleeding past
- * the chip edge. The initials chip sits underneath so a slow image never
- * leaves a blank hole and a 404 falls back cleanly via `onError`. */
-export function AuthorAvatar({ name, size = 24, src, email, className }: AuthorAvatarProps) {
- const label = initialsFromName(name) || "?";
- // Bot accounts (Dependabot, Renovate, GitHub Actions, etc.) rarely have a
- // real Gravatar; their commits and MRs all share one canonical GitHub
- // avatar. Match by substring on the author name or email so a commit
- // signed as "dependabot[bot]" or a PR fetched before `authorAvatarUrl`
- // existed still picks up the right face.
- const botSrc = src ?? resolveBotAvatar(name, email, size);
- // Skip the Gravatar round-trip for the well-known dev-seed domains —
- // they always 404 (because `?d=404` is the explicit fallback signal),
- // which fills the console with noise during `yarn dev:web` and Playwright
- // smoke runs. Falling straight through to the coloured initials chip
- // matches what the user sees once the request has failed anyway.
- const skipGravatar = !!email && isDevSeedEmail(email);
- const resolvedSrc = botSrc ?? (email && !skipGravatar ? gravatarUrl(email, size) : null);
- const [imgFailed, setImgFailed] = useState(false);
- useEffect(() => {
- // Reset the failure flag whenever the underlying URL changes — otherwise
- // a failed Gravatar for author A would permanently suppress a successful
- // one for author B reusing the same component slot.
- setImgFailed(false);
- }, [resolvedSrc]);
-
- const chipStyle: CSSProperties = {
- // Explicit min/max so a flex parent (e.g. the MR drawer) can't squish or
- // stretch the chip off its square footprint — which is what made the
- // Gravatar "slightly scaled" look bad.
- width: size,
- minWidth: size,
- maxWidth: size,
- height: size,
- minHeight: size,
- maxHeight: size,
- borderRadius: "50%",
- background: gradientFor(name ?? ""),
- color: "#fff",
- fontSize: Math.max(9, Math.round(size * 0.4)),
- fontWeight: 600,
- display: "inline-flex",
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
- overflow: "hidden",
- letterSpacing: 0,
- position: "relative",
- // Keep images from inheriting global Tailwind preflight img styles like
- // `max-width: 100%` that could leak into the absolutely-positioned img
- // inside.
- boxSizing: "content-box",
- };
-
- if (!resolvedSrc || imgFailed) {
- return (
-
- {label}
-
- );
- }
-
- return (
-
- {label}
- setImgFailed(true)}
- style={{
- // Filling the circular mask — width/height 100% to the chip, plus
- // `object-fit: cover` so Gravatar's 2x bitmap is cropped not
- // squeezed. `display: block` avoids the tiny inline-descent gap
- // that Tailwind's preflight otherwise leaves under .
- position: "absolute",
- inset: 0,
- width: "100%",
- height: "100%",
- display: "block",
- objectFit: "cover",
- borderRadius: "50%",
- background: "transparent",
- maxWidth: "none",
- margin: 0,
- padding: 0,
- }}
- />
-
- );
-}
-
-/** Email domains used by the dev-mode seed (and the Playwright fixture seed).
- * These addresses don't map to real Gravatars, so issuing the request just
- * produces `404` log noise — the cheap avoid-it-up-front check below saves
- * a few network round-trips per page load. */
-const DEV_SEED_EMAIL_DOMAINS = ["@example.com", "@renovateapp.com"] as const;
-
-function isDevSeedEmail(email: string): boolean {
- const lower = email.toLowerCase();
- return DEV_SEED_EMAIL_DOMAINS.some((d) => lower.endsWith(d));
-}
-
-/** Map a display name or commit-email to the canonical GitHub avatar URL
- * for well-known bots. Returns null for anything we don't recognise so the
- * caller can fall back to Gravatar-by-email. We query GitHub's
- * `/u/:login.png?size=N` endpoint because it handles both App accounts
- * (dependabot, renovate) and regular bot users uniformly, and it serves an
- * appropriately-sized asset without needing the GitHub API. */
-function resolveBotAvatar(
- name: string | null | undefined,
- email: string | null | undefined,
- size: number,
-): string | null {
- const sniff = ((name ?? "") + " " + (email ?? "")).toLowerCase();
- // Retina boost: pull a 2x bitmap so the circular mask (object-fit: cover)
- // stays crisp on HiDPI displays, mirroring what AuthorAvatar does for
- // Gravatar URLs.
- const px = Math.max(32, size * 2);
- if (sniff.includes("dependabot")) {
- return `https://github.com/dependabot.png?size=${px}`;
- }
- if (sniff.includes("renovate")) {
- return `https://github.com/renovate-bot.png?size=${px}`;
- }
- if (sniff.includes("github-actions") || sniff.includes("github actions")) {
- return `https://github.com/github-actions.png?size=${px}`;
- }
- return null;
-}
-
-/** Color stops for the default avatars. Direction is applied separately so
- * it can be rotated deterministically from the hash — two avatars with
- * different keys but the same color slot still feel distinct. */
-const GRADIENT_STOPS = [
- "#ff7a59,#d6336c",
- "#4f8cff,#7b2ff7",
- "#10b981,#0ea5a3",
- "#f59e0b,#ef4444",
- "#06b6d4,#3b82f6",
- "#a855f7,#ec4899",
- "#84cc16,#10b981",
- "#f97316,#eab308",
-];
-
-/** Plan 1 §B.2: cycle four gradient directions so the corner-bright pattern
- * isn't always top-left. Using the cardinal diagonals only (no top/bottom
- * axis) keeps the visual weight balanced regardless of which slot is
- * picked. */
-const GRADIENT_DIRECTIONS = ["135deg", "45deg", "225deg", "315deg"];
-
-function gradientFor(key: string): string {
- let hash = 0;
- for (let i = 0; i < key.length; i += 1) {
- hash = (hash * 31 + key.charCodeAt(i)) | 0;
- }
- const abs = Math.abs(hash);
- const stops = GRADIENT_STOPS[abs % GRADIENT_STOPS.length] ?? GRADIENT_STOPS[0]!;
- // Bit-shift so direction and color don't share the same low bits, keeping
- // their distributions independent.
- const dir =
- GRADIENT_DIRECTIONS[(abs >>> 3) % GRADIENT_DIRECTIONS.length] ?? GRADIENT_DIRECTIONS[0]!;
- return `linear-gradient(${dir},${stops})`;
-}
diff --git a/app/src/components/molecules/BranchFilterChip/BranchFilterChip.stories.tsx b/app/src/components/molecules/BranchFilterChip/BranchFilterChip.stories.tsx
deleted file mode 100644
index 9b2a48d..0000000
--- a/app/src/components/molecules/BranchFilterChip/BranchFilterChip.stories.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { BranchFilterChip } from "@/components/molecules/BranchFilterChip";
-
-const meta: Meta = {
- title: "Molecules/BranchFilterChip",
- component: BranchFilterChip,
-};
-
-export default meta;
-
-const noop = () => {};
-
-export const Inactive: StoryObj = {
- args: { active: false, onClick: noop, count: 12, children: "All" },
-};
-
-export const Active: StoryObj = {
- args: { active: true, onClick: noop, count: 3, children: "Ahead" },
-};
-
-export const NoCount: StoryObj = {
- args: { active: false, onClick: noop, children: "Remote" },
-};
diff --git a/app/src/components/molecules/BranchFilterChip/BranchFilterChip.test.tsx b/app/src/components/molecules/BranchFilterChip/BranchFilterChip.test.tsx
deleted file mode 100644
index f52ff05..0000000
--- a/app/src/components/molecules/BranchFilterChip/BranchFilterChip.test.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { BranchFilterChip } from "@/components/molecules/BranchFilterChip";
-
-describe("BranchFilterChip", () => {
- it("rendert Label und Counter", () => {
- render(
- {}} count={7}>
- Open
- ,
- );
- expect(screen.getByRole("button", { name: /Open/ })).toBeInTheDocument();
- expect(screen.getByText("7")).toBeInTheDocument();
- });
-
- it("setzt die active-Klasse, wenn active=true", () => {
- render(
- {}}>
- x
- ,
- );
- expect(screen.getByRole("button").className).toContain("active");
- });
-
- it("ruft onClick beim Klick auf", async () => {
- const user = userEvent.setup();
- const onClick = vi.fn();
- render(
-
- x
- ,
- );
- await user.click(screen.getByRole("button"));
- expect(onClick).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/app/src/components/molecules/BranchFilterChip/index.tsx b/app/src/components/molecules/BranchFilterChip/index.tsx
deleted file mode 100644
index e137efd..0000000
--- a/app/src/components/molecules/BranchFilterChip/index.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { ReactNode } from "react";
-
-interface BranchFilterChipProps {
- active: boolean;
- onClick: () => void;
- count?: number;
- children: ReactNode;
-}
-
-/** Pill-style filter chip for the Branches page filter row. Shows the label
- * next to an optional counter. The active variant uses the accent colour. */
-export function BranchFilterChip({ active, onClick, count, children }: BranchFilterChipProps) {
- return (
-
- {children}
- {count != null && {count} }
-
- );
-}
diff --git a/app/src/components/molecules/DetailSection/index.tsx b/app/src/components/molecules/DetailSection/index.tsx
deleted file mode 100644
index 6b257f8..0000000
--- a/app/src/components/molecules/DetailSection/index.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { type ReactNode, useState } from "react";
-
-import { Icon } from "@/components/atoms/Icon";
-
-interface DetailSectionProps {
- title: string;
- meta?: ReactNode;
- children: ReactNode;
- defaultOpen?: boolean;
-}
-
-export function DetailSection({ title, meta, children, defaultOpen = true }: DetailSectionProps) {
- const [open, setOpen] = useState(defaultOpen);
- return (
-
-
- setOpen((o) => !o)}
- aria-expanded={open ? "true" : "false"}
- aria-label={`${open ? "Collapse" : "Expand"} ${title}`}
- >
-
-
-
- {title}
-
- {meta != null && {meta} }
-
- {open &&
{children}
}
-
- );
-}
diff --git a/app/src/components/molecules/Drawer/index.tsx b/app/src/components/molecules/Drawer/index.tsx
deleted file mode 100644
index 4ba6e0d..0000000
--- a/app/src/components/molecules/Drawer/index.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import { type ReactNode, useEffect, useRef, useState } from "react";
-
-import { useDrawerSwipe } from "@/hooks/useDrawerSwipe";
-
-/**
- * Shared right-side panel envelope used by `MergeRequestsPage`'s MR drawer
- * and (going forward) the repo detail PR drawer. The Phase 0.2 prop surface
- * is a discriminated union — callers pass either `children` for free-form
- * content or `tabs` for a tabbed shell. `tabs` is reserved for Plan 3 §C.5
- * ("Files Changed" tab) and not exercised yet; the type is locked in now so
- * Plan 3 can add it without breaking callers.
- */
-export type DrawerTab = {
- id: string;
- label: string;
- content: ReactNode;
-};
-
-type DrawerCommonProps = {
- open: boolean;
- side?: "right" | "left";
- size?: "sm" | "md" | "lg" | string;
- onClose: () => void;
- header?: ReactNode;
- footer?: ReactNode;
- className?: string;
- /** Optional `data-testid` so callers can target the outer aside in tests. */
- testId?: string;
- /**
- * When true, a transparent backdrop is rendered behind the drawer; clicking
- * it dismisses the drawer (Plan 1 §A.1). Defaults to `true` for the
- * floating overlay variant and to `false` for the inline variant
- * (className contains `a-drawer-inline`) — inline drawers share their
- * column with surrounding chrome and a click-outside dismissal would
- * conflict with the host page. Pass an explicit value to override.
- */
- dismissOnBackdrop?: boolean;
-};
-
-type DrawerChildrenProps = DrawerCommonProps & {
- children: ReactNode;
- tabs?: never;
- defaultTabId?: never;
- onTabChange?: never;
-};
-
-type DrawerTabsProps = DrawerCommonProps & {
- tabs: DrawerTab[];
- defaultTabId?: string;
- onTabChange?: (id: string) => void;
- children?: never;
-};
-
-export type DrawerProps = DrawerChildrenProps | DrawerTabsProps;
-
-function widthFor(size: DrawerProps["size"]): string | undefined {
- if (!size || size === "md") return undefined;
- if (size === "sm") return "300px";
- if (size === "lg") return "440px";
- return size;
-}
-
-export function Drawer(props: DrawerProps) {
- const {
- open,
- side = "right",
- size,
- onClose,
- header,
- footer,
- className,
- testId,
- dismissOnBackdrop,
- } = props;
- const asideRef = useRef(null);
-
- // Inline drawers ride inside the host grid (`a-drawer-inline` removes the
- // fixed-overlay positioning), so a backdrop spanning the viewport would
- // dismiss the drawer on completely unrelated clicks. Default the inline
- // variant to opt-out and the floating variant to opt-in; callers can
- // still override via the explicit prop.
- const isInline = (className ?? "").includes("a-drawer-inline");
- const backdropEnabled = dismissOnBackdrop ?? !isInline;
-
- // ESC closes the drawer. Bound only while the drawer is open so we don't
- // intercept Escape elsewhere.
- useEffect(() => {
- if (!open) return;
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [open, onClose]);
-
- // D.5: swipe-to-dismiss on touch devices. Right-side drawers close on
- // a rightward swipe (toward the screen edge); left-side mirrors that.
- useDrawerSwipe({
- ref: asideRef,
- onClose,
- enabled: open,
- direction: side === "left" ? "left" : "right",
- });
-
- if (!open) return null;
-
- const widthOverride = widthFor(size);
-
- const sideClass = side === "left" ? "a-drawer-side-left" : "a-drawer-side-right";
-
- return (
- <>
- {backdropEnabled && (
-
- )}
-
- >
- );
-}
-
-function DrawerTabs({
- tabs,
- defaultTabId,
- onTabChange,
-}: {
- tabs: DrawerTab[];
- defaultTabId?: string;
- onTabChange?: (id: string) => void;
-}) {
- const initial = defaultTabId ?? tabs[0]?.id ?? "";
- const [active, setActive] = useState(initial);
- const select = (id: string) => {
- setActive(id);
- onTabChange?.(id);
- };
- const current = tabs.find((t) => t.id === active) ?? tabs[0];
- return (
-
-
- {tabs.map((tab) => (
- select(tab.id)}
- >
- {tab.label}
-
- ))}
-
-
- {current?.content}
-
-
- );
-}
diff --git a/app/src/components/molecules/EmptyState/EmptyState.stories.tsx b/app/src/components/molecules/EmptyState/EmptyState.stories.tsx
deleted file mode 100644
index c2f2e1f..0000000
--- a/app/src/components/molecules/EmptyState/EmptyState.stories.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { GitPullRequest } from "lucide-react";
-
-import { EmptyState } from "@/components/molecules/EmptyState";
-
-const meta: Meta = {
- title: "Molecules/EmptyState",
- component: EmptyState,
-};
-
-export default meta;
-
-type Story = StoryObj;
-
-export const Default: Story = {
- args: {
- title: "Nothing here",
- description: "Add something to get started.",
- },
-};
-
-export const WithLucideIcon: Story = {
- args: {
- icon: GitPullRequest,
- title: "No open merge requests",
- description: "When something comes in, it'll show up here.",
- },
-};
-
-export const Snoozing: Story = {
- args: {
- mascot: "snoozing",
- title: "No open merge requests",
- description: "Everything's quiet — enjoy it while it lasts.",
- },
-};
-
-export const Celebrating: Story = {
- args: {
- mascot: "celebrating",
- title: "Everything clean and in sync",
- description: "Nothing needs your attention right now.",
- },
-};
-
-export const Searching: Story = {
- args: {
- mascot: "searching",
- title: "No branches match your filter",
- description: "Try a broader filter or clear it.",
- },
-};
-
-export const Waving: Story = {
- args: {
- mascot: "waving",
- title: "Nothing here yet",
- description: "Add your first repository to get started.",
- },
-};
-
-export const Shrugging: Story = {
- args: {
- mascot: "shrugging",
- title: "Nothing to show",
- description: "There's just nothing here — yet.",
- },
-};
diff --git a/app/src/components/molecules/EmptyState/EmptyState.test.tsx b/app/src/components/molecules/EmptyState/EmptyState.test.tsx
deleted file mode 100644
index acfc93c..0000000
--- a/app/src/components/molecules/EmptyState/EmptyState.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { EmptyState } from "@/components/molecules/EmptyState";
-
-describe("EmptyState", () => {
- it("rendert den Titel", () => {
- render( );
- expect(screen.getByRole("heading", { name: "Keine Repos" })).toBeInTheDocument();
- });
-
- it("zeigt Description und Action", () => {
- render(
- Hinzufügen }
- />,
- );
- expect(screen.getByText("Noch nichts hier")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Hinzufügen" })).toBeInTheDocument();
- });
-
- it("rendert das optionale Icon", () => {
- function DummyIcon({ className }: { className?: string }) {
- return ;
- }
- render( );
- expect(screen.getByTestId("dummy")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/molecules/EmptyState/index.tsx b/app/src/components/molecules/EmptyState/index.tsx
deleted file mode 100644
index cf3476d..0000000
--- a/app/src/components/molecules/EmptyState/index.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { type ComponentType, type ReactNode } from "react";
-
-import { Mascot, type MascotVariant } from "@/components/atoms/Mascot";
-import { cn } from "@/lib/utils";
-
-interface EmptyStateProps {
- /** Optional Lucide-style icon. Ignored when `mascot` is set. */
- icon?: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
- /** Friendly Recrest character to show above the text. Takes precedence over `icon`. */
- mascot?: MascotVariant;
- /** Pixel size of the mascot SVG. Default 112; use ~88 in compact cards. */
- mascotSize?: number;
- title: string;
- description?: ReactNode;
- action?: ReactNode;
- className?: string;
-}
-
-export function EmptyState({
- icon: Icon,
- mascot,
- mascotSize,
- title,
- description,
- action,
- className,
-}: EmptyStateProps) {
- return (
-
- {mascot ? (
-
- ) : (
- Icon && (
-
-
-
- )
- )}
-
-
{title}
- {description &&
{description}
}
-
- {action &&
{action}
}
-
- );
-}
diff --git a/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.stories.tsx b/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.stories.tsx
deleted file mode 100644
index 39955be..0000000
--- a/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.stories.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { ExternalLinkButton } from "@/components/molecules/ExternalLinkButton";
-
-const meta: Meta = {
- title: "Molecules/ExternalLinkButton",
- component: ExternalLinkButton,
-};
-
-export default meta;
-
-export const WithLabel: StoryObj = {
- args: { url: "https://github.com/SoftVentures/Recrest", label: "Open on GitHub" },
-};
-
-export const IconOnly: StoryObj = {
- args: { url: "https://github.com/SoftVentures/Recrest", iconOnly: true, title: "Open on GitHub" },
-};
-
-export const Small: StoryObj = {
- args: { url: "https://github.com/SoftVentures/Recrest", label: "Source", size: "sm" },
-};
diff --git a/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.test.tsx b/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.test.tsx
deleted file mode 100644
index 7283368..0000000
--- a/app/src/components/molecules/ExternalLinkButton/ExternalLinkButton.test.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { ReactElement } from "react";
-
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { ExternalLinkButton } from "@/components/molecules/ExternalLinkButton";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-
-function renderWithTooltip(ui: ReactElement) {
- return render({ui} );
-}
-
-describe("ExternalLinkButton", () => {
- it("rendert Label und nutzt es als aria-label-Fallback", () => {
- renderWithTooltip( );
- const btn = screen.getByTestId("external-link-button");
- expect(btn).toHaveAttribute("aria-label", "Docs");
- });
-
- it("ruft window.open außerhalb von Tauri auf", async () => {
- const user = userEvent.setup();
- const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
- renderWithTooltip( );
- await user.click(screen.getByTestId("external-link-button"));
- expect(openSpy).toHaveBeenCalledWith("https://example.com", "_blank", "noopener,noreferrer");
- openSpy.mockRestore();
- });
-
- it("rendert im iconOnly-Modus kein Label", () => {
- renderWithTooltip( );
- expect(screen.queryByText("Docs")).toBeNull();
- });
-});
diff --git a/app/src/components/molecules/ExternalLinkButton/index.tsx b/app/src/components/molecules/ExternalLinkButton/index.tsx
deleted file mode 100644
index bc2a0b4..0000000
--- a/app/src/components/molecules/ExternalLinkButton/index.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { ReactNode } from "react";
-
-import { Icon } from "@/components/atoms/Icon";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip";
-import { openExternal } from "@/lib/tauri";
-
-interface ExternalLinkButtonProps {
- url: string;
- label?: ReactNode;
- /** Visual size — matches the `r-btn` / `r-btn sm` scale. */
- size?: "sm" | "md";
- /** When true, renders only the external-link icon (accessible label comes
- * from `title`). Used in row-level action strips. */
- iconOnly?: boolean;
- title?: string;
- className?: string;
-}
-
-/**
- * Small anchor-style button that opens `url` via the Tauri opener plugin
- * (with a `window.open` fallback outside Tauri). Replaces the ~26 inline
- * ` void openExternal(url)}>` patterns scattered
- * across detail panes, about tabs, provider rows, and PR link buttons.
- */
-export function ExternalLinkButton({
- url,
- label,
- size = "md",
- iconOnly,
- title,
- className,
-}: ExternalLinkButtonProps) {
- const classes = ["r-btn", size === "sm" ? "sm" : "", className ?? ""].filter(Boolean).join(" ");
- const tooltipText = title ?? (typeof label === "string" ? label : url);
- return (
-
-
- void openExternal(url)}
- >
-
- {!iconOnly && label}
-
-
- {tooltipText}
-
- );
-}
diff --git a/app/src/components/molecules/IconButton/IconButton.stories.tsx b/app/src/components/molecules/IconButton/IconButton.stories.tsx
deleted file mode 100644
index 95a7ea6..0000000
--- a/app/src/components/molecules/IconButton/IconButton.stories.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Icon } from "@/components/atoms/Icon";
-import { IconButton } from "@/components/molecules/IconButton";
-
-const meta: Meta = {
- title: "Molecules/IconButton",
- component: IconButton,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: {
- tooltip: "Fetch",
- children: ,
- },
-};
diff --git a/app/src/components/molecules/IconButton/IconButton.test.tsx b/app/src/components/molecules/IconButton/IconButton.test.tsx
deleted file mode 100644
index d9b3bbd..0000000
--- a/app/src/components/molecules/IconButton/IconButton.test.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { ReactNode } from "react";
-
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { IconButton, IconLink } from "@/components/molecules/IconButton";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-
-function wrap(node: ReactNode) {
- return {node} ;
-}
-
-describe("IconButton", () => {
- it("nutzt den Tooltip-Text als aria-label", () => {
- render(wrap(x ));
- expect(screen.getByRole("button", { name: "Schließen" })).toBeInTheDocument();
- });
-
- it("übernimmt ein explizites aria-label", () => {
- render(
- wrap(
-
- x
- ,
- ),
- );
- expect(screen.getByRole("button", { name: "Explizit" })).toBeInTheDocument();
- });
-
- it("ruft onClick bei Klick auf", async () => {
- const user = userEvent.setup();
- const handler = vi.fn();
- render(
- wrap(
-
- x
- ,
- ),
- );
- await user.click(screen.getByRole("button"));
- expect(handler).toHaveBeenCalledTimes(1);
- });
-
- it("rendert IconLink mit href", () => {
- render(
- wrap(
-
- x
- ,
- ),
- );
- const link = screen.getByRole("link", { name: "Extern" });
- expect(link).toHaveAttribute("href", "https://example.com");
- });
-});
diff --git a/app/src/components/molecules/IconButton/index.tsx b/app/src/components/molecules/IconButton/index.tsx
deleted file mode 100644
index c8f6b0d..0000000
--- a/app/src/components/molecules/IconButton/index.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { type ComponentPropsWithoutRef, type ReactNode, forwardRef } from "react";
-
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip";
-
-type ButtonProps = ComponentPropsWithoutRef<"button">;
-
-interface IconButtonProps extends ButtonProps {
- tooltip: ReactNode;
- tooltipSide?: "top" | "bottom" | "left" | "right";
-}
-
-/** Tooltip text is reused as the accessible name when the caller hasn't
- * provided an explicit `aria-label`. Only strings can serve as names;
- * richer ReactNode tooltips still need an explicit `aria-label`. */
-function resolveAriaLabel(explicit: string | undefined, tooltip: ReactNode): string | undefined {
- if (explicit) return explicit;
- return typeof tooltip === "string" ? tooltip : undefined;
-}
-
-export const IconButton = forwardRef(function IconButton(
- {
- tooltip,
- tooltipSide = "bottom",
- className,
- children,
- type = "button",
- "aria-label": ariaLabel,
- ...rest
- },
- ref,
-) {
- const resolvedLabel = resolveAriaLabel(ariaLabel, tooltip);
- return (
-
-
-
- {children}
-
-
- {tooltip}
-
- );
-});
-
-type AnchorProps = ComponentPropsWithoutRef<"a">;
-
-interface IconLinkProps extends AnchorProps {
- tooltip: ReactNode;
- tooltipSide?: "top" | "bottom" | "left" | "right";
-}
-
-export const IconLink = forwardRef(function IconLink(
- { tooltip, tooltipSide = "bottom", className, children, "aria-label": ariaLabel, ...rest },
- ref,
-) {
- const resolvedLabel = resolveAriaLabel(ariaLabel, tooltip);
- return (
-
-
-
- {children}
-
-
- {tooltip}
-
- );
-});
diff --git a/app/src/components/molecules/InfoCard/InfoCard.stories.tsx b/app/src/components/molecules/InfoCard/InfoCard.stories.tsx
deleted file mode 100644
index 2c105d8..0000000
--- a/app/src/components/molecules/InfoCard/InfoCard.stories.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { InfoCard } from "@/components/molecules/InfoCard";
-
-const meta: Meta = {
- title: "Molecules/InfoCard",
- component: InfoCard,
-};
-
-export default meta;
-
-export const WithTitle: StoryObj = {
- args: { title: "Remote", children: "origin/main" },
-};
-
-export const TitleOnly: StoryObj = {
- args: { title: "Last commit", children: "ci(deps): bump deps (2 days ago)" },
-};
diff --git a/app/src/components/molecules/InfoCard/InfoCard.test.tsx b/app/src/components/molecules/InfoCard/InfoCard.test.tsx
deleted file mode 100644
index a2fbcfe..0000000
--- a/app/src/components/molecules/InfoCard/InfoCard.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { InfoCard } from "@/components/molecules/InfoCard";
-
-describe("InfoCard", () => {
- it("rendert Children", () => {
- render(
-
- Body
- ,
- );
- expect(screen.getByText("Body")).toBeInTheDocument();
- });
-
- it("rendert Titel und Action in der Kopfzeile", () => {
- render(
- Mehr }>
- B
- ,
- );
- expect(screen.getByRole("heading", { name: "Titel" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Mehr" })).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/molecules/InfoCard/index.tsx b/app/src/components/molecules/InfoCard/index.tsx
deleted file mode 100644
index aec56b1..0000000
--- a/app/src/components/molecules/InfoCard/index.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { ReactNode } from "react";
-
-interface InfoCardProps {
- title?: ReactNode;
- action?: ReactNode;
- children: ReactNode;
- className?: string;
-}
-
-/** Generic titled card used in RepoDetailPage. Title + action on the
- * header row, scrollable body below. */
-export function InfoCard({ title, action, children, className }: InfoCardProps) {
- return (
-
- {(title || action) && (
-
- {title && {title} }
- {action}
-
- )}
- {children}
-
- );
-}
diff --git a/app/src/components/molecules/InfoHint/InfoHint.stories.tsx b/app/src/components/molecules/InfoHint/InfoHint.stories.tsx
deleted file mode 100644
index a0b9ee1..0000000
--- a/app/src/components/molecules/InfoHint/InfoHint.stories.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { InfoHint } from "@/components/molecules/InfoHint";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-
-const meta: Meta = {
- title: "Molecules/InfoHint",
- component: InfoHint,
- decorators: [
- (Story) => (
-
-
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: { children: "Extra context shown on hover." },
-};
-export const LongText: StoryObj = {
- args: {
- children:
- "Personal access tokens with repo scope are required so Recrest can list private pull requests on your behalf. Tokens are stored in the OS keychain.",
- },
-};
-export const CustomLabel: StoryObj = {
- args: { children: "Only stored locally.", label: "Privacy note" },
-};
-export const RightSide: StoryObj = {
- args: { children: "Opens to the right.", side: "right" },
-};
diff --git a/app/src/components/molecules/InfoHint/InfoHint.test.tsx b/app/src/components/molecules/InfoHint/InfoHint.test.tsx
deleted file mode 100644
index 1d7a52e..0000000
--- a/app/src/components/molecules/InfoHint/InfoHint.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { InfoHint } from "@/components/molecules/InfoHint";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-
-describe("InfoHint", () => {
- it("rendert den Trigger mit Default-Label", () => {
- render(
-
- Mehr Infos
- ,
- );
- expect(screen.getByRole("button", { name: "More info" })).toBeInTheDocument();
- });
-
- it("übernimmt ein eigenes Label", () => {
- render(
-
- Text
- ,
- );
- expect(screen.getByRole("button", { name: "Hilfe" })).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/molecules/InfoHint/index.tsx b/app/src/components/molecules/InfoHint/index.tsx
deleted file mode 100644
index 053402a..0000000
--- a/app/src/components/molecules/InfoHint/index.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { type ReactNode } from "react";
-
-import { Info } from "lucide-react";
-
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip";
-import { cn } from "@/lib/utils";
-
-interface InfoHintProps {
- /** Tooltip body. Plain string or rich node — keep it short. */
- children: ReactNode;
- /** Accessible label for the trigger (default: "More info"). */
- label?: string;
- className?: string;
- side?: "top" | "right" | "bottom" | "left";
-}
-
-/**
- * Small `ⓘ` icon that reveals an explanatory tooltip on hover/focus.
- * Use sparingly for fields whose intent isn't obvious from the label alone.
- */
-export function InfoHint({
- children,
- label = "More info",
- className,
- side = "top",
-}: InfoHintProps) {
- return (
-
-
- e.preventDefault()}
- >
-
-
-
-
- {children}
-
-
- );
-}
diff --git a/app/src/components/molecules/KpiCard/KpiCard.stories.tsx b/app/src/components/molecules/KpiCard/KpiCard.stories.tsx
deleted file mode 100644
index a3b567f..0000000
--- a/app/src/components/molecules/KpiCard/KpiCard.stories.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { KpiCard } from "@/components/molecules/KpiCard";
-
-const meta: Meta = {
- title: "Molecules/KpiCard",
- component: KpiCard,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: { label: "Open PRs", value: 4, hint: "2 ready to merge" },
-};
-export const WithoutHint: StoryObj = {
- args: { label: "Repositories", value: 27 },
-};
-export const LargeValue: StoryObj = {
- args: { label: "Commits (30d)", value: "1,248", hint: "+12% vs last month" },
-};
-export const ZeroState: StoryObj = {
- args: { label: "Failing checks", value: 0, hint: "All green" },
-};
-export const RichValue: StoryObj = {
- args: {
- label: "Ahead / Behind",
- value: (
-
- +3
- {" / "}
- -1
-
- ),
- hint: "origin/main",
- },
-};
diff --git a/app/src/components/molecules/KpiCard/KpiCard.test.tsx b/app/src/components/molecules/KpiCard/KpiCard.test.tsx
deleted file mode 100644
index 8458d55..0000000
--- a/app/src/components/molecules/KpiCard/KpiCard.test.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { KpiCard } from "@/components/molecules/KpiCard";
-
-describe("KpiCard", () => {
- it("rendert Label und Value", () => {
- render( );
- expect(screen.getByText("Repos")).toBeInTheDocument();
- expect(screen.getByText("42")).toBeInTheDocument();
- });
-
- it("zeigt den optionalen Hint an", () => {
- render( );
- expect(screen.getByText("+2 diese Woche")).toBeInTheDocument();
- });
-
- it("rendert keinen Hint, wenn keiner übergeben wurde", () => {
- render( );
- expect(screen.queryByText(/diese Woche/)).toBeNull();
- });
-});
diff --git a/app/src/components/molecules/KpiCard/index.tsx b/app/src/components/molecules/KpiCard/index.tsx
deleted file mode 100644
index 728e0cc..0000000
--- a/app/src/components/molecules/KpiCard/index.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { ReactNode } from "react";
-
-interface KpiCardProps {
- label: string;
- value: ReactNode;
- hint?: ReactNode;
-}
-
-/** RepoDetailPage-style KPI card — larger than `KpiTile`, sits in a 4-col
- * grid at the top of the detail view. */
-export function KpiCard({ label, value, hint }: KpiCardProps) {
- return (
-
-
{label}
-
{value}
- {hint &&
{hint}
}
-
- );
-}
diff --git a/app/src/components/molecules/KpiTile/KpiTile.stories.tsx b/app/src/components/molecules/KpiTile/KpiTile.stories.tsx
deleted file mode 100644
index c01f025..0000000
--- a/app/src/components/molecules/KpiTile/KpiTile.stories.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { KpiTile } from "@/components/molecules/KpiTile";
-
-const meta: Meta = {
- title: "Molecules/KpiTile",
- component: KpiTile,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: { label: "Open merge requests", value: 12, sub: "across 3 repos" },
-};
-
-export const Clickable: StoryObj = {
- args: {
- label: "Dirty repos",
- value: 5,
- sub: "Needs your attention",
- onClick: () => {},
- },
-};
diff --git a/app/src/components/molecules/KpiTile/KpiTile.test.tsx b/app/src/components/molecules/KpiTile/KpiTile.test.tsx
deleted file mode 100644
index 4e22b01..0000000
--- a/app/src/components/molecules/KpiTile/KpiTile.test.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { KpiTile } from "@/components/molecules/KpiTile";
-
-describe("KpiTile", () => {
- it("rendert als div ohne onClick", () => {
- render( );
- expect(screen.queryByRole("button")).toBeNull();
- expect(screen.getByText("Repos")).toBeInTheDocument();
- expect(screen.getByText("3")).toBeInTheDocument();
- });
-
- it("rendert als Button und ruft onClick auf", async () => {
- const user = userEvent.setup();
- const onClick = vi.fn();
- render( );
- const btn = screen.getByRole("button");
- expect(btn).toHaveAttribute("data-clickable", "true");
- await user.click(btn);
- expect(onClick).toHaveBeenCalledTimes(1);
- });
-
- it("zeigt den optionalen Sub-Text", () => {
- render( );
- expect(screen.getByText("letzte 7 Tage")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/molecules/KpiTile/index.tsx b/app/src/components/molecules/KpiTile/index.tsx
deleted file mode 100644
index daefb85..0000000
--- a/app/src/components/molecules/KpiTile/index.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import type { ReactNode } from "react";
-
-interface KpiTileProps {
- label: string;
- value: ReactNode;
- sub?: ReactNode;
- onClick?: () => void;
-}
-
-/** Dashboard KPI tile — small card with a number, label, and optional
- * sub-text. Clickable when `onClick` is provided. */
-export function KpiTile({ label, value, sub, onClick }: KpiTileProps) {
- const Tag = (onClick ? "button" : "div") as "button" | "div";
- return (
-
- {label}
- {value}
- {sub && {sub}
}
-
- );
-}
diff --git a/app/src/components/molecules/MrChip/MrChip.stories.tsx b/app/src/components/molecules/MrChip/MrChip.stories.tsx
deleted file mode 100644
index 16296f4..0000000
--- a/app/src/components/molecules/MrChip/MrChip.stories.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { MrChip } from "@/components/molecules/MrChip";
-
-const meta: Meta = {
- title: "Molecules/MrChip",
- component: MrChip,
-};
-
-export default meta;
-
-export const Inactive: StoryObj = {
- args: { active: false, children: "Open", count: 12 },
-};
-
-export const Active: StoryObj = {
- args: { active: true, children: "Draft", count: 3 },
-};
diff --git a/app/src/components/molecules/MrChip/MrChip.test.tsx b/app/src/components/molecules/MrChip/MrChip.test.tsx
deleted file mode 100644
index 9c0eb1d..0000000
--- a/app/src/components/molecules/MrChip/MrChip.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { MrChip } from "@/components/molecules/MrChip";
-
-describe("MrChip", () => {
- it("rendert Label", () => {
- render(Open );
- expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
- });
-
- it("zeigt den Counter, wenn > 0", () => {
- render(Open );
- expect(screen.getByText("4")).toBeInTheDocument();
- });
-
- it("versteckt den Counter bei 0", () => {
- render(Open );
- expect(screen.queryByText("0")).toBeNull();
- });
-
- it("setzt active-Klasse und ruft onClick", async () => {
- const user = userEvent.setup();
- const onClick = vi.fn();
- render(
-
- x
- ,
- );
- const btn = screen.getByRole("button");
- expect(btn.className).toContain("active");
- await user.click(btn);
- expect(onClick).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/app/src/components/molecules/MrChip/index.tsx b/app/src/components/molecules/MrChip/index.tsx
deleted file mode 100644
index e0eedeb..0000000
--- a/app/src/components/molecules/MrChip/index.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import type { ReactNode } from "react";
-
-interface MrChipProps {
- active?: boolean;
- onClick?: () => void;
- children: ReactNode;
- /** Optional trailing counter (hidden when null/0). */
- count?: number | null;
-}
-
-/** Filter chip used in the Merge Requests page header (Open / Draft /
- * Merged / Closed). Visually matches the branches-page chip but keyed off
- * a different CSS class so they can diverge later. */
-export function MrChip({ active, onClick, children, count }: MrChipProps) {
- return (
-
- {children}
- {count != null && count > 0 && {count} }
-
- );
-}
diff --git a/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.stories.tsx b/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.stories.tsx
deleted file mode 100644
index 2d8e9ca..0000000
--- a/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.stories.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { Provider } from "react-redux";
-
-import { OpenInIdeButton } from "@/components/molecules/OpenInIdeButton";
-import { store } from "@/store";
-import { loadDetectedIdes } from "@/store/slices/settingsSlice";
-
-// The stories need a Redux store because `useActiveIde()` reads
-// `settings.detectedIdes` + `settings.defaultIde`. For demo purposes we
-// inject a few detected IDEs via a fake `fulfilled` action.
-store.dispatch({
- type: loadDetectedIdes.fulfilled.type,
- payload: ["vscode", "cursor", "webstorm"],
-});
-
-const meta: Meta = {
- title: "Molecules/OpenInIdeButton",
- component: OpenInIdeButton,
- args: { repoId: "demo-repo" },
- decorators: [
- (Story) => (
-
-
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-export const Primary: StoryObj = {
- args: { variant: "primary" },
-};
-
-export const Default: StoryObj = {
- args: { variant: "default" },
-};
-
-export const IconOnly: StoryObj = {
- args: { variant: "icon" },
-};
diff --git a/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.test.tsx b/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.test.tsx
deleted file mode 100644
index 5b9899c..0000000
--- a/app/src/components/molecules/OpenInIdeButton/OpenInIdeButton.test.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { Provider } from "react-redux";
-import { describe, expect, it } from "vitest";
-
-import { OpenInIdeButton } from "@/components/molecules/OpenInIdeButton";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-import { store } from "@/store";
-import { loadDetectedIdes } from "@/store/slices/settingsSlice";
-
-/** Seeds the detected-IDE list without calling into Rust. */
-function seedDetected(ids: string[]) {
- store.dispatch({
- type: loadDetectedIdes.fulfilled.type,
- payload: ids,
- });
-}
-
-describe("OpenInIdeButton", () => {
- it("renders the generic label and stays disabled when no IDE is detected", () => {
- seedDetected([]);
- render(
-
-
-
-
- ,
- );
- const btn = screen.getByTestId("open-in-ide-button");
- expect(btn).toBeDisabled();
- expect(btn).toHaveTextContent(/open in ide/i);
- });
-
- it("renders the IDE name in the label when at least one is detected", () => {
- seedDetected(["cursor"]);
- render(
-
-
-
-
- ,
- );
- const btn = screen.getByTestId("open-in-ide-button");
- expect(btn).toBeEnabled();
- expect(btn).toHaveTextContent(/open in cursor/i);
- });
-
- it("renders an icon-only variant without a visible text label", () => {
- seedDetected(["webstorm"]);
- render(
-
-
-
-
- ,
- );
- const btn = screen.getByRole("button");
- // No "Open in …" text leaks into icon-only variants — only the tooltip on
- // title/aria-label carries the copy.
- expect(btn.textContent ?? "").not.toMatch(/open in/i);
- });
-});
diff --git a/app/src/components/molecules/OpenInIdeButton/index.tsx b/app/src/components/molecules/OpenInIdeButton/index.tsx
deleted file mode 100644
index cba81e4..0000000
--- a/app/src/components/molecules/OpenInIdeButton/index.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { type ReactNode, useState } from "react";
-
-import { TauriCommand } from "@recrest/shared";
-
-import { Icon } from "@/components/atoms/Icon";
-import { IdeIcon } from "@/components/atoms/IdeIcon";
-import { IconButton } from "@/components/molecules/IconButton";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip";
-import { useActiveIde } from "@/hooks/useActiveIde";
-import { invoke } from "@/lib/tauri";
-import { toast } from "@/lib/toast";
-
-export type OpenInIdeVariant = "primary" | "default" | "icon";
-
-export interface OpenInIdeButtonProps {
- repoId: string;
- /** `"primary"` = large primary button with IDE icon + label (default);
- * `"default"` = secondary button with icon + label;
- * `"icon"` = icon only, for dense row layouts. */
- variant?: OpenInIdeVariant;
- /** Extra className forwarded onto the rendered element. */
- className?: string;
- /** Extra node before the label (e.g. a shortcut badge). Only used in the
- * text variants. */
- before?: ReactNode;
- /** Fires after a successful launch. Useful when callers need to reset
- * surrounding state (selection, row-close etc.). */
- onOpened?: () => void;
-}
-
-/**
- * Central "Open in …" button — renders the official logo of the currently
- * selected IDE, a dynamic label with its name, and handles toast + busy
- * state internally. When no IDE is detected on the system, the button is
- * disabled and carries an explanatory tooltip.
- *
- * All call sites (DetailPane, RepoRow, RepoDetail, full-screen header) share
- * this component so label, icon and error handling stay consistent and
- * settings changes propagate everywhere at once.
- */
-export function OpenInIdeButton({
- repoId,
- variant = "primary",
- className,
- before,
- onOpened,
-}: OpenInIdeButtonProps) {
- const activeIde = useActiveIde();
- const [busy, setBusy] = useState(false);
- const disabled = busy || activeIde === null;
- const name = activeIde?.name;
- const title = activeIde ? `Open in ${name}` : "Open in IDE";
- const disabledTitle =
- activeIde === null
- ? "No IDE detected — install VS Code, Cursor, or a JetBrains IDE"
- : undefined;
-
- const handleClick = async () => {
- if (disabled) return;
- setBusy(true);
- const loadingText = name ? `Opening ${name}…` : "Opening IDE…";
- const successText = name ? `Opened in ${name}` : "Opened in IDE";
- const toastId = toast.loading(loadingText);
- try {
- await invoke(TauriCommand.OPEN_IN_IDE, { repoId });
- toast.success(successText, { id: toastId });
- onOpened?.();
- } catch (err) {
- const msg = (err as { message?: string })?.message ?? "Open in IDE failed";
- toast.error(msg, { id: toastId });
- } finally {
- setBusy(false);
- }
- };
-
- const iconNode = busy ? (
-
- ) : activeIde ? (
-
- ) : (
-
- );
-
- if (variant === "icon") {
- return (
- void handleClick()}
- disabled={disabled}
- className={className}
- >
- {iconNode}
-
- );
- }
-
- const labelText = busy
- ? name
- ? `Opening ${name}…`
- : "Opening IDE…"
- : name
- ? `Open in ${name}`
- : "Open in IDE";
-
- const buttonClass = ["r-btn", variant === "primary" ? "primary" : "", className ?? ""]
- .filter(Boolean)
- .join(" ");
-
- const button = (
- void handleClick()}
- disabled={disabled}
- aria-label={disabledTitle ?? labelText}
- data-testid="open-in-ide-button"
- >
- {before}
- {iconNode}
- {labelText}
-
- );
-
- if (!disabledTitle) return button;
- return (
-
- {button}
- {disabledTitle}
-
- );
-}
diff --git a/app/src/components/molecules/RepoAvatar/RepoAvatar.stories.tsx b/app/src/components/molecules/RepoAvatar/RepoAvatar.stories.tsx
deleted file mode 100644
index d656023..0000000
--- a/app/src/components/molecules/RepoAvatar/RepoAvatar.stories.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { RepoAvatar } from "@/components/molecules/RepoAvatar";
-import { sampleRepo } from "@/test-utils/fixtures";
-
-const meta: Meta = {
- title: "Molecules/RepoAvatar",
- component: RepoAvatar,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: { repo: sampleRepo, size: 32, radius: 6 },
-};
diff --git a/app/src/components/molecules/RepoAvatar/RepoAvatar.test.tsx b/app/src/components/molecules/RepoAvatar/RepoAvatar.test.tsx
deleted file mode 100644
index 196a2f2..0000000
--- a/app/src/components/molecules/RepoAvatar/RepoAvatar.test.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { ReactElement } from "react";
-
-import { configureStore } from "@reduxjs/toolkit";
-import { render, screen } from "@testing-library/react";
-import { Provider } from "react-redux";
-import { describe, expect, it } from "vitest";
-
-import { RepoAvatar } from "@/components/molecules/RepoAvatar";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-import { settingsReducer } from "@/store/slices/settingsSlice";
-
-function renderWithStore(ui: ReactElement) {
- const store = configureStore({ reducer: { settings: settingsReducer } });
- return render(
-
- {ui}
- ,
- );
-}
-
-describe("RepoAvatar", () => {
- it("rendert einen Buchstaben-Kachel für einen Repo ohne Logo", () => {
- renderWithStore( );
- expect(screen.getByText("R")).toBeInTheDocument();
- });
-
- it("nutzt den Namen als aria-label-Attribut", () => {
- renderWithStore( );
- expect(screen.getByTestId("repo-avatar")).toHaveAttribute("aria-label", "MyRepo");
- });
-
- it("respektiert die size-Prop", () => {
- const { container } = renderWithStore(
- ,
- );
- const el = container.querySelector(".repo-avatar") as HTMLElement;
- expect(el.style.width).toBe("64px");
- expect(el.style.height).toBe("64px");
- });
-});
diff --git a/app/src/components/molecules/RepoAvatar/index.tsx b/app/src/components/molecules/RepoAvatar/index.tsx
deleted file mode 100644
index b04ed74..0000000
--- a/app/src/components/molecules/RepoAvatar/index.tsx
+++ /dev/null
@@ -1,245 +0,0 @@
-import { useEffect, useState } from "react";
-
-import { WindowEvent, storageKeyForLogo } from "@recrest/shared";
-
-import { BrandIcon, type BrandSlug } from "@/components/atoms/BrandIcon";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip";
-import { useRepoFavicon } from "@/hooks/useRepoFavicon";
-import { useRepoLogo } from "@/hooks/useRepoLogo";
-
-/** Curated, hand-picked two-stop gradients for repo avatars. Each pair is
- * tuned for contrast with white text and reasonable harmony. Order matters:
- * the first assignment comes first, so the prettiest ones go early. */
-const AVATAR_GRADIENTS: Array<[string, string]> = [
- ["#ff7a59", "#d6336c"],
- ["#4f8cff", "#7b2ff7"],
- ["#10b981", "#0ea5a3"],
- ["#f59e0b", "#ef4444"],
- ["#ec4899", "#8b5cf6"],
- ["#06b6d4", "#3b82f6"],
- ["#22c55e", "#14b8a6"],
- ["#f97316", "#eab308"],
- ["#a855f7", "#ec4899"],
- ["#0ea5e9", "#14b8a6"],
- ["#e11d48", "#f97316"],
- ["#6366f1", "#06b6d4"],
- ["#84cc16", "#10b981"],
- ["#d946ef", "#6366f1"],
- ["#f43f5e", "#a855f7"],
- ["#059669", "#0284c7"],
- ["#fb7185", "#fbbf24"],
- ["#7c3aed", "#2563eb"],
- ["#16a34a", "#65a30d"],
- ["#be185d", "#4c1d95"],
- ["#0891b2", "#4338ca"],
- ["#ea580c", "#b91c1c"],
- ["#15803d", "#0d9488"],
- ["#9333ea", "#db2777"],
-];
-
-/** Assigns each repo id the next unused gradient slot, so no two repos share
- * a gradient as long as we stay under AVATAR_GRADIENTS.length. Persists for
- * the lifetime of the window; on reload the assignment order can differ but
- * within one session every repo keeps its slot. */
-const GRADIENT_ASSIGNMENTS = new Map();
-let nextGradientSlot = 0;
-
-function gradientForRepo(id: string): [string, string] {
- let slot = GRADIENT_ASSIGNMENTS.get(id);
- if (slot == null) {
- slot = nextGradientSlot++;
- GRADIENT_ASSIGNMENTS.set(id, slot);
- }
- const idx = slot % AVATAR_GRADIENTS.length;
- return AVATAR_GRADIENTS[idx] ?? AVATAR_GRADIENTS[0]!;
-}
-
-interface RepoLike {
- id: string;
- name: string;
- /** Auto-detected logo paths from Rust. Optional so the avatar still works
- * for repo-like objects that don't carry the full Repository DTO. */
- logoPath?: string | null;
- logoDarkPath?: string | null;
- /** Remote URL — used for the L.1 favicon fallback when no in-repo logo
- * was detected and the host doesn't match a Simple Icon brand. */
- remoteUrl?: string | null;
-}
-
-interface RepoAvatarProps {
- repo: RepoLike;
- size?: number;
- radius?: number;
-}
-
-function readStored(id: string): string | null {
- try {
- return localStorage.getItem(storageKeyForLogo(id));
- } catch {
- return null;
- }
-}
-
-// eslint-disable-next-line react-refresh/only-export-components
-export function setRepoLogo(repoId: string, dataUrl: string | null): void {
- try {
- if (dataUrl) localStorage.setItem(storageKeyForLogo(repoId), dataUrl);
- else localStorage.removeItem(storageKeyForLogo(repoId));
- } catch {
- // Storage may be disabled — ignore; the in-memory value still works for
- // the current session once the event listeners fire.
- }
- window.dispatchEvent(
- new CustomEvent(WindowEvent.LOGO_UPDATED, { detail: { repoId, value: dataUrl } }),
- );
-}
-
-export function RepoAvatar({ repo, size = 24, radius = 6 }: RepoAvatarProps) {
- const [custom, setCustom] = useState(() => readStored(repo.id));
- const autoLogo = useRepoLogo({
- logoPath: repo.logoPath ?? null,
- logoDarkPath: repo.logoDarkPath ?? null,
- });
- // L.1: only fall back to a fetched favicon when no local logo and no
- // brand match are available. The hook itself respects the
- // `privacy.fetchFavicons` setting and short-circuits otherwise.
- const needsFavicon = !custom && !autoLogo && !detectSpecialIcon(repo.name);
- const favicon = useRepoFavicon(needsFavicon ? (repo.remoteUrl ?? null) : null);
-
- useEffect(() => {
- setCustom(readStored(repo.id));
- const onStorage = (e: StorageEvent) => {
- if (e.key === storageKeyForLogo(repo.id)) setCustom(e.newValue);
- };
- const onCustom = (e: Event) => {
- const detail = (e as CustomEvent<{ repoId: string; value: string | null }>).detail;
- if (detail.repoId === repo.id) setCustom(detail.value);
- };
- window.addEventListener("storage", onStorage);
- window.addEventListener(WindowEvent.LOGO_UPDATED, onCustom);
- return () => {
- window.removeEventListener("storage", onStorage);
- window.removeEventListener(WindowEvent.LOGO_UPDATED, onCustom);
- };
- }, [repo.id]);
-
- // Priority ladder: user-uploaded override > repo-detected logo >
- // host favicon (privacy-gated) > brand glyph > letter tile.
- const src = custom ?? autoLogo ?? favicon;
- if (src) {
- return (
-
-
-
-
-
-
- {repo.name}
-
- );
- }
-
- const [c1, c2] = gradientForRepo(repo.id || repo.name);
- const gradient = `linear-gradient(135deg, ${c1} 0%, ${c2} 100%)`;
-
- const specialIcon = detectSpecialIcon(repo.name);
- if (specialIcon) {
- return (
-
-
-
-
-
-
- {repo.name}
-
- );
- }
-
- const cleaned = repo.name.replace(/^[\W_]+/, "") || repo.name;
- const letter = cleaned.charAt(0).toUpperCase();
-
- return (
-
-
-
- {letter}
-
-
- {repo.name}
-
- );
-}
-
-function detectSpecialIcon(name: string): BrandSlug | null {
- const normalized = name.toLowerCase().replace(/[.\s_-]/g, "");
- if (normalized === "github" || normalized === "githubprivate") return "github";
- if (normalized === "gitlab") return "gitlab";
- if (normalized === "bitbucket") return "bitbucket";
- return null;
-}
diff --git a/app/src/components/molecules/SettingsField/SettingsField.stories.tsx b/app/src/components/molecules/SettingsField/SettingsField.stories.tsx
deleted file mode 100644
index 103ef14..0000000
--- a/app/src/components/molecules/SettingsField/SettingsField.stories.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Switch } from "@/components/atoms/Switch";
-import { SettingsField } from "@/components/molecules/SettingsField";
-
-const meta: Meta = {
- title: "Molecules/SettingsField",
- component: SettingsField,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: {
- label: "Start with system",
- description: "Launch Recrest when you log in.",
- children: ,
- },
-};
-
-export const WithHint: StoryObj = {
- args: {
- label: "Close to tray",
- description: "Keep Recrest running in the tray when you close the window.",
- hint: "The system tray is the small icon area next to your clock.",
- children: ,
- },
-};
diff --git a/app/src/components/molecules/SettingsField/SettingsField.test.tsx b/app/src/components/molecules/SettingsField/SettingsField.test.tsx
deleted file mode 100644
index 6d3f833..0000000
--- a/app/src/components/molecules/SettingsField/SettingsField.test.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { SettingsField } from "@/components/molecules/SettingsField";
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-
-describe("SettingsField", () => {
- it("rendert Label und Children", () => {
- render(
-
-
-
-
- ,
- );
- expect(screen.getByText("Theme")).toBeInTheDocument();
- expect(screen.getByTestId("ctrl")).toBeInTheDocument();
- });
-
- it("zeigt die Description an", () => {
- render(
-
-
-
-
- ,
- );
- expect(screen.getByText("App-Sprache")).toBeInTheDocument();
- });
-
- it("rendert den Hint-Button, wenn ein hint gegeben ist", () => {
- render(
-
-
-
-
- ,
- );
- expect(screen.getByRole("button", { name: "More info" })).toBeInTheDocument();
- });
-
- it("verknüpft das Label via htmlFor", () => {
- render(
-
-
-
-
- ,
- );
- expect(screen.getByText("Pfad").closest("label")).toHaveAttribute("for", "path-input");
- });
-});
diff --git a/app/src/components/molecules/SettingsField/index.tsx b/app/src/components/molecules/SettingsField/index.tsx
deleted file mode 100644
index 4bdccb2..0000000
--- a/app/src/components/molecules/SettingsField/index.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { ReactNode } from "react";
-
-import { InfoHint } from "@/components/molecules/InfoHint";
-import { cn } from "@/lib/utils";
-
-interface SettingsFieldProps {
- label: ReactNode;
- description?: ReactNode;
- /** Short explanatory tooltip shown next to the label. */
- hint?: ReactNode;
- htmlFor?: string;
- children: ReactNode;
- /** Reserved for backwards compatibility — inline is the only layout now. */
- layout?: "inline" | "stacked";
- className?: string;
-}
-
-/** Single row inside a SettingsSection: label + description on the left,
- * control on the right. */
-export function SettingsField({
- label,
- description,
- hint,
- htmlFor,
- children,
- className,
-}: SettingsFieldProps) {
- return (
-
-
-
- {label}
- {hint && (
-
- {hint}
-
- )}
-
- {description &&
{description}
}
-
-
{children}
-
- );
-}
diff --git a/app/src/components/molecules/SettingsSectionHeader/SettingsSectionHeader.stories.tsx b/app/src/components/molecules/SettingsSectionHeader/SettingsSectionHeader.stories.tsx
deleted file mode 100644
index a1493e5..0000000
--- a/app/src/components/molecules/SettingsSectionHeader/SettingsSectionHeader.stories.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { SettingsSectionHeader } from "@/components/molecules/SettingsSectionHeader";
-
-const meta: Meta = {
- title: "Molecules/SettingsSectionHeader",
- component: SettingsSectionHeader,
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- args: { title: "General", description: "Application behaviour, appearance, and notifications." },
-};
-
-export const TitleOnly: StoryObj = {
- args: { title: "About" },
-};
diff --git a/app/src/components/molecules/SettingsSectionHeader/SettingsSectionHeader.test.tsx b/app/src/components/molecules/SettingsSectionHeader/SettingsSectionHeader.test.tsx
deleted file mode 100644
index 2759ca7..0000000
--- a/app/src/components/molecules/SettingsSectionHeader/SettingsSectionHeader.test.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import { SettingsSectionHeader } from "@/components/molecules/SettingsSectionHeader";
-
-describe("SettingsSectionHeader", () => {
- it("rendert Titel als h2", () => {
- render( );
- expect(screen.getByRole("heading", { level: 2, name: "Erscheinungsbild" })).toBeInTheDocument();
- });
-
- it("zeigt die Description an", () => {
- render( );
- expect(screen.getByText("Beschreibungstext")).toBeInTheDocument();
- });
-
- it("rendert ohne Description keinen p-Tag", () => {
- const { container } = render( );
- expect(container.querySelector("p")).toBeNull();
- });
-});
diff --git a/app/src/components/molecules/SettingsSectionHeader/index.tsx b/app/src/components/molecules/SettingsSectionHeader/index.tsx
deleted file mode 100644
index a459d65..0000000
--- a/app/src/components/molecules/SettingsSectionHeader/index.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { ReactNode } from "react";
-
-interface SettingsSectionHeaderProps {
- title: ReactNode;
- description?: ReactNode;
-}
-
-/**
- * Heading + optional intro text at the top of every Settings tab body.
- * Replaces the ~6 inline `` copies
- * scattered across `SettingsPage.tsx` tab renderers.
- */
-export function SettingsSectionHeader({ title, description }: SettingsSectionHeaderProps) {
- return (
-
-
{title}
- {description &&
{description}
}
-
- );
-}
diff --git a/app/src/components/molecules/Sonner/Sonner.stories.tsx b/app/src/components/molecules/Sonner/Sonner.stories.tsx
deleted file mode 100644
index c22470f..0000000
--- a/app/src/components/molecules/Sonner/Sonner.stories.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { Provider } from "react-redux";
-import { toast } from "sonner";
-
-import { Button } from "@/components/atoms/Button";
-import { Toaster } from "@/components/molecules/Sonner";
-import { store } from "@/store";
-
-const meta: Meta = {
- title: "Molecules/Sonner",
- component: Toaster,
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- render: () => (
-
-
-
- toast("Settings saved")}>Neutral
- toast.success("Repository scan finished")}>
- Success
-
- toast.error("Failed to fetch pull requests")}>
- Error
-
-
-
- ),
-};
-
-export const WithDescription: StoryObj = {
- render: () => (
-
-
-
- toast("New pull request", {
- description: "#482 Improve scanner cancellation handling",
- })
- }
- >
- Show toast with description
-
-
- ),
-};
-
-export const WithAction: StoryObj = {
- render: () => (
-
-
-
- toast("Token removed", {
- description: "GitHub integration disconnected.",
- action: { label: "Undo", onClick: () => toast.success("Restored") },
- })
- }
- >
- Show toast with action
-
-
- ),
-};
diff --git a/app/src/components/molecules/Sonner/Sonner.test.tsx b/app/src/components/molecules/Sonner/Sonner.test.tsx
deleted file mode 100644
index 13bd74a..0000000
--- a/app/src/components/molecules/Sonner/Sonner.test.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type { ReactElement } from "react";
-
-import { configureStore } from "@reduxjs/toolkit";
-import { render } from "@testing-library/react";
-import { Provider } from "react-redux";
-import { describe, expect, it } from "vitest";
-
-import { Toaster } from "@/components/molecules/Sonner";
-import { settingsReducer } from "@/store/slices/settingsSlice";
-
-function renderWithStore(ui: ReactElement) {
- const store = configureStore({ reducer: { settings: settingsReducer } });
- return render({ui} );
-}
-
-describe("Sonner Toaster", () => {
- it("rendert ohne Crash im Default-Theme", () => {
- expect(() => renderWithStore( )).not.toThrow();
- });
-
- it("akzeptiert eine Position-Override ohne Crash", () => {
- expect(() => renderWithStore( )).not.toThrow();
- });
-});
diff --git a/app/src/components/molecules/Sonner/index.tsx b/app/src/components/molecules/Sonner/index.tsx
deleted file mode 100644
index 7d8b0a9..0000000
--- a/app/src/components/molecules/Sonner/index.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Toaster as SonnerToaster, type ToasterProps } from "sonner";
-
-import { useAppSelector } from "@/store/hooks";
-
-/**
- * App-wide Toaster. Mount once in AppShell. Individual `toast.success()`
- * / `toast.error()` calls happen in the lib/toast helper.
- */
-export function Toaster(props: ToasterProps) {
- const theme = useAppSelector((s) => s.settings.theme);
- const resolvedTheme: ToasterProps["theme"] =
- theme === "system" ? "system" : theme === "dark" ? "dark" : "light";
-
- return (
-
- );
-}
diff --git a/app/src/components/molecules/cards/KpiCard/KpiCard.stories.tsx b/app/src/components/molecules/cards/KpiCard/KpiCard.stories.tsx
new file mode 100644
index 0000000..47f1d78
--- /dev/null
+++ b/app/src/components/molecules/cards/KpiCard/KpiCard.stories.tsx
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import KpiCard from "@/components/molecules/cards/KpiCard";
+
+const meta: Meta = {
+ title: "Molecules/Cards/KpiCard",
+ component: KpiCard,
+ args: {
+ label: "Open repos",
+ value: 12,
+ sub: "of 14 tracked",
+ size: "lg",
+ },
+ argTypes: {
+ size: { control: "inline-radio", options: ["md", "lg"] },
+ accent: { control: "boolean" },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Large: Story = {};
+export const LargeClickable: Story = {
+ args: { onClick: () => {} },
+};
+export const LargeAccent: Story = { args: { accent: true } };
+export const Medium: Story = { args: { size: "md" } };
+export const MediumNoSub: Story = { args: { size: "md", sub: undefined } };
diff --git a/app/src/components/molecules/cards/KpiCard/KpiCard.test.tsx b/app/src/components/molecules/cards/KpiCard/KpiCard.test.tsx
new file mode 100644
index 0000000..bd47792
--- /dev/null
+++ b/app/src/components/molecules/cards/KpiCard/KpiCard.test.tsx
@@ -0,0 +1,45 @@
+import { Box } from "@mui/material";
+
+import { fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import KpiCard from "@/components/molecules/cards/KpiCard";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("KpiCard", () => {
+ it("renders label, value, sub", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const wrap = getByTestId(COMPONENT_TEST_IDS.molecules.kpiCard.root);
+ expect(wrap.textContent).toContain("Open repos");
+ expect(wrap.textContent).toContain("12");
+ expect(wrap.textContent).toContain("of 14 tracked");
+ });
+
+ it("disables click when onClick is omitted", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const btn = getByTestId(COMPONENT_TEST_IDS.molecules.kpiCard.root).querySelector("button");
+ expect(btn).not.toBeNull();
+ expect(btn?.hasAttribute("disabled")).toBe(true);
+ });
+
+ it("fires onClick when supplied", () => {
+ const spy = vi.fn();
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ const btn = getByTestId(COMPONENT_TEST_IDS.molecules.kpiCard.root).querySelector("button");
+ fireEvent.click(btn!);
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/components/molecules/cards/KpiCard/index.tsx b/app/src/components/molecules/cards/KpiCard/index.tsx
new file mode 100644
index 0000000..341b348
--- /dev/null
+++ b/app/src/components/molecules/cards/KpiCard/index.tsx
@@ -0,0 +1,128 @@
+import { type ReactNode } from "react";
+
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+export type KpiCardSize = "md" | "lg";
+
+export interface KpiCardProps {
+ label: string;
+ value: ReactNode;
+ sub?: ReactNode;
+ /** Highlight the value in `primary.main` (large summary KPIs only). */
+ accent?: boolean;
+ size?: KpiCardSize;
+ onClick?: () => void;
+ "data-testid"?: string;
+}
+
+interface ButtonProps {
+ size: KpiCardSize;
+ clickable: boolean;
+}
+
+const FORWARD = (p: PropertyKey) => p !== "size" && p !== "clickable";
+
+// eslint-disable-next-line no-restricted-syntax -- native element required for accessibility
+const Root = styled("button", { shouldForwardProp: FORWARD })(
+ ({ theme, size, clickable }) => ({
+ textAlign: "left",
+ backgroundColor:
+ size === "lg" ? theme.palette.surface.interface.base : theme.palette.background.paper,
+ border: `1px solid ${theme.palette.divider}`,
+ borderRadius: 8,
+ padding: size === "lg" ? "20px 22px" : 14,
+ cursor: clickable ? "pointer" : "default",
+ display: "flex",
+ flexDirection: "column",
+ gap: size === "lg" ? 0 : 4,
+ fontFamily: "inherit",
+ color: "inherit",
+ width: "100%",
+ transition:
+ "transform 0.16s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.16s ease, border-color 0.12s ease, background 0.12s ease",
+ "&:disabled": { cursor: "default" },
+ ...(clickable && {
+ "&:not(:disabled):hover": {
+ borderColor: theme.palette.border.hover,
+ backgroundColor: theme.palette.surface.interface.active,
+ transform: "translateY(-1px)",
+ boxShadow: `0 4px 14px -8px ${theme.palette.common.black}`,
+ },
+ "&:not(:disabled):active": {
+ transform: "translateY(0)",
+ },
+ }),
+ 'html[data-reduced-motion="true"] &': {
+ transition: "none",
+ "&:not(:disabled):hover": { transform: "none", boxShadow: "none" },
+ },
+ }),
+);
+
+const Label = styled(Box)(({ theme }) => ({
+ fontSize: 11,
+ color: theme.palette.text.information,
+ textTransform: "uppercase",
+ letterSpacing: "0.04em",
+ fontWeight: 600,
+})) as typeof Box;
+
+interface ValueProps {
+ accent: boolean;
+ size: KpiCardSize;
+}
+
+const Value = styled(Box, {
+ shouldForwardProp: (p) => p !== "accent" && p !== "size",
+})(({ theme, accent, size }) => ({
+ fontSize: size === "lg" ? 44 : 26,
+ fontWeight: size === "lg" ? 700 : 600,
+ lineHeight: size === "lg" ? 1 : "30px",
+ letterSpacing: size === "lg" ? "-0.03em" : "-0.01em",
+ color: accent ? theme.palette.primary.main : theme.palette.text.primary,
+ margin: size === "lg" ? "12px 0 6px" : 0,
+ fontVariantNumeric: "tabular-nums",
+}));
+
+const Sub = styled(Box)(({ theme }) => ({
+ fontSize: 11.5,
+ color: theme.palette.text.information,
+})) as typeof Box;
+
+/**
+ * Compact KPI surface: uppercase label, large value, optional sub-line. The
+ * `lg` size matches the Dashboard's clickable summary tiles (44px value,
+ * lifted hover); the `md` size matches RepoDetail's static stat grid (26px
+ * value, no hover). Pass `onClick` to make the whole tile a button with the
+ * lifted-hover treatment.
+ */
+function KpiCard({
+ label,
+ value,
+ sub,
+ accent = false,
+ size = "lg",
+ onClick,
+ "data-testid": testId,
+}: KpiCardProps) {
+ const clickable = Boolean(onClick);
+ return (
+
+ {label}
+
+ {value}
+
+ {sub && {sub} }
+
+ );
+}
+
+export default KpiCard;
diff --git a/app/src/components/molecules/compounds/AlertDialog/AlertDialog.stories.tsx b/app/src/components/molecules/compounds/AlertDialog/AlertDialog.stories.tsx
deleted file mode 100644
index 6f59c82..0000000
--- a/app/src/components/molecules/compounds/AlertDialog/AlertDialog.stories.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Button } from "@/components/atoms/Button";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/molecules/compounds/AlertDialog";
-
-const meta: Meta = {
- title: "Molecules/Compounds/AlertDialog",
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- render: () => (
-
-
- Show alert
-
-
-
- Are you sure?
-
- This will refresh every open pull request. It may take a moment.
-
-
-
- Cancel
- Refresh
-
-
-
- ),
-};
-
-export const Destructive: StoryObj = {
- render: () => (
-
-
- Disconnect provider
-
-
-
- Disconnect GitHub?
-
- The stored personal access token will be removed from the system keychain. You can
- reconnect at any time.
-
-
-
- Keep connected
-
- Disconnect
-
-
-
-
- ),
-};
-
-export const LongDescription: StoryObj = {
- render: () => (
-
-
- Show terms
-
-
-
- Privacy reminder
-
- Recrest operates entirely on your machine. Personal access tokens never leave the
- device, repository contents are never uploaded, and no telemetry is collected unless you
- explicitly opt in. Metadata shown in this dashboard is fetched directly from the
- configured git hosting provider using your own token.
-
-
-
- Close
- Got it
-
-
-
- ),
-};
diff --git a/app/src/components/molecules/compounds/AlertDialog/AlertDialog.test.tsx b/app/src/components/molecules/compounds/AlertDialog/AlertDialog.test.tsx
deleted file mode 100644
index 4b0b229..0000000
--- a/app/src/components/molecules/compounds/AlertDialog/AlertDialog.test.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/components/molecules/compounds/AlertDialog";
-
-describe("AlertDialog", () => {
- it("rendert den Trigger und zeigt Titel nach dem Öffnen", async () => {
- const user = userEvent.setup();
- render(
-
- Open
-
-
- Achtung
- Bist du sicher?
-
-
- Abbrechen
- OK
-
-
- ,
- );
- await user.click(screen.getByRole("button", { name: "Open" }));
- expect(await screen.findByText("Achtung")).toBeInTheDocument();
- expect(screen.getByText("Bist du sicher?")).toBeInTheDocument();
- });
-
- it("rendert kontrolliert als open", () => {
- render(
-
-
- Titel
-
- ,
- );
- expect(screen.getByText("Titel")).toBeInTheDocument();
- });
-
- it("triggert Action-Button onClick", async () => {
- const user = userEvent.setup();
- const onAction = vi.fn();
- render(
-
-
- T
- OK
-
- ,
- );
- await user.click(screen.getByRole("button", { name: "OK" }));
- expect(onAction).toHaveBeenCalled();
- });
-});
diff --git a/app/src/components/molecules/compounds/AlertDialog/index.tsx b/app/src/components/molecules/compounds/AlertDialog/index.tsx
deleted file mode 100644
index dc9a2a9..0000000
--- a/app/src/components/molecules/compounds/AlertDialog/index.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
-
-import { buttonVariants } from "@/components/atoms/Button";
-import { cn } from "@/lib/utils";
-
-// Wrap the bare Radix primitives as function components instead of `const`
-// aliases so `react-refresh/only-export-components` recognises every export as
-// a component and Fast Refresh keeps working in this file.
-export function AlertDialog(props: ComponentProps) {
- return ;
-}
-export function AlertDialogTrigger(props: ComponentProps) {
- return ;
-}
-export function AlertDialogPortal(props: ComponentProps) {
- return ;
-}
-
-export function AlertDialogOverlay({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
-
-export function AlertDialogContent({
- className,
- ...props
-}: ComponentProps) {
- return (
-
-
-
-
- );
-}
-
-export function AlertDialogHeader({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-export function AlertDialogFooter({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-export function AlertDialogTitle({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
-
-export function AlertDialogDescription({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
-
-export function AlertDialogAction({
- className,
- ...props
-}: ComponentProps) {
- return ;
-}
-
-export function AlertDialogCancel({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
diff --git a/app/src/components/molecules/compounds/ConfirmDialog/ConfirmDialog.stories.tsx b/app/src/components/molecules/compounds/ConfirmDialog/ConfirmDialog.stories.tsx
deleted file mode 100644
index 034f59b..0000000
--- a/app/src/components/molecules/compounds/ConfirmDialog/ConfirmDialog.stories.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { useState } from "react";
-
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Button } from "@/components/atoms/Button";
-import { ConfirmDialog } from "@/components/molecules/compounds/ConfirmDialog";
-
-const meta: Meta = {
- title: "Molecules/Compounds/ConfirmDialog",
- component: ConfirmDialog,
-};
-
-export default meta;
-
-function DefaultStory() {
- const [open, setOpen] = useState(false);
- return (
- <>
- setOpen(true)}>Open confirm
- void 0}
- />
- >
- );
-}
-
-function DestructiveStory() {
- const [open, setOpen] = useState(false);
- return (
- <>
- setOpen(true)}>
- Remove repository
-
- void 0}
- />
- >
- );
-}
-
-function WithRememberChoiceStory() {
- const [open, setOpen] = useState(false);
- return (
- <>
- setOpen(true)}>
- Discard working changes
-
- void 0}
- />
- >
- );
-}
-
-export const Default: StoryObj = { render: () => };
-export const Destructive: StoryObj = {
- render: () => ,
-};
-export const WithRememberChoice: StoryObj = {
- render: () => ,
-};
diff --git a/app/src/components/molecules/compounds/ConfirmDialog/ConfirmDialog.test.tsx b/app/src/components/molecules/compounds/ConfirmDialog/ConfirmDialog.test.tsx
deleted file mode 100644
index c09091e..0000000
--- a/app/src/components/molecules/compounds/ConfirmDialog/ConfirmDialog.test.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import { ConfirmDialog } from "@/components/molecules/compounds/ConfirmDialog";
-
-describe("ConfirmDialog", () => {
- it("rendert Titel und Description wenn offen", () => {
- render(
- {}}
- title="Wirklich löschen?"
- description="Das kann nicht rückgängig gemacht werden."
- onConfirm={() => {}}
- />,
- );
- expect(screen.getByText("Wirklich löschen?")).toBeInTheDocument();
- expect(screen.getByText("Das kann nicht rückgängig gemacht werden.")).toBeInTheDocument();
- });
-
- it("rendert nichts wenn geschlossen", () => {
- render(
- {}} title="Hidden" onConfirm={() => {}} />,
- );
- expect(screen.queryByText("Hidden")).toBeNull();
- });
-
- it("ruft onConfirm beim Klick auf den Bestätigen-Button auf", async () => {
- const user = userEvent.setup();
- const onConfirm = vi.fn();
- const onOpenChange = vi.fn();
- render(
- ,
- );
- await user.click(screen.getByRole("button", { name: "Ja, löschen" }));
- expect(onConfirm).toHaveBeenCalledTimes(1);
- });
-
- it("zeigt die 'don't ask again'-Checkbox bei rememberKey", () => {
- render(
- {}}
- title="Test"
- rememberKey="test.key"
- onConfirm={() => {}}
- />,
- );
- expect(screen.getByRole("checkbox")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/molecules/compounds/ConfirmDialog/index.tsx b/app/src/components/molecules/compounds/ConfirmDialog/index.tsx
deleted file mode 100644
index 7c82f98..0000000
--- a/app/src/components/molecules/compounds/ConfirmDialog/index.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
-
-import { useTranslation } from "react-i18next";
-
-import { storageKeyForConfirmSkip } from "@recrest/shared";
-
-import { Checkbox } from "@/components/atoms/Checkbox";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/molecules/compounds/AlertDialog";
-
-/**
- * Single source of truth for "are you sure?" prompts. Each caller supplies
- * a stable `rememberKey` so the user's "don't ask again" choice survives
- * across sessions via `localStorage`. Keys are namespaced via
- * `storageKeyForConfirmSkip()` (see `@recrest/shared`).
- */
-const skipKey = storageKeyForConfirmSkip;
-
-// eslint-disable-next-line react-refresh/only-export-components
-export function isConfirmSkipped(key: string): boolean {
- try {
- return localStorage.getItem(skipKey(key)) === "1";
- } catch {
- return false;
- }
-}
-
-// eslint-disable-next-line react-refresh/only-export-components
-export function setConfirmSkipped(key: string, value: boolean): void {
- try {
- if (value) localStorage.setItem(skipKey(key), "1");
- else localStorage.removeItem(skipKey(key));
- } catch {
- /* no-op */
- }
-}
-
-interface ConfirmDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- title: ReactNode;
- description?: ReactNode;
- confirmLabel?: ReactNode;
- cancelLabel?: ReactNode;
- /** Visual tone for the confirm button. Destructive highlights the action. */
- tone?: "default" | "destructive";
- /** When present, shows a "don't ask again" checkbox and remembers the
- * choice under this key. Omit to force confirmation every time. */
- rememberKey?: string;
- onConfirm: () => void | Promise;
-}
-
-/**
- * Controlled confirm dialog. Pair it with `useConfirmOnce` for callers that
- * want a simple "confirm unless the user opted out" workflow.
- */
-export function ConfirmDialog({
- open,
- onOpenChange,
- title,
- description,
- confirmLabel,
- cancelLabel,
- tone = "default",
- rememberKey,
- onConfirm,
-}: ConfirmDialogProps) {
- const { t } = useTranslation();
- const [skip, setSkip] = useState(false);
- const [busy, setBusy] = useState(false);
-
- useEffect(() => {
- if (open) setSkip(false);
- }, [open]);
-
- const handleConfirm = async () => {
- setBusy(true);
- try {
- if (rememberKey && skip) setConfirmSkipped(rememberKey, true);
- await onConfirm();
- onOpenChange(false);
- } finally {
- setBusy(false);
- }
- };
-
- return (
-
-
-
- {title}
- {description && {description} }
-
-
- {rememberKey && (
-
- setSkip(v === true)} />
- {t("confirm.skip", { defaultValue: "Don't ask again for this action" })}
-
- )}
-
-
-
- {cancelLabel ?? t("actions.cancel", { ns: "common", defaultValue: "Cancel" })}
-
- {
- e.preventDefault();
- void handleConfirm();
- }}
- data-tone={tone}
- className={tone === "destructive" ? "bg-destructive hover:bg-destructive/90" : ""}
- disabled={busy}
- >
- {confirmLabel ?? t("actions.confirm", { ns: "common", defaultValue: "Confirm" })}
-
-
-
-
- );
-}
-
-/**
- * Hook that wraps `ConfirmDialog` with a promise-based API. Call `confirm()`
- * with the prompt details; it resolves to `true` if the user confirmed (or
- * had previously opted out), `false` otherwise. Mount the returned ``
- * anywhere in the tree.
- */
-// eslint-disable-next-line react-refresh/only-export-components
-export function useConfirm() {
- const [state, setState] = useState<
- | (Omit & {
- resolve: (ok: boolean) => void;
- })
- | null
- >(null);
-
- const confirm = useCallback(
- (opts: Omit): Promise => {
- if (opts.rememberKey && isConfirmSkipped(opts.rememberKey)) {
- return Promise.resolve(true);
- }
- return new Promise((resolve) => {
- setState({ ...opts, resolve });
- });
- },
- [],
- );
-
- const node = useMemo(() => {
- if (!state) return null;
- const close = (ok: boolean) => {
- state.resolve(ok);
- setState(null);
- };
- return (
- {
- if (!o) close(false);
- }}
- onConfirm={() => close(true)}
- />
- );
- }, [state]);
-
- return { confirm, node } as const;
-}
diff --git a/app/src/components/molecules/compounds/Dialog/Dialog.stories.tsx b/app/src/components/molecules/compounds/Dialog/Dialog.stories.tsx
deleted file mode 100644
index bdf9a8d..0000000
--- a/app/src/components/molecules/compounds/Dialog/Dialog.stories.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Button } from "@/components/atoms/Button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/molecules/compounds/Dialog";
-
-const meta: Meta = {
- title: "Molecules/Compounds/Dialog",
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- render: () => (
-
-
- Open dialog
-
-
-
- Add scan path
-
- Recrest will index all git repositories found under the selected folder.
-
-
-
- Cancel
- Add path
-
-
-
- ),
-};
-
-export const DestructiveConfirmation: StoryObj = {
- render: () => (
-
-
- Remove repository
-
-
-
- Remove repository?
-
- This will remove the repository from Recrest. The files on disk are untouched.
-
-
-
- Cancel
- Remove
-
-
-
- ),
-};
-
-export const WithoutCloseButton: StoryObj = {
- render: () => (
-
-
- Open blocking dialog
-
-
-
- Scanning in progress
-
- This dialog cannot be dismissed until the scan completes.
-
-
-
-
- ),
-};
diff --git a/app/src/components/molecules/compounds/Dialog/Dialog.test.tsx b/app/src/components/molecules/compounds/Dialog/Dialog.test.tsx
deleted file mode 100644
index 2f5eca8..0000000
--- a/app/src/components/molecules/compounds/Dialog/Dialog.test.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it } from "vitest";
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogTitle,
- DialogTrigger,
-} from "@/components/molecules/compounds/Dialog";
-
-describe("Dialog", () => {
- it("rendert den Trigger", () => {
- render(
-
- Open
-
- Titel
-
- ,
- );
- expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
- });
-
- it("öffnet den Dialog beim Klick auf den Trigger", async () => {
- const user = userEvent.setup();
- render(
-
- Open
-
- Mein Titel
- Beschreibung
-
- ,
- );
- await user.click(screen.getByRole("button", { name: "Open" }));
- expect(await screen.findByText("Mein Titel")).toBeInTheDocument();
- expect(screen.getByText("Beschreibung")).toBeInTheDocument();
- });
-
- it("rendert im kontrollierten Modus geöffnet", () => {
- render(
-
-
- Open Dialog
-
- ,
- );
- expect(screen.getByText("Open Dialog")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/molecules/compounds/Dialog/index.tsx b/app/src/components/molecules/compounds/Dialog/index.tsx
deleted file mode 100644
index 9f198eb..0000000
--- a/app/src/components/molecules/compounds/Dialog/index.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as DialogPrimitive from "@radix-ui/react-dialog";
-import { X } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-// Wrap the bare Radix primitives as function components instead of `const`
-// aliases so `react-refresh/only-export-components` recognises every export as
-// a component and Fast Refresh keeps working in this file.
-export function Dialog(props: ComponentProps) {
- return ;
-}
-export function DialogTrigger(props: ComponentProps) {
- return ;
-}
-export function DialogClose(props: ComponentProps) {
- return ;
-}
-export function DialogPortal(props: ComponentProps) {
- return ;
-}
-
-export function DialogOverlay({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
-
-export function DialogContent({
- className,
- children,
- showClose = true,
- ...props
-}: ComponentProps & { showClose?: boolean }) {
- return (
-
-
-
- {children}
- {showClose && (
-
-
- Close
-
- )}
-
-
- );
-}
-
-export function DialogHeader({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-export function DialogFooter({ className, ...props }: ComponentProps<"div">) {
- return (
-
- );
-}
-
-export function DialogTitle({ className, ...props }: ComponentProps) {
- return (
-
- );
-}
-
-export function DialogDescription({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
diff --git a/app/src/components/molecules/compounds/DropdownMenu/DropdownMenu.stories.tsx b/app/src/components/molecules/compounds/DropdownMenu/DropdownMenu.stories.tsx
deleted file mode 100644
index e55a312..0000000
--- a/app/src/components/molecules/compounds/DropdownMenu/DropdownMenu.stories.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useState } from "react";
-
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { ExternalLink, GitPullRequest, Star, Trash2 } from "lucide-react";
-
-import { Button } from "@/components/atoms/Button";
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuTrigger,
-} from "@/components/molecules/compounds/DropdownMenu";
-
-const meta: Meta = {
- title: "Molecules/Compounds/DropdownMenu",
-};
-
-export default meta;
-
-function DefaultStory() {
- return (
-
-
- Repository actions
-
-
- recrest
-
-
- Pin
-
-
- Open pull requests
- {"\u2318P"}
-
-
- Open in browser
-
-
-
- Remove
-
-
-
- );
-}
-
-function WithCheckboxesStory() {
- const [drafts, setDrafts] = useState(true);
- const [own, setOwn] = useState(false);
- const [ready, setReady] = useState(true);
- return (
-
-
- PR filters
-
-
- Show
-
-
- Drafts
-
-
- Ready for review
-
-
- Authored by me
-
-
-
- );
-}
-
-function WithRadioGroupStory() {
- const [sort, setSort] = useState("updated");
- return (
-
-
- Sort by
-
-
- Sort by
-
-
- Last updated
- Name
- Status
-
-
-
- );
-}
-
-export const Default: StoryObj = { render: () => };
-export const WithCheckboxes: StoryObj = { render: () => };
-export const WithRadioGroup: StoryObj = { render: () => };
diff --git a/app/src/components/molecules/compounds/DropdownMenu/DropdownMenu.test.tsx b/app/src/components/molecules/compounds/DropdownMenu/DropdownMenu.test.tsx
deleted file mode 100644
index 3f52598..0000000
--- a/app/src/components/molecules/compounds/DropdownMenu/DropdownMenu.test.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/molecules/compounds/DropdownMenu";
-
-describe("DropdownMenu", () => {
- it("rendert den Trigger", () => {
- render(
-
- Menu
-
- Eintrag
-
- ,
- );
- expect(screen.getByRole("button", { name: "Menu" })).toBeInTheDocument();
- });
-
- it("öffnet das Menü beim Klick auf den Trigger", async () => {
- const user = userEvent.setup();
- render(
-
- Menu
-
- Profil
-
- Logout
-
- ,
- );
- await user.click(screen.getByRole("button", { name: "Menu" }));
- expect(await screen.findByText("Profil")).toBeInTheDocument();
- expect(screen.getByText("Logout")).toBeInTheDocument();
- });
-
- it("ruft onSelect auf, wenn ein Item gewählt wird", async () => {
- const user = userEvent.setup();
- const onSelect = vi.fn();
- render(
-
- Menu
-
- Eintrag
-
- ,
- );
- await user.click(screen.getByRole("button", { name: "Menu" }));
- await user.click(await screen.findByText("Eintrag"));
- expect(onSelect).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/app/src/components/molecules/compounds/DropdownMenu/index.tsx b/app/src/components/molecules/compounds/DropdownMenu/index.tsx
deleted file mode 100644
index e47a489..0000000
--- a/app/src/components/molecules/compounds/DropdownMenu/index.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
-import { Check, ChevronRight, Circle } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-// Wrap the bare Radix primitives as function components instead of `const`
-// aliases so `react-refresh/only-export-components` recognises every export as
-// a component and Fast Refresh keeps working in this file.
-export function DropdownMenu(props: ComponentProps) {
- return ;
-}
-export function DropdownMenuTrigger(props: ComponentProps) {
- return ;
-}
-export function DropdownMenuGroup(props: ComponentProps) {
- return ;
-}
-export function DropdownMenuPortal(props: ComponentProps) {
- return ;
-}
-export function DropdownMenuSub(props: ComponentProps) {
- return ;
-}
-export function DropdownMenuRadioGroup(
- props: ComponentProps,
-) {
- return ;
-}
-
-export function DropdownMenuContent({
- className,
- sideOffset = 4,
- ...props
-}: ComponentProps) {
- return (
-
-
-
- );
-}
-
-export function DropdownMenuItem({
- className,
- inset,
- ...props
-}: ComponentProps & { inset?: boolean }) {
- return (
-
- );
-}
-
-export function DropdownMenuCheckboxItem({
- className,
- children,
- checked,
- ...props
-}: ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- );
-}
-
-export function DropdownMenuRadioItem({
- className,
- children,
- ...props
-}: ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- );
-}
-
-export function DropdownMenuLabel({
- className,
- inset,
- ...props
-}: ComponentProps & { inset?: boolean }) {
- return (
-
- );
-}
-
-export function DropdownMenuSeparator({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
-
-export function DropdownMenuShortcut({ className, ...props }: ComponentProps<"span">) {
- return (
-
- );
-}
-
-export function DropdownMenuSubTrigger({
- className,
- inset,
- children,
- ...props
-}: ComponentProps & { inset?: boolean }) {
- return (
-
- {children}
-
-
- );
-}
-
-export function DropdownMenuSubContent({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
diff --git a/app/src/components/molecules/compounds/Select/Select.stories.tsx b/app/src/components/molecules/compounds/Select/Select.stories.tsx
deleted file mode 100644
index 8fbc5a7..0000000
--- a/app/src/components/molecules/compounds/Select/Select.stories.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectSeparator,
- SelectTrigger,
- SelectValue,
-} from "@/components/molecules/compounds/Select";
-
-const meta: Meta = {
- title: "Molecules/Compounds/Select",
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- render: () => (
-
-
-
-
-
-
- System
- Light
- Dark
-
-
-
- ),
-};
-
-export const WithPlaceholder: StoryObj = {
- render: () => (
-
-
-
-
-
-
- Visual Studio Code
- WebStorm
- Zed
- Sublime Text
-
-
-
- ),
-};
-
-export const Grouped: StoryObj = {
- render: () => (
-
-
-
-
-
-
-
- Cloud
- GitHub.com
- GitLab.com
- Bitbucket
-
-
-
- Self-hosted
- GitHub Enterprise
- GitLab self-managed
-
-
-
-
- ),
-};
-
-export const Disabled: StoryObj = {
- render: () => (
-
-
-
-
-
-
- English
- Deutsch
-
-
-
- ),
-};
diff --git a/app/src/components/molecules/compounds/Select/Select.test.tsx b/app/src/components/molecules/compounds/Select/Select.test.tsx
deleted file mode 100644
index c7e0d60..0000000
--- a/app/src/components/molecules/compounds/Select/Select.test.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
-
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/molecules/compounds/Select";
-
-describe("Select", () => {
- it("rendert den Trigger mit Placeholder", () => {
- render(
-
-
-
-
-
- A
-
- ,
- );
- expect(screen.getByText("Bitte wählen")).toBeInTheDocument();
- });
-
- it("zeigt den ausgewählten Wert an, wenn ein Default gesetzt ist", () => {
- render(
-
-
-
-
-
- Apfel
- Birne
-
- ,
- );
- expect(screen.getByText("Birne")).toBeInTheDocument();
- });
-});
diff --git a/app/src/components/molecules/compounds/Select/index.tsx b/app/src/components/molecules/compounds/Select/index.tsx
deleted file mode 100644
index ef8b2ec..0000000
--- a/app/src/components/molecules/compounds/Select/index.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as SelectPrimitive from "@radix-ui/react-select";
-import { Check, ChevronDown, ChevronUp } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-
-// Wrap the bare Radix primitives as function components instead of `const`
-// aliases so `react-refresh/only-export-components` recognises every export as
-// a component and Fast Refresh keeps working in this file.
-export function Select(props: ComponentProps) {
- return ;
-}
-export function SelectGroup(props: ComponentProps) {
- return ;
-}
-export function SelectValue(props: ComponentProps) {
- return ;
-}
-
-export function SelectTrigger({
- className,
- children,
- ...props
-}: ComponentProps) {
- return (
- span]:line-clamp-1",
- className,
- )}
- {...props}
- >
- {children}
-
-
-
-
- );
-}
-
-export function SelectContent({
- className,
- children,
- position = "popper",
- ...props
-}: ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
- );
-}
-
-export function SelectItem({
- className,
- children,
- ...props
-}: ComponentProps) {
- return (
-
-
-
-
-
-
- {children}
-
- );
-}
-
-export function SelectSeparator({
- className,
- ...props
-}: ComponentProps) {
- return (
-
- );
-}
-
-export function SelectLabel({ className, ...props }: ComponentProps) {
- return (
-
- );
-}
diff --git a/app/src/components/molecules/compounds/Tabs/Tabs.stories.tsx b/app/src/components/molecules/compounds/Tabs/Tabs.stories.tsx
deleted file mode 100644
index 5be7594..0000000
--- a/app/src/components/molecules/compounds/Tabs/Tabs.stories.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/molecules/compounds/Tabs";
-
-const meta: Meta = {
- title: "Molecules/Compounds/Tabs",
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- render: () => (
-
-
- Overview
- Branches
- Activity
-
-
- Summary of the repository’s current state.
-
-
- All local branches and their upstream tracking.
-
-
- Recent commits and CI runs.
-
-
- ),
-};
-
-export const ManyTabs: StoryObj = {
- render: () => (
-
-
- General
- Appearance
- Providers
- Scan
- Advanced
-
-
- Basic settings like language and startup behaviour.
-
-
- Theme, accent, and typography.
-
-
- Connect GitHub, GitLab, or Bitbucket.
-
-
- Folders that Recrest indexes for repositories.
-
-
- Experimental flags.
-
-
- ),
-};
-
-export const WithDisabledTab: StoryObj = {
- render: () => (
-
-
- Available
-
- Coming soon
-
-
-
- The first tab is selectable.
-
-
- You shouldn’t be able to see this.
-
-
- ),
-};
diff --git a/app/src/components/molecules/compounds/Tabs/Tabs.test.tsx b/app/src/components/molecules/compounds/Tabs/Tabs.test.tsx
deleted file mode 100644
index 64fa984..0000000
--- a/app/src/components/molecules/compounds/Tabs/Tabs.test.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it } from "vitest";
-
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/molecules/compounds/Tabs";
-
-describe("Tabs", () => {
- it("rendert den Default-Tab-Inhalt", () => {
- render(
-
-
- Erster
- Zweiter
-
- Inhalt 1
- Inhalt 2
- ,
- );
- expect(screen.getByText("Inhalt 1")).toBeInTheDocument();
- expect(screen.queryByText("Inhalt 2")).toBeNull();
- });
-
- it("wechselt den Inhalt beim Klick auf einen anderen Tab", async () => {
- const user = userEvent.setup();
- render(
-
-
- Erster
- Zweiter
-
- Inhalt 1
- Inhalt 2
- ,
- );
- await user.click(screen.getByRole("tab", { name: "Zweiter" }));
- expect(screen.getByText("Inhalt 2")).toBeInTheDocument();
- });
-
- it("markiert den aktiven Tab mit data-state=active", () => {
- render(
-
-
- A
- B
-
- x
- ,
- );
- expect(screen.getByRole("tab", { name: "A" })).toHaveAttribute("data-state", "active");
- });
-});
diff --git a/app/src/components/molecules/compounds/Tabs/index.tsx b/app/src/components/molecules/compounds/Tabs/index.tsx
deleted file mode 100644
index ebd8559..0000000
--- a/app/src/components/molecules/compounds/Tabs/index.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as TabsPrimitive from "@radix-ui/react-tabs";
-
-import { cn } from "@/lib/utils";
-
-// Wrap the bare Radix primitive as a function component instead of a `const`
-// alias so `react-refresh/only-export-components` recognises it as a component
-// and Fast Refresh keeps working in this file.
-export function Tabs(props: ComponentProps) {
- return ;
-}
-
-export function TabsList({ className, ...props }: ComponentProps) {
- return (
-
- );
-}
-
-export function TabsTrigger({ className, ...props }: ComponentProps) {
- return (
-
- );
-}
-
-export function TabsContent({ className, ...props }: ComponentProps) {
- return (
-
- );
-}
diff --git a/app/src/components/molecules/compounds/Tooltip/Tooltip.stories.tsx b/app/src/components/molecules/compounds/Tooltip/Tooltip.stories.tsx
deleted file mode 100644
index d7380da..0000000
--- a/app/src/components/molecules/compounds/Tooltip/Tooltip.stories.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import type { Meta, StoryObj } from "@storybook/react-vite";
-
-import { Button } from "@/components/atoms/Button";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/molecules/compounds/Tooltip";
-
-const meta: Meta = {
- title: "Molecules/Compounds/Tooltip",
- decorators: [
- (Story) => (
-
-
-
-
-
- ),
- ],
-};
-
-export default meta;
-
-export const Default: StoryObj = {
- render: () => (
-
-
- Hover me
-
- Refresh status for all repositories
-
- ),
-};
-
-export const LongContent: StoryObj = {
- render: () => (
-
-
- Long tooltip
-
-
- Personal access tokens are only stored in the OS keychain. Recrest never writes them to disk
- in plaintext.
-
-
- ),
-};
-
-export const Sides: StoryObj = {
- render: () => (
-
- {(["top", "right", "bottom", "left"] as const).map((side) => (
-
-
- {side}
-
- Opens on {side}
-
- ))}
-
- ),
-};
diff --git a/app/src/components/molecules/compounds/Tooltip/Tooltip.test.tsx b/app/src/components/molecules/compounds/Tooltip/Tooltip.test.tsx
deleted file mode 100644
index 652bb30..0000000
--- a/app/src/components/molecules/compounds/Tooltip/Tooltip.test.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it } from "vitest";
-
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/molecules/compounds/Tooltip";
-
-describe("Tooltip", () => {
- it("rendert den Trigger", () => {
- render(
-
-
- Hover me
- Hilfe
-
- ,
- );
- expect(screen.getByText("Hover me")).toBeInTheDocument();
- });
-
- it("zeigt den Content-Text nach Fokus auf dem Trigger", async () => {
- const user = userEvent.setup();
- render(
-
-
- Trigger
- Tooltiptext
-
- ,
- );
- await user.tab();
- const matches = await screen.findAllByText("Tooltiptext");
- expect(matches.length).toBeGreaterThan(0);
- });
-});
diff --git a/app/src/components/molecules/compounds/Tooltip/index.tsx b/app/src/components/molecules/compounds/Tooltip/index.tsx
deleted file mode 100644
index 57e8705..0000000
--- a/app/src/components/molecules/compounds/Tooltip/index.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { type ComponentProps } from "react";
-
-import * as TooltipPrimitive from "@radix-ui/react-tooltip";
-
-import { cn } from "@/lib/utils";
-
-// Wrap the bare Radix primitives as function components rather than re-exporting
-// them as `const` aliases. The `const` form trips
-// `react-refresh/only-export-components`: the rule cannot tell that the value is
-// itself a component, so it bails out of Fast Refresh for the whole file.
-// Wrapping is a one-line cost and keeps every import site unchanged.
-export function TooltipProvider(props: ComponentProps) {
- return ;
-}
-export function Tooltip(props: ComponentProps) {
- return ;
-}
-export function TooltipTrigger(props: ComponentProps) {
- return ;
-}
-
-export function TooltipContent({
- className,
- sideOffset = 6,
- ...props
-}: ComponentProps) {
- return (
-
-
-
- );
-}
diff --git a/app/src/components/molecules/compounds/TruncatedTooltip/TruncatedTooltip.test.tsx b/app/src/components/molecules/compounds/TruncatedTooltip/TruncatedTooltip.test.tsx
deleted file mode 100644
index 18c34b1..0000000
--- a/app/src/components/molecules/compounds/TruncatedTooltip/TruncatedTooltip.test.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-import { TooltipProvider } from "@/components/molecules/compounds/Tooltip";
-import { TruncatedTooltip } from "@/components/molecules/compounds/TruncatedTooltip";
-
-/**
- * jsdom does not perform layout, so `scrollWidth` and `clientWidth` are both
- * `0` by default. We stub them on `HTMLElement.prototype` to simulate the
- * two relevant states.
- */
-function stubWidths(scroll: number, client: number) {
- Object.defineProperty(HTMLElement.prototype, "scrollWidth", {
- configurable: true,
- get() {
- return scroll;
- },
- });
- Object.defineProperty(HTMLElement.prototype, "clientWidth", {
- configurable: true,
- get() {
- return client;
- },
- });
-}
-
-beforeEach(() => {
- // Minimal ResizeObserver stub — our hook only uses observe/disconnect.
- (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
- observe() {}
- disconnect() {}
- unobserve() {}
- };
-});
-
-afterEach(() => {
- vi.restoreAllMocks();
-});
-
-describe("TruncatedTooltip", () => {
- it("renders the child as-is when text is not truncated", () => {
- stubWidths(50, 100); // scroll <= client → not truncated
- render(
-
-
- short
-
- ,
- );
- const child = screen.getByTestId("tt-child");
- expect(child).toBeInTheDocument();
- // Radix adds `data-state` to its Trigger. A bare child has none.
- expect(child.getAttribute("data-state")).toBeNull();
- });
-
- it("wraps the child in a tooltip trigger when text overflows", () => {
- stubWidths(200, 100); // scroll > client → truncated
- render(
-
-
- overflowing
-
- ,
- );
- const child = screen.getByTestId("tt-child");
- // Radix Tooltip.Trigger sets `data-state="closed"` on the trigger element
- // until hovered/focused. Its presence confirms the wrapping kicked in.
- expect(child.getAttribute("data-state")).toBe("closed");
- });
-
- it("renders children as-is when content is empty", () => {
- stubWidths(200, 100); // would normally trigger tooltip
- render(
-
-
- whatever
-
- ,
- );
- const child = screen.getByTestId("tt-child");
- expect(child.getAttribute("data-state")).toBeNull();
- });
-});
diff --git a/app/src/components/molecules/compounds/TruncatedTooltip/index.tsx b/app/src/components/molecules/compounds/TruncatedTooltip/index.tsx
deleted file mode 100644
index 0e3db2a..0000000
--- a/app/src/components/molecules/compounds/TruncatedTooltip/index.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import {
- type ReactElement,
- type Ref,
- cloneElement,
- useCallback,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/molecules/compounds/Tooltip";
-
-interface Props {
- /** Tooltip text. If undefined/empty, renders children as-is. */
- content: string | undefined | null;
- /** Single child whose DOM node is measured for truncation.
- * Must be a DOM element (e.g. ``, ``, `
`, `
`) — not a
- * function component, because a ref is attached to decide whether the
- * content is actually clipped. */
- children: ReactElement;
-}
-
-/**
- * Wraps children in a Tooltip only when the child's text is actually
- * horizontally truncated (`scrollWidth > clientWidth`). Keeps a11y and
- * hover noise down on labels that fit their container.
- *
- * Assumes `TooltipProvider` is mounted higher up (done once in `AppShell`).
- */
-export function TruncatedTooltip({ content, children }: Props) {
- const innerRef = useRef(null);
- const [truncated, setTruncated] = useState(false);
-
- const measure = useCallback(() => {
- const el = innerRef.current;
- if (!el) return;
- setTruncated(el.scrollWidth > el.clientWidth);
- }, []);
-
- useLayoutEffect(() => {
- measure();
- const el = innerRef.current;
- if (!el || typeof ResizeObserver === "undefined") return;
- const ro = new ResizeObserver(measure);
- ro.observe(el);
- return () => ro.disconnect();
- }, [measure, content]);
-
- const originalRef = (children as unknown as { ref?: Ref }).ref;
- const mergedRef = (node: HTMLElement | null) => {
- innerRef.current = node;
- if (typeof originalRef === "function") {
- originalRef(node);
- } else if (originalRef && typeof originalRef === "object") {
- (originalRef as { current: HTMLElement | null }).current = node;
- }
- };
-
- // `cloneElement` does not carry the child's exact props signature, so the
- // ref key must be injected via a loosely-typed props object. DOM elements
- // accept the `ref` prop at runtime regardless.
- const childWithRef = cloneElement(children, { ref: mergedRef } as unknown as Partial);
-
- if (!content || !truncated) return childWithRef;
- return (
-
- {childWithRef}
- {content}
-
- );
-}
diff --git a/app/src/components/molecules/drawers/GeneralDrawer/GeneralDrawer.stories.tsx b/app/src/components/molecules/drawers/GeneralDrawer/GeneralDrawer.stories.tsx
new file mode 100644
index 0000000..a0bbd3b
--- /dev/null
+++ b/app/src/components/molecules/drawers/GeneralDrawer/GeneralDrawer.stories.tsx
@@ -0,0 +1,34 @@
+import { useState } from "react";
+
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import GeneralButton from "@/components/atoms/buttons/GeneralButton";
+import GeneralDrawer from "@/components/molecules/drawers/GeneralDrawer";
+
+const Body = styled(Box)(({ theme }) => ({ padding: theme.spacing(2) }));
+
+function DefaultDemo() {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>Open drawer
+ setOpen(false)}>
+ Drawer content
+
+ >
+ );
+}
+
+const meta: Meta = {
+ title: "Molecules/Drawers/GeneralDrawer",
+ component: GeneralDrawer,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = { render: () => };
diff --git a/app/src/components/molecules/drawers/GeneralDrawer/GeneralDrawer.test.tsx b/app/src/components/molecules/drawers/GeneralDrawer/GeneralDrawer.test.tsx
new file mode 100644
index 0000000..e4487f0
--- /dev/null
+++ b/app/src/components/molecules/drawers/GeneralDrawer/GeneralDrawer.test.tsx
@@ -0,0 +1,27 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import GeneralDrawer from "@/components/molecules/drawers/GeneralDrawer";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralDrawer", () => {
+ it("renders its body when open", () => {
+ const { getByTestId } = renderWithTheme(
+ {}}>
+ drawer body
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.molecules.drawer.body)).toBeInTheDocument();
+ });
+
+ it("does not render the body when closed", () => {
+ const { queryByTestId } = renderWithTheme(
+ {}}>
+ drawer body
+ ,
+ );
+ expect(queryByTestId(COMPONENT_TEST_IDS.molecules.drawer.body)).toBeNull();
+ });
+});
diff --git a/app/src/components/molecules/drawers/GeneralDrawer/index.tsx b/app/src/components/molecules/drawers/GeneralDrawer/index.tsx
new file mode 100644
index 0000000..c1f3dfa
--- /dev/null
+++ b/app/src/components/molecules/drawers/GeneralDrawer/index.tsx
@@ -0,0 +1,86 @@
+import { Drawer, type DrawerProps as MuiDrawerProps } from "@mui/material";
+import type { Theme } from "@mui/material/styles";
+
+export type GeneralDrawerSize = "sm" | "md" | "lg" | "xl";
+
+export interface GeneralDrawerProps extends MuiDrawerProps {
+ size?: GeneralDrawerSize;
+}
+
+/**
+ * Per-size paper widths. Tuned to match the Recrest baseline mocks, where
+ * the MR-detail pane reads as a sidebar (~360 px) rather than a half-screen
+ * overlay. `lg` and `xl` cover the rare cases that genuinely need the extra
+ * room (e.g. a future full-page diff drawer).
+ */
+const SIZE_PX: Record = {
+ sm: 320,
+ md: 360,
+ lg: 420,
+ xl: 560,
+};
+
+/**
+ * Recrest's drawer style intentionally drops MUI's heavy elevation stack —
+ * the original mocks pin the drawer to the viewport edge with a single
+ * border-left + a barely-there ambient shadow, so adjacent UI keeps its
+ * contrast. We also disable the backdrop so the MR list behind the panel
+ * stays interactive (matches the original "inspect while browsing" flow).
+ */
+function GeneralDrawer({
+ size = "md",
+ anchor = "right",
+ slotProps,
+ hideBackdrop = true,
+ ...rest
+}: GeneralDrawerProps) {
+ const width = SIZE_PX[size];
+ const paperSlotProps = slotProps?.paper ?? {};
+ return (
+ `1px solid ${theme.palette.divider}`,
+ borderRight: 0,
+ boxShadow: "0 4px 12px -4px rgba(17,17,22,0.10), 0 1px 0 rgba(17,17,22,0.04)",
+ backgroundImage: "none",
+ ...((paperSlotProps as { sx?: unknown }).sx ?? {}),
+ } as object,
+ },
+ }}
+ {...rest}
+ />
+ );
+}
+
+export default GeneralDrawer;
diff --git a/app/src/components/molecules/drawers/MrDetailDrawer/MrDetailDrawer.stories.tsx b/app/src/components/molecules/drawers/MrDetailDrawer/MrDetailDrawer.stories.tsx
new file mode 100644
index 0000000..53828fb
--- /dev/null
+++ b/app/src/components/molecules/drawers/MrDetailDrawer/MrDetailDrawer.stories.tsx
@@ -0,0 +1,53 @@
+import { Provider as ReduxProvider } from "react-redux";
+
+import { MemoryRouter } from "react-router-dom";
+
+import { I18nextProvider } from "react-i18next";
+
+import { configureStore } from "@reduxjs/toolkit";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import MrDetailDrawer from "@/components/molecules/drawers/MrDetailDrawer";
+import i18n from "@/locales";
+import { providersReducer } from "@/store/reducers/providersReducer";
+import { prsReducer } from "@/store/reducers/prsReducer";
+import { remoteImportReducer } from "@/store/reducers/remoteImportReducer";
+import { reposReducer } from "@/store/reducers/reposReducer";
+import { settingsReducer } from "@/store/reducers/settingsReducer";
+import { uiReducer } from "@/store/reducers/uiReducer";
+
+const store = configureStore({
+ reducer: {
+ ui: uiReducer,
+ settings: settingsReducer,
+ providers: providersReducer,
+ repos: reposReducer,
+ prs: prsReducer,
+ remoteImport: remoteImportReducer,
+ },
+});
+
+const meta = {
+ title: "Molecules/Drawers/MrDetailDrawer",
+ component: MrDetailDrawer,
+ decorators: [
+ (Story) => (
+
+
+
+
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Empty: Story = {
+ args: { pr: null, repoId: "demo", onClose: () => {} },
+};
diff --git a/app/src/components/molecules/drawers/MrDetailDrawer/MrDetailDrawer.test.tsx b/app/src/components/molecules/drawers/MrDetailDrawer/MrDetailDrawer.test.tsx
new file mode 100644
index 0000000..04d6b7c
--- /dev/null
+++ b/app/src/components/molecules/drawers/MrDetailDrawer/MrDetailDrawer.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import MrDetailDrawer from "@/components/molecules/drawers/MrDetailDrawer";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("MrDetailDrawer", () => {
+ it("does not render the drawer body when pr is null", () => {
+ const { queryByTestId } = renderWithProviders(
+ {}} />,
+ );
+ expect(queryByTestId(TEST_IDS.mr.drawer)).toBeNull();
+ });
+});
diff --git a/app/src/components/molecules/drawers/MrDetailDrawer/index.tsx b/app/src/components/molecules/drawers/MrDetailDrawer/index.tsx
new file mode 100644
index 0000000..db6f156
--- /dev/null
+++ b/app/src/components/molecules/drawers/MrDetailDrawer/index.tsx
@@ -0,0 +1,58 @@
+import { type Ref } from "react";
+
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { PullRequest } from "@recrest/shared";
+
+import GeneralDrawer, {
+ type GeneralDrawerSize,
+} from "@/components/molecules/drawers/GeneralDrawer";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { MrDetailPanel } from "@/pages/app/MergeRequests/components/MrDetailPanel";
+
+const DrawerBody = styled(Box)({
+ height: "100%",
+}) as typeof Box;
+
+export interface MrDetailDrawerProps {
+ pr: PullRequest | null;
+ repoId: string;
+ repoName?: string;
+ size?: GeneralDrawerSize;
+ /** Optional ref forwarded to the drawer body — enables swipe-to-close
+ * handlers (`useDrawerSwipe`) on the same node MUI mounts the content into. */
+ bodyRef?: Ref;
+ /** Pass-through `data-testid` for the drawer body so E2E specs can target it. */
+ bodyTestId?: string;
+ /** Pass-through `data-testid` for the drawer root (paper). */
+ "data-testid"?: string;
+ onClose: () => void;
+}
+
+/**
+ * The canonical "open this PR in a side drawer" widget. Wraps `GeneralDrawer`
+ * with the `MrDetailPanel` body and the testid plumbing so callers don't have
+ * to re-stitch the drawer shell each time. Used on both the MR list page and
+ * the per-repo detail page.
+ */
+function MrDetailDrawer({
+ pr,
+ repoId,
+ repoName,
+ size = "md",
+ bodyRef,
+ bodyTestId = TEST_IDS.mr.drawer,
+ "data-testid": testId,
+ onClose,
+}: MrDetailDrawerProps) {
+ return (
+
+
+ {pr && }
+
+
+ );
+}
+
+export default MrDetailDrawer;
diff --git a/app/src/components/molecules/feedback/EmptyState/EmptyState.stories.tsx b/app/src/components/molecules/feedback/EmptyState/EmptyState.stories.tsx
new file mode 100644
index 0000000..1a905a7
--- /dev/null
+++ b/app/src/components/molecules/feedback/EmptyState/EmptyState.stories.tsx
@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Inbox } from "lucide-react";
+
+import EmptyState from "@/components/molecules/feedback/EmptyState";
+
+const meta = {
+ title: "Molecules/Feedback/EmptyState",
+ component: EmptyState,
+ args: {
+ title: "Nothing here yet",
+ description: "Connect a provider or add a local repository to get started.",
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const WithMascot: Story = { args: { mascot: "shrugging" } };
+export const WithIcon: Story = { args: { icon: Inbox } };
+export const Plain: Story = {};
diff --git a/app/src/components/molecules/feedback/EmptyState/EmptyState.test.tsx b/app/src/components/molecules/feedback/EmptyState/EmptyState.test.tsx
new file mode 100644
index 0000000..9fac6e1
--- /dev/null
+++ b/app/src/components/molecules/feedback/EmptyState/EmptyState.test.tsx
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+
+import EmptyState from "@/components/molecules/feedback/EmptyState";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("EmptyState", () => {
+ it("renders title and description", () => {
+ const { getByText, getByTestId } = renderWithTheme(
+ ,
+ );
+ expect(getByTestId(TEST_IDS.emptyState)).toBeInTheDocument();
+ expect(getByText("Nothing here")).toBeInTheDocument();
+ expect(getByText("Try adding a repo")).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/molecules/feedback/EmptyState/index.tsx b/app/src/components/molecules/feedback/EmptyState/index.tsx
new file mode 100644
index 0000000..216411f
--- /dev/null
+++ b/app/src/components/molecules/feedback/EmptyState/index.tsx
@@ -0,0 +1,124 @@
+import { type ComponentType, type ReactNode } from "react";
+
+import { Box, Typography } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import Mascot, { type MascotVariant } from "@/components/atoms/brand/Mascot";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+
+/**
+ * Centered empty-state block used inside cards and full-page placeholders.
+ * Renders a friendly mascot (preferred), an optional Lucide-style icon, the
+ * required title, optional description and an action slot below.
+ *
+ * The mascot is the primary signifier of Recrest's brand voice in empty
+ * states — callers should default to picking a variant that matches the
+ * semantic of the empty state ("celebrating" for success-shaped empties,
+ * "snoozing" for nothing-to-do, "searching" for no-hits, "waving" for
+ * onboarding, "shrugging" for generic).
+ */
+export interface EmptyStateProps {
+ /** Optional Lucide-style icon. Ignored when `mascot` is set. */
+ icon?: ComponentType<{ size?: number; "aria-hidden"?: boolean }>;
+ /** Friendly Recrest character to show above the text. Takes precedence over `icon`. */
+ mascot?: MascotVariant;
+ /** Pixel size of the mascot SVG. Default 112; use ~88 in compact cards. */
+ mascotSize?: number;
+ title: string;
+ description?: ReactNode;
+ action?: ReactNode;
+ className?: string;
+}
+
+const Root = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 12,
+ width: "100%",
+ minHeight: 180,
+ padding: "32px 24px",
+ textAlign: "center",
+});
+
+const IconBubble = styled(Box)(({ theme }) => ({
+ display: "flex",
+ width: 48,
+ height: 48,
+ alignItems: "center",
+ justifyContent: "center",
+ borderRadius: "50%",
+ backgroundColor: theme.palette.surface.interface.active,
+ color: theme.palette.text.information,
+}));
+
+const TextBlock = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ gap: 4,
+});
+
+const Title = styled(Typography)(({ theme }) => ({
+ fontSize: 13,
+ fontWeight: 600,
+ color: theme.palette.text.primary,
+ margin: 0,
+ letterSpacing: "-0.01em",
+})) as typeof Typography;
+
+const Description = styled(Typography)(({ theme }) => ({
+ fontSize: 12.5,
+ color: theme.palette.text.information,
+ margin: 0,
+ maxWidth: 360,
+ lineHeight: 1.5,
+})) as typeof Typography;
+
+const ActionSlot = styled(Box)({
+ marginTop: 4,
+});
+
+const MascotInk = styled(Box)(({ theme }) => ({
+ color: theme.palette.mode === "dark" ? "rgba(255,255,255,0.85)" : "rgba(20,22,28,0.85)",
+ lineHeight: 0,
+}));
+
+function EmptyState({
+ icon: Icon,
+ mascot,
+ mascotSize = 112,
+ title,
+ description,
+ action,
+ className,
+}: EmptyStateProps) {
+ return (
+
+ {mascot ? (
+
+
+
+ ) : (
+ Icon && (
+
+
+
+ )
+ )}
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {action && {action} }
+
+ );
+}
+
+export default EmptyState;
diff --git a/app/src/components/molecules/feedback/GeneralToaster/GeneralToaster.stories.tsx b/app/src/components/molecules/feedback/GeneralToaster/GeneralToaster.stories.tsx
new file mode 100644
index 0000000..0690208
--- /dev/null
+++ b/app/src/components/molecules/feedback/GeneralToaster/GeneralToaster.stories.tsx
@@ -0,0 +1,39 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { toast } from "sonner";
+
+import GeneralButton from "@/components/atoms/buttons/GeneralButton";
+import GeneralToaster from "@/components/molecules/feedback/GeneralToaster";
+
+const ButtonRow = styled(Box)(({ theme }) => ({
+ display: "flex",
+ gap: theme.spacing(1),
+}));
+
+const meta: Meta = {
+ title: "Molecules/Feedback/GeneralToaster",
+ component: GeneralToaster,
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+ <>
+
+
+ toast.success("Saved")}>Success
+ toast.error("Failed to save")}>
+ Error
+
+ toast("Heads up")}>
+ Neutral
+
+
+ >
+ ),
+};
diff --git a/app/src/components/molecules/feedback/GeneralToaster/GeneralToaster.test.tsx b/app/src/components/molecules/feedback/GeneralToaster/GeneralToaster.test.tsx
new file mode 100644
index 0000000..fa05424
--- /dev/null
+++ b/app/src/components/molecules/feedback/GeneralToaster/GeneralToaster.test.tsx
@@ -0,0 +1,18 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import GeneralToaster from "@/components/molecules/feedback/GeneralToaster";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithTheme } from "@/test/utils";
+
+describe("GeneralToaster", () => {
+ it("mounts inside its wrapper without crashing", () => {
+ const { getByTestId } = renderWithTheme(
+
+
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.molecules.toaster.wrap)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/molecules/feedback/GeneralToaster/index.tsx b/app/src/components/molecules/feedback/GeneralToaster/index.tsx
new file mode 100644
index 0000000..b1ca94f
--- /dev/null
+++ b/app/src/components/molecules/feedback/GeneralToaster/index.tsx
@@ -0,0 +1,27 @@
+import { useTheme } from "@mui/material/styles";
+
+import { Toaster } from "sonner";
+
+/**
+ * Thin sonner wrapper that picks colours from the active MUI theme so the
+ * toast surface matches the app palette in light/dark/oled/glassy modes.
+ */
+function GeneralToaster() {
+ const theme = useTheme();
+ return (
+
+ );
+}
+
+export default GeneralToaster;
diff --git a/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.stories.tsx b/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.stories.tsx
new file mode 100644
index 0000000..46db630
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.stories.tsx
@@ -0,0 +1,54 @@
+import { Provider as ReduxProvider } from "react-redux";
+
+import { MemoryRouter } from "react-router-dom";
+
+import { I18nextProvider } from "react-i18next";
+
+import { configureStore } from "@reduxjs/toolkit";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import AddRepoModal from "@/components/molecules/modals/AddRepoModal";
+import i18n from "@/locales";
+import { providersReducer } from "@/store/reducers/providersReducer";
+import { prsReducer } from "@/store/reducers/prsReducer";
+import { remoteImportReducer } from "@/store/reducers/remoteImportReducer";
+import { reposReducer } from "@/store/reducers/reposReducer";
+import { settingsReducer } from "@/store/reducers/settingsReducer";
+import { uiReducer } from "@/store/reducers/uiReducer";
+
+const store = configureStore({
+ reducer: {
+ ui: uiReducer,
+ settings: settingsReducer,
+ providers: providersReducer,
+ repos: reposReducer,
+ prs: prsReducer,
+ remoteImport: remoteImportReducer,
+ },
+ preloadedState: {
+ ui: { sidebarCollapsed: false, importDialogOpen: true } as never,
+ },
+});
+
+const meta = {
+ title: "Molecules/Modals/AddRepoModal",
+ component: AddRepoModal,
+ decorators: [
+ (Story) => (
+
+
+
+
+
+
+
+ ),
+ ],
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.styles.tsx b/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.styles.tsx
new file mode 100644
index 0000000..7d5f02a
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.styles.tsx
@@ -0,0 +1,131 @@
+import { Box, Typography } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+export const Header = styled(Box)({
+ display: "flex",
+ alignItems: "flex-start",
+ gap: 14,
+ flex: 1,
+ minWidth: 0,
+}) as typeof Box;
+
+export const HeaderIcon = styled(Box)(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 40,
+ height: 40,
+ borderRadius: 8,
+ backgroundColor: theme.palette.primary.main,
+ color: theme.palette.primary.contrastText,
+ flexShrink: 0,
+})) as typeof Box;
+
+export const HeaderText = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ gap: 2,
+ minWidth: 0,
+ flex: 1,
+ paddingTop: 4,
+}) as typeof Box;
+
+export const HeaderTitle = styled(Typography)(({ theme }) => ({
+ fontSize: 18,
+ fontWeight: 700,
+ lineHeight: "24px",
+ color: theme.palette.text.primary,
+ letterSpacing: "-0.01em",
+})) as typeof Typography;
+
+export const HeaderSubtitle = styled(Typography)(({ theme }) => ({
+ fontSize: 12.5,
+ lineHeight: "18px",
+ color: theme.palette.text.information,
+})) as typeof Typography;
+
+export const HeaderBody = styled(Box)({
+ flex: 1,
+ minWidth: 0,
+}) as typeof Box;
+
+export const TitleText = styled(Typography)(({ theme }) => ({
+ fontSize: 15,
+ fontWeight: 700,
+ color: theme.palette.text.primary,
+ letterSpacing: "-0.01em",
+})) as typeof Typography;
+
+export const SubText = styled(Typography)(({ theme }) => ({
+ fontSize: 12,
+ color: theme.palette.text.information,
+ marginTop: 2,
+})) as typeof Typography;
+
+export const TabBar = styled(Box)(({ theme }) => ({
+ display: "flex",
+ gap: 4,
+ padding: "10px 20px 0",
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ flexShrink: 0,
+})) as typeof Box;
+
+// eslint-disable-next-line no-restricted-syntax -- native element required for accessibility
+export const TabButton = styled("button", {
+ shouldForwardProp: (p) => p !== "active",
+})<{ active: boolean }>(({ theme, active }) => ({
+ position: "relative",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ height: 36,
+ padding: "0 12px",
+ background: "transparent",
+ border: 0,
+ marginBottom: -1,
+ color: active ? theme.palette.text.primary : theme.palette.text.information,
+ fontFamily: "inherit",
+ fontSize: 12.5,
+ fontWeight: 600,
+ cursor: "pointer",
+ transition: "color 0.12s ease",
+ "&:hover": {
+ color: theme.palette.text.primary,
+ },
+ "&::after": {
+ content: '""',
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: -1,
+ height: 2,
+ backgroundColor: active ? theme.palette.primary.main : "transparent",
+ borderTopLeftRadius: 2,
+ borderTopRightRadius: 2,
+ },
+}));
+
+// eslint-disable-next-line no-restricted-syntax -- generic styled element required for typed props
+export const Badge = styled("span", {
+ shouldForwardProp: (p) => p !== "active",
+})<{ active?: boolean }>(({ theme, active }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ minWidth: 16,
+ height: 16,
+ padding: "0 5px",
+ borderRadius: 100,
+ fontSize: 10,
+ fontWeight: 700,
+ backgroundColor: active
+ ? theme.palette.primary.main
+ : theme.palette.surface.interface.backElevation,
+ color: active ? theme.palette.primary.contrastText : theme.palette.text.information,
+}));
+
+export const Body = styled(Box)({
+ flex: 1,
+ minHeight: 0,
+ overflow: "hidden",
+}) as typeof Box;
diff --git a/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.test.tsx b/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.test.tsx
new file mode 100644
index 0000000..8fb46dd
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/AddRepoModal.test.tsx
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+
+import AddRepoModal from "@/components/molecules/modals/AddRepoModal";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("AddRepoModal", () => {
+ it("does not render the dialog while the import flag is off", () => {
+ const { queryByTestId } = renderWithProviders( );
+ expect(queryByTestId(TEST_IDS.addRepoDialog.root)).toBeNull();
+ });
+});
diff --git a/app/src/components/molecules/modals/AddRepoModal/index.tsx b/app/src/components/molecules/modals/AddRepoModal/index.tsx
new file mode 100644
index 0000000..6dc6570
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/index.tsx
@@ -0,0 +1,117 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { useTranslation } from "react-i18next";
+
+import { FolderGit2, GitBranch, Plus } from "lucide-react";
+
+import {
+ Badge,
+ Body,
+ Header,
+ HeaderIcon,
+ HeaderSubtitle,
+ HeaderText,
+ HeaderTitle,
+ TabBar,
+ TabButton,
+} from "@/components/molecules/modals/AddRepoModal/AddRepoModal.styles";
+import ClonePanel from "@/components/molecules/modals/AddRepoModal/panels/ClonePanel";
+import LocalPanel from "@/components/molecules/modals/AddRepoModal/panels/LocalPanel";
+import ProvidersPanel from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel";
+import GeneralModal from "@/components/molecules/modals/GeneralModal";
+import { PROVIDER_IDS } from "@/lib/constants/providers.constants";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { setImportDialogOpen } from "@/store/actions/ui.actions";
+import { useAppDispatch, useAppSelector } from "@/store/hooks";
+
+type Tab = "providers" | "local" | "clone";
+
+export default function AddRepoModal() {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const open = useAppSelector((s) => s.ui.importDialogOpen);
+ const connections = useAppSelector((s) => s.providers.connections);
+ const connectedProviders = useMemo(
+ () => PROVIDER_IDS.filter((id) => connections[id]?.connected),
+ [connections],
+ );
+
+ const [tab, setTab] = useState("providers");
+
+ useEffect(() => {
+ if (!open) return;
+ setTab(connectedProviders.length > 0 ? "providers" : "local");
+ }, [open, connectedProviders.length]);
+
+ const close = useCallback(() => {
+ dispatch(setImportDialogOpen(false));
+ }, [dispatch]);
+
+ return (
+
+
+
+
+
+ {t("import.title")}
+ {t("import.desc")}
+
+
+ }
+ contentChildren={
+ <>
+
+ setTab("providers")}
+ data-testid={TEST_IDS.addRepoDialog.tab.providers}
+ >
+
+ {t("import.tab.providers")}
+ {connectedProviders.length > 0 && (
+ {connectedProviders.length}
+ )}
+
+ setTab("local")}
+ data-testid={TEST_IDS.addRepoDialog.tab.local}
+ >
+
+ {t("import.tab.local")}
+
+ setTab("clone")}
+ data-testid={TEST_IDS.addRepoDialog.tab.clone}
+ >
+
+ {t("import.tab.clone")}
+
+
+
+
+ {tab === "providers" ? (
+
+ ) : tab === "local" ? (
+
+ ) : (
+
+ )}
+
+ >
+ }
+ />
+ );
+}
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/ClonePanel.stories.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/ClonePanel.stories.tsx
new file mode 100644
index 0000000..7f3dbff
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/ClonePanel.stories.tsx
@@ -0,0 +1,26 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import ClonePanel from "@/components/molecules/modals/AddRepoModal/panels/ClonePanel";
+
+const Stage = styled(Box)(({ theme }) => ({ width: 560, padding: theme.spacing(2) }));
+
+const meta: Meta = {
+ title: "Molecules/Modals/AddRepoModal/Panels/ClonePanel",
+ component: ClonePanel,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = { args: { onClose: () => {} } };
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/ClonePanel.test.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/ClonePanel.test.tsx
new file mode 100644
index 0000000..019644a
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/ClonePanel.test.tsx
@@ -0,0 +1,13 @@
+import { describe, expect, it } from "vitest";
+
+import ClonePanel from "@/components/molecules/modals/AddRepoModal/panels/ClonePanel";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("ClonePanel", () => {
+ it("renders the URL and destination inputs", () => {
+ const { getByTestId } = renderWithProviders( {}} />);
+ expect(getByTestId(TEST_IDS.addRepoDialog.url)).toBeInTheDocument();
+ expect(getByTestId(TEST_IDS.addRepoDialog.dest)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/index.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/index.tsx
new file mode 100644
index 0000000..56233fa
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ClonePanel/index.tsx
@@ -0,0 +1,132 @@
+import { type FormEvent, useState } from "react";
+
+import { useTranslation } from "react-i18next";
+
+import { ArrowDown, FolderOpen } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ BrowseBtn,
+ Field,
+ Footer,
+ FormBody,
+ FormFields,
+ Hint,
+ Input,
+ Label,
+ PathFieldRow,
+ PrimaryBtn,
+ SecondaryBtn,
+} from "@/components/molecules/modals/AddRepoModal/panels/_shared";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { isTauri } from "@/lib/tauri";
+import { pickFolder } from "@/lib/utils/pickFolder.utils";
+import { gitCloneUrl, loadRepos } from "@/store/actions/repos.actions";
+import { useAppDispatch } from "@/store/hooks";
+
+export function ClonePanel({ onClose }: { onClose: () => void }) {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const [url, setUrl] = useState("");
+ const [destination, setDestination] = useState("");
+ const [subFolder, setSubFolder] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const canSubmit = Boolean(url.trim() && destination.trim()) && !busy;
+
+ const onBrowse = async () => {
+ const picked = await pickFolder(destination.trim() || undefined);
+ if (picked) setDestination(picked);
+ };
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!canSubmit) return;
+ setBusy(true);
+ try {
+ const repo = await dispatch(
+ gitCloneUrl({
+ url: url.trim(),
+ destination: destination.trim(),
+ subFolder: subFolder.trim() || null,
+ }),
+ ).unwrap();
+ toast.success(`Cloned ${repo.name}`);
+ void dispatch(loadRepos());
+ setUrl("");
+ setDestination("");
+ setSubFolder("");
+ onClose();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ toast.error(`Clone failed: ${msg}`);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ {t("import.field.url")}
+ setUrl(e.target.value)}
+ placeholder="https://github.com/owner/repo.git"
+ autoFocus
+ data-testid={TEST_IDS.addRepoDialog.url}
+ />
+ {t("import.url_hint")}
+
+
+ {t("import.field.dest")}
+
+ setDestination(e.target.value)}
+ placeholder="/Users/you/Code"
+ data-testid={TEST_IDS.addRepoDialog.dest}
+ />
+ void onBrowse()}
+ disabled={!isTauri()}
+ data-testid={TEST_IDS.addRepoDialog.destBrowse}
+ >
+
+ {t("actions.browse")}
+
+
+ {t("import.field.dest_hint")}
+
+
+ {t("import.field.sub")}
+ setSubFolder(e.target.value)}
+ placeholder="e.g. my-fork"
+ data-testid={TEST_IDS.addRepoDialog.sub}
+ />
+
+
+
+
+ {t("actions.cancel")}
+
+
+
+ {busy ? t("actions.cloning") : t("actions.clone")}
+
+
+
+ );
+}
+
+export default ClonePanel;
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/LocalPanel.stories.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/LocalPanel.stories.tsx
new file mode 100644
index 0000000..ce92a2f
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/LocalPanel.stories.tsx
@@ -0,0 +1,26 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import LocalPanel from "@/components/molecules/modals/AddRepoModal/panels/LocalPanel";
+
+const Stage = styled(Box)(({ theme }) => ({ width: 560, padding: theme.spacing(2) }));
+
+const meta: Meta = {
+ title: "Molecules/Modals/AddRepoModal/Panels/LocalPanel",
+ component: LocalPanel,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = { args: { onClose: () => {} } };
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/LocalPanel.test.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/LocalPanel.test.tsx
new file mode 100644
index 0000000..de60b79
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/LocalPanel.test.tsx
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+
+import LocalPanel from "@/components/molecules/modals/AddRepoModal/panels/LocalPanel";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("LocalPanel", () => {
+ it("renders the path input", () => {
+ const { getByTestId } = renderWithProviders( {}} />);
+ expect(getByTestId(TEST_IDS.addRepoDialog.path)).toBeInTheDocument();
+ });
+});
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/index.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/index.tsx
new file mode 100644
index 0000000..d853e9f
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/LocalPanel/index.tsx
@@ -0,0 +1,101 @@
+import { type FormEvent, useState } from "react";
+
+import { useTranslation } from "react-i18next";
+
+import { ArrowDown, FolderOpen } from "lucide-react";
+import { toast } from "sonner";
+
+import {
+ BrowseBtn,
+ Field,
+ Footer,
+ FormBody,
+ FormFields,
+ Hint,
+ Input,
+ Label,
+ PathFieldRow,
+ PrimaryBtn,
+ SecondaryBtn,
+} from "@/components/molecules/modals/AddRepoModal/panels/_shared";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { isTauri } from "@/lib/tauri";
+import { pickFolder } from "@/lib/utils/pickFolder.utils";
+import { addRepo } from "@/store/actions/repos.actions";
+import { useAppDispatch } from "@/store/hooks";
+
+export function LocalPanel({ onClose }: { onClose: () => void }) {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const [path, setPath] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const onBrowse = async () => {
+ const picked = await pickFolder(path.trim() || undefined);
+ if (picked) setPath(picked);
+ };
+
+ const onSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ const trimmed = path.trim();
+ if (!trimmed) return;
+ setBusy(true);
+ try {
+ const repo = await dispatch(addRepo({ path: trimmed })).unwrap();
+ toast.success(`Added ${repo.name}`);
+ setPath("");
+ onClose();
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ toast.error(`Could not add repository: ${msg}`);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ {t("import.field.path")}
+
+ setPath(e.target.value)}
+ placeholder="/Users/you/Code/my-repo"
+ autoFocus
+ data-testid={TEST_IDS.addRepoDialog.path}
+ />
+ void onBrowse()}
+ disabled={!isTauri()}
+ data-testid={TEST_IDS.addRepoDialog.pathBrowse}
+ >
+
+ {t("actions.browse")}
+
+
+ {t("import.field.path_hint")}
+
+
+
+
+ {t("actions.cancel")}
+
+
+
+ {busy ? t("actions.adding") : t("actions.add")}
+
+
+
+ );
+}
+
+export default LocalPanel;
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.stories.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.stories.tsx
new file mode 100644
index 0000000..20c62fa
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.stories.tsx
@@ -0,0 +1,28 @@
+import { Box } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import ProvidersPanel from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel";
+
+const Stage = styled(Box)({ width: 880, height: 520 });
+
+const meta: Meta = {
+ title: "Molecules/Modals/AddRepoModal/Panels/ProvidersPanel",
+ component: ProvidersPanel,
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const NoConnections: Story = {
+ args: { connectedProviders: [], onClose: () => {} },
+};
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.styles.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.styles.tsx
new file mode 100644
index 0000000..9847816
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.styles.tsx
@@ -0,0 +1,307 @@
+import { Box, Typography } from "@mui/material";
+import { styled } from "@mui/material/styles";
+
+import { RefreshCw } from "lucide-react";
+
+export const ProvidersGrid = styled(Box)({
+ display: "grid",
+ gridTemplateColumns: "240px 1fr",
+ height: "100%",
+}) as typeof Box;
+
+export const ProvidersAside = styled(Box)(({ theme }) => ({
+ borderRight: `1px solid ${theme.palette.divider}`,
+ padding: 10,
+ overflowY: "auto",
+ display: "flex",
+ flexDirection: "column",
+ gap: 2,
+})) as typeof Box;
+
+export const ProviderGroup = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ gap: 2,
+}) as typeof Box;
+
+export const AsideHeading = styled(Typography)(({ theme }) => ({
+ padding: "6px 10px",
+ fontSize: 10,
+ fontWeight: 700,
+ textTransform: "uppercase",
+ letterSpacing: "0.06em",
+ color: theme.palette.text.information,
+})) as typeof Typography;
+
+// eslint-disable-next-line no-restricted-syntax -- native element required for accessibility
+export const AsideItem = styled("button", {
+ shouldForwardProp: (p) => p !== "active" && p !== "indent",
+})<{ active: boolean; indent?: boolean }>(({ theme, active, indent }) => ({
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ width: "100%",
+ textAlign: "left",
+ padding: indent ? "5px 10px 5px 28px" : "7px 10px",
+ borderRadius: 8,
+ border: 0,
+ background: active
+ ? `color-mix(in srgb, ${theme.palette.primary.main} 14%, transparent)`
+ : "transparent",
+ color: active ? theme.palette.primary.dark : theme.palette.text.primary,
+ fontFamily: "inherit",
+ fontSize: 12.5,
+ fontWeight: active ? 600 : 500,
+ cursor: "pointer",
+ "&:hover": {
+ backgroundColor: active
+ ? `color-mix(in srgb, ${theme.palette.primary.main} 14%, transparent)`
+ : theme.palette.surface.interface.active,
+ },
+}));
+
+export const AsideIcon = styled(Box)({
+ width: 18,
+ height: 18,
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+}) as typeof Box;
+
+export const ProvidersMain = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ minHeight: 0,
+ overflow: "hidden",
+}) as typeof Box;
+
+export const SearchBar = styled(Box)(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ gap: 10,
+ padding: "10px 16px",
+ borderBottom: `1px solid ${theme.palette.divider}`,
+})) as typeof Box;
+
+export const SelectedPill = styled(Typography)(({ theme }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+ padding: "3px 9px",
+ borderRadius: 100,
+ backgroundColor: theme.palette.primary.main,
+ color: theme.palette.primary.contrastText,
+ fontSize: 10.5,
+ fontWeight: 700,
+})) as typeof Typography;
+
+export const RepoListScroll = styled(Box)({
+ flex: 1,
+ minHeight: 0,
+ overflowY: "auto",
+}) as typeof Box;
+
+export const SectionHeaderBar = styled(Box)(({ theme }) => ({
+ position: "sticky",
+ top: 0,
+ zIndex: 1,
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "8px 16px",
+ fontSize: 10,
+ fontWeight: 700,
+ textTransform: "uppercase",
+ letterSpacing: "0.06em",
+ color: theme.palette.text.information,
+ backgroundColor: theme.palette.background.default,
+ borderBottom: `1px solid ${theme.palette.divider}`,
+})) as typeof Box;
+
+// eslint-disable-next-line no-restricted-syntax -- semantic wraps a checkbox + clickable label area
+export const RepoRow = styled("label", {
+ shouldForwardProp: (p) => p !== "selected" && p !== "disabled",
+})<{ selected: boolean; disabled?: boolean }>(({ theme, selected, disabled }) => ({
+ position: "relative",
+ display: "flex",
+ alignItems: "center",
+ gap: 12,
+ padding: "10px 16px",
+ borderBottom: `1px solid ${theme.palette.divider}`,
+ cursor: disabled ? "not-allowed" : "pointer",
+ opacity: disabled ? 0.55 : 1,
+ backgroundColor: selected
+ ? `color-mix(in srgb, ${theme.palette.primary.main} 10%, transparent)`
+ : "transparent",
+ transition: "background-color 0.12s ease",
+ "&:hover": {
+ backgroundColor: selected
+ ? `color-mix(in srgb, ${theme.palette.primary.main} 12%, transparent)`
+ : disabled
+ ? "transparent"
+ : theme.palette.surface.interface.active,
+ },
+ ...(selected
+ ? {
+ "&::before": {
+ content: '""',
+ position: "absolute",
+ left: 0,
+ top: 0,
+ bottom: 0,
+ width: 2,
+ backgroundColor: theme.palette.primary.main,
+ },
+ }
+ : {}),
+}));
+
+export const RepoBody = styled(Box)({
+ flex: 1,
+ minWidth: 0,
+}) as typeof Box;
+
+export const RepoTitleRow = styled(Box)({
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+}) as typeof Box;
+
+export const RepoTitle = styled(Typography)(({ theme }) => ({
+ fontSize: 13,
+ fontWeight: 600,
+ color: theme.palette.text.primary,
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+})) as typeof Typography;
+
+export const RepoDesc = styled(Box)(({ theme }) => ({
+ fontSize: 11.5,
+ color: theme.palette.text.information,
+ marginTop: 2,
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+})) as typeof Box;
+
+export const RepoMeta = styled(Box)(({ theme }) => ({
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ marginTop: 4,
+ fontSize: 10.5,
+ color: theme.palette.text.informationLight,
+})) as typeof Box;
+
+export const LangChip = styled(Box)({
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+}) as typeof Box;
+
+export const LangDot = styled(Typography)(({ theme }) => ({
+ width: 8,
+ height: 8,
+ borderRadius: "50%",
+ backgroundColor: theme.palette.primary.main,
+})) as typeof Typography;
+
+// eslint-disable-next-line no-restricted-syntax -- generic styled element required for typed props
+export const MetaBadge = styled("span", {
+ shouldForwardProp: (p) => p !== "tone",
+})<{ tone: "neutral" | "success" }>(({ theme, tone }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 3,
+ padding: "1px 6px",
+ borderRadius: 8,
+ fontSize: 10,
+ fontWeight: 600,
+ backgroundColor:
+ tone === "success"
+ ? `color-mix(in srgb, ${theme.palette.success.main} 14%, transparent)`
+ : theme.palette.surface.interface.backElevation,
+ color: tone === "success" ? theme.palette.success.main : theme.palette.text.information,
+}));
+
+export const EmptyState = styled(Box)(({ theme }) => ({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 8,
+ padding: "60px 20px",
+ textAlign: "center",
+ fontSize: 12,
+ color: theme.palette.text.information,
+})) as typeof Box;
+
+export const ConnectFirst = styled(Box)({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: 14,
+ padding: 60,
+ textAlign: "center",
+ height: "100%",
+}) as typeof Box;
+
+export const ConnectIcon = styled(Box)(({ theme }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: 56,
+ height: 56,
+ borderRadius: "50%",
+ backgroundColor: `color-mix(in srgb, ${theme.palette.primary.main} 18%, transparent)`,
+ color: theme.palette.primary.main,
+})) as typeof Box;
+
+export const ConnectBrands = styled(Box)({
+ display: "flex",
+ alignItems: "center",
+ gap: 14,
+}) as typeof Box;
+
+export const ConnectText = styled(Typography)(({ theme }) => ({
+ maxWidth: 360,
+ fontSize: 13,
+ color: theme.palette.text.information,
+})) as typeof Typography;
+
+export const StatusInline = styled(Typography)(({ theme }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 4,
+ fontSize: 11,
+ color: theme.palette.text.information,
+})) as typeof Typography;
+
+export const Spin = styled(RefreshCw)({
+ animation: "addrepo-spin 0.9s linear infinite",
+ "@keyframes addrepo-spin": {
+ to: { transform: "rotate(360deg)" },
+ },
+});
+
+// eslint-disable-next-line no-restricted-syntax -- generic styled element required for typed props
+export const Badge = styled("span", {
+ shouldForwardProp: (p) => p !== "active",
+})<{ active?: boolean }>(({ theme, active }) => ({
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ minWidth: 16,
+ height: 16,
+ padding: "0 5px",
+ borderRadius: 100,
+ fontSize: 10,
+ fontWeight: 700,
+ backgroundColor: active
+ ? theme.palette.primary.main
+ : theme.palette.surface.interface.backElevation,
+ color: active ? theme.palette.primary.contrastText : theme.palette.text.information,
+}));
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.test.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.test.tsx
new file mode 100644
index 0000000..4a4c453
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.test.tsx
@@ -0,0 +1,18 @@
+import { Box } from "@mui/material";
+
+import { describe, expect, it } from "vitest";
+
+import ProvidersPanel from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel";
+import { COMPONENT_TEST_IDS } from "@/lib/constants/componentTests.constants";
+import { renderWithProviders } from "@/test/utils";
+
+describe("ProvidersPanel", () => {
+ it("renders inside its wrapper when no providers are connected", () => {
+ const { getByTestId } = renderWithProviders(
+
+ {}} />
+ ,
+ );
+ expect(getByTestId(COMPONENT_TEST_IDS.molecules.providersPanel.wrap).firstChild).not.toBeNull();
+ });
+});
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/index.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/index.tsx
new file mode 100644
index 0000000..3cbab0d
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/index.tsx
@@ -0,0 +1,399 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { useTranslation } from "react-i18next";
+
+import { Box } from "@mui/material";
+
+import { type RemoteRepository } from "@recrest/shared";
+
+import { ArrowDown, Check, ChevronRight, FolderGit2, FolderOpen, Inbox } from "lucide-react";
+import { toast } from "sonner";
+
+import BrandIcon from "@/assets/icons/BrandIcon";
+import GeneralAvatar from "@/components/atoms/avatars/GeneralAvatar";
+import GeneralSearchInput from "@/components/atoms/inputs/GeneralSearchInput";
+import {
+ AsideHeading,
+ AsideIcon,
+ AsideItem,
+ Badge,
+ ConnectBrands,
+ ConnectFirst,
+ ConnectIcon,
+ ConnectText,
+ EmptyState,
+ ProviderGroup,
+ ProvidersAside,
+ ProvidersGrid,
+ ProvidersMain,
+ RepoListScroll,
+ SearchBar,
+ SectionHeaderBar,
+ SelectedPill,
+ Spin,
+} from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.styles";
+import RepoRowCard from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard";
+import {
+ BrowseBtn,
+ Footer,
+ Input,
+ PrimaryBtn,
+ SecondaryBtn,
+} from "@/components/molecules/modals/AddRepoModal/panels/_shared";
+import { I18nNamespace } from "@/lib/constants/i18n.constants";
+import { PROVIDER_NAMES, Provider, type ProviderId } from "@/lib/constants/providers.constants";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { isTauri } from "@/lib/tauri";
+import { hashCode } from "@/lib/utils/hash.utils";
+import { pickFolder } from "@/lib/utils/pickFolder.utils";
+import {
+ cloneRemoteRepositoriesBulk,
+ fetchRemoteOrganizations,
+ fetchRemoteRepositories,
+} from "@/store/actions/remoteImport.actions";
+import { loadRepos } from "@/store/actions/repos.actions";
+import { useAppDispatch, useAppSelector } from "@/store/hooks";
+import { keyFor } from "@/store/types/remoteImport.types";
+
+interface ProvidersPanelProps {
+ connectedProviders: ProviderId[];
+ onClose: () => void;
+}
+
+// Stable two-stop gradients for org chips when the provider didn't supply
+// an `avatarUrl`. Matches the look of `RepoAvatar`/`AuthorAvatar`.
+const ORG_GRADIENTS: ReadonlyArray = [
+ ["#4f8cff", "#7b2ff7"],
+ ["#ff7a59", "#d6336c"],
+ ["#10b981", "#0ea5a3"],
+ ["#f59e0b", "#ef4444"],
+ ["#06b6d4", "#3b82f6"],
+ ["#ec4899", "#8b5cf6"],
+];
+function gradientForOrg(id: string): readonly [string, string] {
+ const idx = hashCode(id.toLowerCase()) % ORG_GRADIENTS.length;
+ return ORG_GRADIENTS[idx] ?? ORG_GRADIENTS[0]!;
+}
+
+export function ProvidersPanel({ connectedProviders, onClose }: ProvidersPanelProps) {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const [activeProvider, setActiveProvider] = useState(
+ connectedProviders[0] ?? null,
+ );
+ const [activeOrg, setActiveOrg] = useState(null);
+ const [query, setQuery] = useState("");
+ const [selected, setSelected] = useState>(new Set());
+ const [destination, setDestination] = useState("");
+ const [cloning, setCloning] = useState(false);
+
+ useEffect(() => {
+ if (!activeProvider && connectedProviders[0]) {
+ setActiveProvider(connectedProviders[0]);
+ }
+ }, [connectedProviders, activeProvider]);
+
+ useEffect(() => {
+ if (!activeProvider) return;
+ void dispatch(fetchRemoteOrganizations(activeProvider));
+ void dispatch(fetchRemoteRepositories({ providerId: activeProvider, orgSlug: activeOrg }));
+ }, [dispatch, activeProvider, activeOrg]);
+
+ const orgs = useAppSelector((s) =>
+ activeProvider ? (s.remoteImport.organizations[activeProvider] ?? []) : [],
+ );
+ const listingKey = activeProvider ? keyFor(activeProvider, activeOrg) : null;
+ const listing = useAppSelector((s) =>
+ listingKey ? s.remoteImport.listings[listingKey] : undefined,
+ );
+ const loading = useAppSelector((s) =>
+ listingKey ? (s.remoteImport.loading[listingKey] ?? false) : false,
+ );
+ const progress = useAppSelector((s) => s.remoteImport.cloneProgress);
+
+ const { available, added } = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ const all = listing?.repositories ?? [];
+ const matched = q
+ ? all.filter(
+ (r) =>
+ r.fullName.toLowerCase().includes(q) ||
+ (r.description?.toLowerCase().includes(q) ?? false) ||
+ r.ownerLogin.toLowerCase().includes(q),
+ )
+ : all;
+ const byRecent = (a: RemoteRepository, b: RemoteRepository) => {
+ const aKey = a.pushedAt ?? a.updatedAt ?? "";
+ const bKey = b.pushedAt ?? b.updatedAt ?? "";
+ return bKey.localeCompare(aKey);
+ };
+ const localMatches = listing?.localMatches ?? {};
+ const sorted = [...matched].sort(byRecent);
+ const av: RemoteRepository[] = [];
+ const ad: RemoteRepository[] = [];
+ for (const r of sorted) {
+ if (localMatches[r.id]) ad.push(r);
+ else av.push(r);
+ }
+ return { available: av, added: ad };
+ }, [listing, query]);
+
+ const toggle = useCallback((id: string) => {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ }, []);
+
+ const onBrowseDestination = async () => {
+ const picked = await pickFolder(destination.trim() || undefined);
+ if (picked) setDestination(picked);
+ };
+
+ const onImport = async () => {
+ if (!destination.trim()) {
+ toast.error(t("import.pick_dest"));
+ return;
+ }
+ if (selected.size === 0 || !listing) return;
+ const requests = (listing.repositories ?? [])
+ .filter((r) => selected.has(r.id) && !listing.localMatches[r.id])
+ .map((r) => ({
+ providerId: r.providerId,
+ remoteRepoId: r.id,
+ cloneUrl: r.cloneUrlHttps,
+ destination: destination.trim(),
+ subFolder: r.name,
+ useSsh: false,
+ sshUrl: r.cloneUrlSsh,
+ }));
+ if (requests.length === 0) return;
+
+ setCloning(true);
+ try {
+ const outcomes = await dispatch(cloneRemoteRepositoriesBulk(requests)).unwrap();
+ const ok = outcomes.filter((o) => o.ok).length;
+ const fail = outcomes.length - ok;
+ if (ok > 0) {
+ toast.success(`Cloned ${ok} ${ok === 1 ? "repository" : "repositories"}`);
+ void dispatch(loadRepos());
+ }
+ if (fail > 0) {
+ const firstErr = outcomes.find((o) => !o.ok)?.error;
+ toast.error(firstErr ?? "Some clones failed");
+ } else {
+ onClose();
+ }
+ setSelected(new Set());
+ } catch (err) {
+ toast.error(String((err as Error)?.message ?? err));
+ } finally {
+ setCloning(false);
+ }
+ };
+
+ if (connectedProviders.length === 0) {
+ return (
+
+
+
+
+
+
+
+
+
+ {t("import.connect_first")}
+
+ );
+ }
+
+ const canImport = !cloning && selected.size > 0 && Boolean(destination.trim());
+ const totalCount = (listing?.repositories ?? []).length;
+
+ return (
+
+
+ {t("import.providers_heading")}
+ {connectedProviders.map((id) => (
+
+ {
+ setActiveProvider(id);
+ setActiveOrg(null);
+ setSelected(new Set());
+ }}
+ >
+
+
+
+
+ {PROVIDER_NAMES[id]}
+
+
+
+ {activeProvider === id &&
+ orgs.map((org) => {
+ const [g1, g2] = gradientForOrg(org.id);
+ const letter = (org.displayName.trim().charAt(0) || "?").toUpperCase();
+ return (
+ {
+ setActiveOrg(org.slug);
+ setSelected(new Set());
+ }}
+ >
+
+
+
+
+ {org.displayName}
+
+
+ );
+ })}
+
+ ))}
+
+
+
+
+
+ {selected.size > 0 && (
+
+ {selected.size} selected
+
+ )}
+
+
+
+ {loading && totalCount === 0 ? (
+
+
+ {t("import.loading")}
+
+ ) : totalCount === 0 ? (
+
+
+ {t("import.no_results")}
+
+ ) : (
+ <>
+ {available.length > 0 && (
+ <>
+
+ {t("import.group.available")}
+ {available.length}
+
+ {available.map((r) => (
+ toggle(r.id)}
+ progress={progress[r.id]?.stage}
+ />
+ ))}
+ >
+ )}
+ {added.length > 0 && (
+ <>
+
+ {t("import.group.added")}
+ {added.length}
+
+ {added.map((r) => (
+ toggle(r.id)}
+ progress={progress[r.id]?.stage}
+ />
+ ))}
+ >
+ )}
+ >
+ )}
+
+
+
+
+
+ );
+}
+
+export default ProvidersPanel;
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/RepoRowCard.stories.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/RepoRowCard.stories.tsx
new file mode 100644
index 0000000..167e61f
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/RepoRowCard.stories.tsx
@@ -0,0 +1,48 @@
+import type { RemoteRepository } from "@recrest/shared";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import RepoRowCard from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard";
+
+const sampleRepo: RemoteRepository = {
+ id: "1",
+ providerId: "github",
+ fullName: "recrest/example",
+ name: "example",
+ ownerLogin: "recrest",
+ description: "Example repository used in stories.",
+ language: "TypeScript",
+ isPrivate: false,
+ isFork: false,
+ isArchived: false,
+ defaultBranch: "main",
+ cloneUrlHttps: "https://github.com/recrest/example.git",
+ cloneUrlSsh: "git@github.com:recrest/example.git",
+ updatedAt: "2025-02-15T10:00:00Z",
+ pushedAt: "2025-02-15T10:00:00Z",
+ htmlUrl: "https://github.com/recrest/example",
+ sizeKb: 1024,
+ ownerAvatarUrl: null,
+};
+
+const meta = {
+ title: "Molecules/Modals/AddRepoModal/Panels/ProvidersPanel/Parts/RepoRowCard",
+ component: RepoRowCard,
+ args: {
+ repo: sampleRepo,
+ selected: false,
+ alreadyLocal: false,
+ onToggle: () => {},
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Selected: Story = { args: { selected: true } };
+export const AlreadyLocal: Story = { args: { alreadyLocal: true } };
+export const Cloning: Story = { args: { progress: "cloning" } };
+export const Done: Story = { args: { progress: "done" } };
+export const Error: Story = { args: { progress: "error" } };
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/RepoRowCard.test.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/RepoRowCard.test.tsx
new file mode 100644
index 0000000..14c76d2
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/RepoRowCard.test.tsx
@@ -0,0 +1,41 @@
+import type { RemoteRepository } from "@recrest/shared";
+
+import { fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import RepoRowCard from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+import { renderWithTheme } from "@/test/utils";
+
+const sampleRepo: RemoteRepository = {
+ id: "1",
+ providerId: "github",
+ fullName: "recrest/example",
+ name: "example",
+ ownerLogin: "recrest",
+ description: "Example repository used in tests.",
+ language: "TypeScript",
+ isPrivate: false,
+ isFork: false,
+ isArchived: false,
+ defaultBranch: "main",
+ cloneUrlHttps: "https://github.com/recrest/example.git",
+ cloneUrlSsh: "git@github.com:recrest/example.git",
+ updatedAt: "2025-02-15T10:00:00Z",
+ pushedAt: "2025-02-15T10:00:00Z",
+ htmlUrl: "https://github.com/recrest/example",
+ sizeKb: 1024,
+ ownerAvatarUrl: null,
+};
+
+describe("RepoRowCard", () => {
+ it("toggles selection when the checkbox is clicked", () => {
+ const onToggle = vi.fn();
+ const { getByTestId } = renderWithTheme(
+ ,
+ );
+ const checkbox = getByTestId(TEST_IDS.addRepoDialog.rowCheckbox);
+ fireEvent.click(checkbox.querySelector("input") ?? checkbox);
+ expect(onToggle).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/index.tsx b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/index.tsx
new file mode 100644
index 0000000..129d451
--- /dev/null
+++ b/app/src/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/parts/RepoRowCard/index.tsx
@@ -0,0 +1,110 @@
+import { type ReactNode } from "react";
+
+import { Box, Checkbox } from "@mui/material";
+import { styled, useTheme } from "@mui/material/styles";
+
+import { type RemoteRepository } from "@recrest/shared";
+
+import { Check, X } from "lucide-react";
+
+import {
+ LangChip,
+ LangDot,
+ MetaBadge,
+ RepoBody,
+ RepoDesc,
+ RepoMeta,
+ RepoRow,
+ RepoTitle,
+ RepoTitleRow,
+ Spin,
+ StatusInline,
+} from "@/components/molecules/modals/AddRepoModal/panels/ProvidersPanel/ProvidersPanel.styles";
+import { TEST_IDS } from "@/lib/constants/testIds.constants";
+
+const FlushCheckbox = styled(Checkbox)({ padding: 0 });
+
+interface RepoRowCardProps {
+ repo: RemoteRepository;
+ selected: boolean;
+ alreadyLocal: boolean;
+ onToggle: () => void;
+ progress?: string;
+}
+
+export function RepoRowCard({
+ repo,
+ selected,
+ alreadyLocal,
+ onToggle,
+ progress,
+}: RepoRowCardProps): ReactNode {
+ const theme = useTheme();
+ return (
+
+ !alreadyLocal && onToggle()}
+ data-testid={TEST_IDS.addRepoDialog.rowCheckbox}
+ />
+
+
+
+ {repo.fullName}
+
+ {repo.isPrivate && private }
+ {repo.isFork && fork }
+ {repo.isArchived && archived }
+ {alreadyLocal && (
+
+