feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
54
frontend/src/shared/ui/Button.tsx
Normal file
54
frontend/src/shared/ui/Button.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
/** Button — the primary action control of the design system (LD). */
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
export type ButtonSize = "sm" | "md";
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Visual emphasis. Defaults to `secondary`. */
|
||||
variant?: ButtonVariant;
|
||||
/** Control height. Defaults to `md`. */
|
||||
size?: ButtonSize;
|
||||
/** When true, shows a spinner and disables the button. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-md font-medium " +
|
||||
"transition-colors select-none disabled:opacity-50 disabled:cursor-not-allowed " +
|
||||
"focus-visible:outline-none";
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: "h-7 px-2.5 text-xs",
|
||||
md: "h-9 px-3.5 text-sm",
|
||||
};
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "bg-primary text-on-primary hover:bg-primary-hover",
|
||||
secondary: "bg-raised text-content border border-border hover:border-border-strong",
|
||||
ghost: "text-muted hover:text-content hover:bg-raised",
|
||||
danger: "bg-danger text-white hover:brightness-110",
|
||||
};
|
||||
|
||||
/** A themeable button with variants, sizes and a loading state. */
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = "secondary", size = "md", loading = false, disabled, className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={rest.type ?? "button"}
|
||||
disabled={disabled || loading}
|
||||
className={cn(BASE, SIZES[size], VARIANTS[variant], className)}
|
||||
{...rest}
|
||||
>
|
||||
{loading && <Spinner size={size === "sm" ? 12 : 14} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
53
frontend/src/shared/ui/Field.tsx
Normal file
53
frontend/src/shared/ui/Field.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
/** Field — a labelled wrapper for form controls with hint/error text (LD). */
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface FieldProps {
|
||||
/** Visible label text. */
|
||||
label: string;
|
||||
/** Optional helper text shown under the control. */
|
||||
hint?: string;
|
||||
/** Optional error message; when set, it replaces the hint and is announced. */
|
||||
error?: string;
|
||||
/** Visually hide the label (kept for screen readers). */
|
||||
hideLabel?: boolean;
|
||||
className?: string;
|
||||
/**
|
||||
* Render-prop receiving the generated `id` so the control can wire
|
||||
* `id`/`aria-describedby` to the label and message.
|
||||
*/
|
||||
children: (props: { id: string; describedBy?: string }) => React.ReactNode;
|
||||
}
|
||||
|
||||
/** Associates a label (and optional hint/error) with a single control. */
|
||||
export function Field({ label, hint, error, hideLabel, className, children }: FieldProps) {
|
||||
const id = useId();
|
||||
const msgId = `${id}-msg`;
|
||||
const message = error ?? hint;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-xs font-medium text-muted",
|
||||
hideLabel && "sr-only",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children({ id, describedBy: message ? msgId : undefined })}
|
||||
{message && (
|
||||
<p
|
||||
id={msgId}
|
||||
role={error ? "alert" : undefined}
|
||||
className={cn("text-xs", error ? "text-danger" : "text-faint")}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
frontend/src/shared/ui/IconButton.tsx
Normal file
42
frontend/src/shared/ui/IconButton.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
/** IconButton — a square, icon-only button (close ×, toolbar actions) (LD). */
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export type IconButtonSize = "sm" | "md";
|
||||
|
||||
export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Required accessible name (icon-only buttons have no text). */
|
||||
"aria-label": string;
|
||||
/** Control size. Defaults to `md`. */
|
||||
size?: IconButtonSize;
|
||||
}
|
||||
|
||||
const SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "h-6 w-6 text-xs",
|
||||
md: "h-8 w-8 text-sm",
|
||||
};
|
||||
|
||||
/** A compact, icon-only button styled as a ghost control. */
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{ size = "md", className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={rest.type ?? "button"}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-muted transition-colors",
|
||||
"hover:bg-raised hover:text-content focus-visible:outline-none",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
31
frontend/src/shared/ui/Input.tsx
Normal file
31
frontend/src/shared/ui/Input.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
/** Input — a single-line text field (LD). */
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
/** When true, paints the danger border (paired with a Field error). */
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
/** A themed text input; width is controlled by the parent (defaults to full). */
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ invalid = false, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
"h-9 w-full rounded-md bg-raised px-3 text-sm text-content",
|
||||
"border placeholder:text-faint outline-none transition-colors",
|
||||
invalid ? "border-danger" : "border-border focus:border-primary",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
35
frontend/src/shared/ui/Panel.tsx
Normal file
35
frontend/src/shared/ui/Panel.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
/** Panel — a bordered surface/card, optionally with a header (LD). */
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface PanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
||||
/** Optional header title. */
|
||||
title?: React.ReactNode;
|
||||
/** Optional content rendered on the right of the header (actions). */
|
||||
actions?: React.ReactNode;
|
||||
/** Remove the default body padding (e.g. to host a flush list/terminal). */
|
||||
flush?: boolean;
|
||||
}
|
||||
|
||||
/** A raised surface used to group related content. */
|
||||
export function Panel({ title, actions, flush, className, children, ...rest }: PanelProps) {
|
||||
const hasHeader = title != null || actions != null;
|
||||
return (
|
||||
<section
|
||||
className={cn("rounded-lg border border-border bg-surface", className)}
|
||||
{...rest}
|
||||
>
|
||||
{hasHeader && (
|
||||
<header className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5">
|
||||
{typeof title === "string" ? (
|
||||
<h3 className="text-sm font-semibold text-content">{title}</h3>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{actions}
|
||||
</header>
|
||||
)}
|
||||
<div className={cn(!flush && "p-4")}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
frontend/src/shared/ui/Spinner.tsx
Normal file
27
frontend/src/shared/ui/Spinner.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
/** Spinner — an indeterminate loading indicator (LD). */
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Diameter in pixels. Defaults to 16. */
|
||||
size?: number;
|
||||
/** Extra classes (e.g. a colour override). */
|
||||
className?: string;
|
||||
/** Accessible label; defaults to "Loading". */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A CSS-only spinning ring that inherits the current text colour. */
|
||||
export function Spinner({ size = 16, className, label = "Loading" }: SpinnerProps) {
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-block animate-spin rounded-full border-2 border-current border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
frontend/src/shared/ui/Tabs.tsx
Normal file
76
frontend/src/shared/ui/Tabs.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
/** Tabs — a horizontal, optionally closable tab bar (LD).
|
||||
*
|
||||
* Presentation-only and controlled: the parent owns the active id and the open
|
||||
* set. Used for the project tab bar (one tab per open project, ARCHITECTURE §10)
|
||||
* but generic enough for any tabbed surface.
|
||||
*/
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface TabItem {
|
||||
/** Stable id, returned by `onSelect`/`onClose`. */
|
||||
id: string;
|
||||
/** Visible label. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
/** Tabs to render, left to right. */
|
||||
items: TabItem[];
|
||||
/** Currently-active tab id. */
|
||||
value: string | null;
|
||||
/** Called with the id of the tab the user activates. */
|
||||
onSelect: (id: string) => void;
|
||||
/** When provided, each tab shows a close (×) control. */
|
||||
onClose?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** A themed tab strip with selection and optional per-tab close. */
|
||||
export function Tabs({ items, value, onSelect, onClose, className }: TabsProps) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn("flex flex-wrap items-stretch gap-1", className)}
|
||||
>
|
||||
{items.map((tab) => {
|
||||
const active = tab.id === value;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
|
||||
active
|
||||
? "border-border-strong bg-raised"
|
||||
: "border-transparent hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onSelect(tab.id)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-sm focus-visible:outline-none",
|
||||
active ? "font-semibold text-content" : "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{onClose && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
aria-label={`close ${tab.label}`}
|
||||
onClick={() => onClose(tab.id)}
|
||||
className="opacity-60 group-hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/src/shared/ui/Toolbar.tsx
Normal file
27
frontend/src/shared/ui/Toolbar.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
/** Toolbar — a thin horizontal action bar (LD). */
|
||||
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface ToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Push trailing children to the right with an auto margin. */
|
||||
justify?: "start" | "between" | "end";
|
||||
}
|
||||
|
||||
const JUSTIFY = {
|
||||
start: "justify-start",
|
||||
between: "justify-between",
|
||||
end: "justify-end",
|
||||
} as const;
|
||||
|
||||
/** A flex row for grouping buttons/controls with consistent spacing. */
|
||||
export function Toolbar({ justify = "start", className, children, ...rest }: ToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
className={cn("flex items-center gap-2", JUSTIFY[justify], className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
frontend/src/shared/ui/ui.test.tsx
Normal file
117
frontend/src/shared/ui/ui.test.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
/**
|
||||
* LD — design-system component tests. Pure UI, rendered with React Testing
|
||||
* Library; no backend, no gateways. We assert behaviour and accessibility
|
||||
* (roles, names, aria-*) rather than exact Tailwind classes, so the visual
|
||||
* design can evolve without breaking the suite.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { Button } from "./Button";
|
||||
import { Input } from "./Input";
|
||||
import { Field } from "./Field";
|
||||
import { Tabs } from "./Tabs";
|
||||
|
||||
describe("Button", () => {
|
||||
it("renders its label and fires onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Save</Button>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("is disabled and shows a spinner while loading", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button loading onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /Save/ }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(screen.getByRole("status")).toBeTruthy(); // spinner
|
||||
fireEvent.click(btn);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to type=button to avoid accidental form submits", () => {
|
||||
render(<Button>Go</Button>);
|
||||
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe(
|
||||
"button",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input", () => {
|
||||
it("flags aria-invalid when invalid", () => {
|
||||
render(<Input aria-label="email" invalid />);
|
||||
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBe("true");
|
||||
});
|
||||
|
||||
it("is not aria-invalid by default", () => {
|
||||
render(<Input aria-label="email" />);
|
||||
expect(screen.getByLabelText("email").getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Field", () => {
|
||||
it("associates the label with the control via id", () => {
|
||||
render(
|
||||
<Field label="Project name">
|
||||
{({ id, describedBy }) => (
|
||||
<input id={id} aria-describedby={describedBy} />
|
||||
)}
|
||||
</Field>,
|
||||
);
|
||||
// getByLabelText resolves the label→control association.
|
||||
expect(screen.getByLabelText("Project name")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("announces an error and wires aria-describedby", () => {
|
||||
render(
|
||||
<Field label="Root" error="must be absolute">
|
||||
{({ id, describedBy }) => (
|
||||
<input id={id} aria-describedby={describedBy} />
|
||||
)}
|
||||
</Field>,
|
||||
);
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toBe("must be absolute");
|
||||
const input = screen.getByLabelText("Root");
|
||||
expect(input.getAttribute("aria-describedby")).toBe(alert.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tabs", () => {
|
||||
const items = [
|
||||
{ id: "a", label: "alpha" },
|
||||
{ id: "b", label: "beta" },
|
||||
];
|
||||
|
||||
it("marks the active tab with aria-selected and reports selection", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<Tabs items={items} value="a" onSelect={onSelect} />);
|
||||
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs).toHaveLength(2);
|
||||
const [alpha, beta] = tabs;
|
||||
expect(alpha.getAttribute("aria-selected")).toBe("true");
|
||||
expect(beta.getAttribute("aria-selected")).toBe("false");
|
||||
|
||||
fireEvent.click(beta);
|
||||
expect(onSelect).toHaveBeenCalledWith("b");
|
||||
});
|
||||
|
||||
it("renders a close control per tab only when onClose is given", () => {
|
||||
const onClose = vi.fn();
|
||||
const { rerender } = render(
|
||||
<Tabs items={items} value="a" onSelect={() => {}} />,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "close alpha" })).toBeNull();
|
||||
|
||||
rerender(<Tabs items={items} value="a" onSelect={() => {}} onClose={onClose} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "close beta" }));
|
||||
expect(onClose).toHaveBeenCalledWith("b");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user