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 ` 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(); - expect(screen.getByRole("button", { name: "Klick mich" })).toBeInTheDocument(); - }); - - it("wendet die outline-Variante an", () => { - render(); - expect(screen.getByRole("button").className).toContain("border-input"); - }); - - it("ruft onClick bei Klick auf", async () => { - const user = userEvent.setup(); - const handler = vi.fn(); - render(); - await user.click(screen.getByRole("button")); - expect(handler).toHaveBeenCalledTimes(1); - }); - - it("ist disabled während loading", () => { - render(); - expect(screen.getByRole("button")).toBeDisabled(); - }); - - it("ruft onClick nicht auf wenn disabled", async () => { - const user = userEvent.setup(); - const handler = vi.fn(); - render( - , - ); - 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} - )} - - - - - - - - - ); -} 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(); - expect(screen.getByText("Benutzername")).toBeInTheDocument(); - }); - - it("wendet Basisklassen an", () => { - render(); - expect(screen.getByText("Email").className).toContain("text-sm"); - expect(screen.getByText("Email").className).toContain("font-medium"); - }); - - it("merged zusätzliche className", () => { - render(); - expect(screen.getByText("X")).toHaveClass("custom-label"); - }); - - it("unterstützt htmlFor-Prop", () => { - render(); - 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 - ); -} 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 ( -
-
- - {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 && ( -