Skip to content

Commit 40c680d

Browse files
dcl10claude
andauthored
Feature/component library (#40)
* Add @heroicons/react dependency Required by the component library (issue #35) — all icon references use @heroicons/react/24/outline instead of inline SVG paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add cn utility and useViewport hook cn() merges class name strings, filtering falsy values. useViewport() returns { isMobile } tracking whether the viewport is ≤899px — used by TopBar and LeftRail to switch between desktop and mobile layouts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add core primitive components (closes #35 partial) Ports all primitive components from ui-kit-reference/components.jsx into typed TypeScript modules under components/core/: - Button: 4 variants (primary/secondary/ghost/destructive), 3 sizes, optional icon slots, CSS active:scale press animation - Tag: skill/discipline/neutral variants, Geist Mono, 2px radius - Chip: filter pill with active state (Nottingham Blue bg + Paper text) - StatusPill: coloured dot + label for open/pending/urgent/closed/accepted - Avatar: sm/md/lg/xl sizes, 4 UoN-palette gradients, 4px radius - MatchMeter: colour-coded fill bar (≥70% Forest Green, ≥45% Gold) - MatchBadge: compact Forest Green match% pill - TextInput / Textarea: Nottingham Blue focus ring, optional icon, helper text - Toast: fixed bottom-centre dismissible notification - Eyebrow: 11px uppercase, 0.14em letter-spacing - Hairline: 1px divider, horizontal or vertical All components are named exports; icon-only buttons carry aria-label. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add layout components (closes #35) Ports layout.jsx from the ui-kit-reference prototype into typed TypeScript modules under components/shared/: - TopBar: sticky 60px header; desktop shows logo, divider, Matchboard wordmark, DRS label, search, Post button, bell and avatar; mobile collapses to hamburger, logo, compact wordmark, Post icon and avatar - LeftRail: 220px sticky desktop sidebar with nav links and disciplines subsection; on mobile becomes a 280px slide-out drawer (220ms ease-out) with scrim overlay, closes on route change - PageHeader: eyebrow, title scaling 24→28→36px across breakpoints, optional description, mono meta row, and action slot - EmptyState: centred with circular icon background, title, description, optional CTA Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * implement first use of Button from the new comp lib --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 14ad06f commit 40c680d

23 files changed

Lines changed: 1107 additions & 11 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"use client";
2+
3+
import type { HTMLAttributes } from "react";
4+
5+
import { cn } from "@/lib/utils";
6+
7+
type AvatarSize = "sm" | "md" | "lg" | "xl";
8+
9+
interface AvatarProps extends HTMLAttributes<HTMLSpanElement> {
10+
/** 1–2 character initials displayed inside the avatar. */
11+
initials: string;
12+
/** Gradient index (0–3); each maps to a UoN supporting-palette pairing. */
13+
hue?: 0 | 1 | 2 | 3;
14+
/** Preset dimensions; defaults to `"md"`. */
15+
size?: AvatarSize;
16+
}
17+
18+
const AVATAR_GRADIENTS = [
19+
"linear-gradient(135deg, #DEB406, #B91C2E)", // Rebel's Gold → Jubilee Red
20+
"linear-gradient(135deg, #10263B, #37B4B0)", // Nottingham Blue → Trent Turquoise
21+
"linear-gradient(135deg, #792D85, #D7336C)", // Civic Purple → Pioneering Pink
22+
"linear-gradient(135deg, #005F36, #DEB406)", // Forest Green → Rebel's Gold
23+
] as const;
24+
25+
const SIZE_PX: Record<AvatarSize, number> = {
26+
sm: 28,
27+
md: 40,
28+
lg: 56,
29+
xl: 72,
30+
};
31+
32+
const FONT_SIZE: Record<AvatarSize, string> = {
33+
sm: "11px",
34+
md: "14px",
35+
lg: "18px",
36+
xl: "22px",
37+
};
38+
39+
/**
40+
* User avatar with a UoN-palette gradient background and Paper-coloured initials.
41+
* @param initials - 1–2 character initials to display
42+
* @param hue - Gradient variant (0–3); defaults to `0`
43+
* @param size - Preset dimension; defaults to `"md"`
44+
*/
45+
export function Avatar({
46+
initials,
47+
hue = 0,
48+
size = "md",
49+
className,
50+
style,
51+
onClick,
52+
...props
53+
}: AvatarProps) {
54+
const px = SIZE_PX[size];
55+
return (
56+
<span
57+
className={cn(
58+
"inline-flex items-center justify-center font-medium tracking-[-0.01em]",
59+
"rounded-sm shrink-0 select-none text-paper",
60+
onClick ? "cursor-pointer" : "cursor-default",
61+
className,
62+
)}
63+
style={{
64+
width: px,
65+
height: px,
66+
background: AVATAR_GRADIENTS[hue % AVATAR_GRADIENTS.length],
67+
fontSize: FONT_SIZE[size],
68+
...style,
69+
}}
70+
onClick={onClick}
71+
{...props}
72+
>
73+
{initials}
74+
</span>
75+
);
76+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"use client";
2+
3+
import type { ButtonHTMLAttributes, ComponentType } from "react";
4+
5+
import { cn } from "@/lib/utils";
6+
7+
type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive";
8+
type ButtonSize = "sm" | "md" | "lg";
9+
10+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
11+
/** Visual style; defaults to `"primary"`. */
12+
variant?: ButtonVariant;
13+
/** Button dimensions; defaults to `"md"`. */
14+
size?: ButtonSize;
15+
/** Leading icon component from `@heroicons/react/24/outline`. */
16+
icon?: ComponentType<{ className?: string }>;
17+
/** Trailing icon component from `@heroicons/react/24/outline`. */
18+
iconRight?: ComponentType<{ className?: string }>;
19+
}
20+
21+
const variantClasses: Record<ButtonVariant, string> = {
22+
primary:
23+
"bg-nottingham-blue text-paper border border-nottingham-blue hover:bg-[var(--color-primary-deep)]",
24+
secondary:
25+
"bg-paper text-ink border border-[var(--border-strong)] hover:bg-portland-stone",
26+
ghost:
27+
"bg-transparent text-ink border border-transparent hover:bg-[rgba(16,38,59,0.06)]",
28+
destructive:
29+
"bg-paper text-jubilee-red border border-[rgba(185,28,46,0.4)] hover:bg-jubilee-red-20",
30+
};
31+
32+
const sizeClasses: Record<ButtonSize, string> = {
33+
sm: "px-[10px] py-[5px] text-[12px] [&_svg]:w-[14px] [&_svg]:h-[14px]",
34+
md: "px-[16px] py-[9px] text-[14px] [&_svg]:w-[16px] [&_svg]:h-[16px]",
35+
lg: "px-[20px] py-[12px] text-[15px] [&_svg]:w-[16px] [&_svg]:h-[16px]",
36+
};
37+
38+
/**
39+
* Primary interaction button with four visual variants and three sizes.
40+
* Icon-only buttons must provide an `aria-label`.
41+
* @param variant - Visual style; defaults to `"primary"`
42+
* @param size - Button dimensions; defaults to `"md"`
43+
* @param icon - Optional leading icon component
44+
* @param iconRight - Optional trailing icon component
45+
*/
46+
export function Button({
47+
variant = "primary",
48+
size = "md",
49+
icon: Icon,
50+
iconRight: IconRight,
51+
children,
52+
className,
53+
disabled,
54+
type = "button",
55+
...props
56+
}: ButtonProps) {
57+
return (
58+
<button
59+
type={type}
60+
disabled={disabled}
61+
className={cn(
62+
"inline-flex items-center gap-2 font-sans font-medium tracking-[-0.005em] rounded-sm cursor-pointer",
63+
"transition-[background-color,transform,border-color] duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)]",
64+
"active:scale-[0.98] active:transition-[transform] active:duration-[80ms]",
65+
"disabled:opacity-50 disabled:cursor-not-allowed",
66+
variantClasses[variant],
67+
sizeClasses[size],
68+
className,
69+
)}
70+
{...props}
71+
>
72+
{Icon && <Icon className="shrink-0" />}
73+
{children}
74+
{IconRight && <IconRight className="shrink-0" />}
75+
</button>
76+
);
77+
}

frontend/components/core/Chip.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"use client";
2+
3+
import type { HTMLAttributes, ReactNode } from "react";
4+
5+
import { cn } from "@/lib/utils";
6+
7+
interface ChipProps extends HTMLAttributes<HTMLSpanElement> {
8+
/** Whether the chip is in its selected/active state. */
9+
active?: boolean;
10+
children: ReactNode;
11+
}
12+
13+
/**
14+
* Filter pill toggle. Active state uses Nottingham Blue background with Paper text.
15+
* @param active - Whether the chip is selected
16+
*/
17+
export function Chip({ active, children, className, ...props }: ChipProps) {
18+
return (
19+
<span
20+
className={cn(
21+
"inline-flex items-center gap-1.5 px-3 py-[5px]",
22+
"rounded-pill text-[13px] border cursor-pointer select-none",
23+
"transition-all duration-[120ms] ease-[cubic-bezier(0.2,0,0,1)]",
24+
active
25+
? "bg-nottingham-blue text-paper border-nottingham-blue font-medium"
26+
: "bg-paper text-ink border-[var(--border)] font-normal hover:border-[var(--border-strong)]",
27+
className,
28+
)}
29+
role="button"
30+
{...props}
31+
>
32+
{children}
33+
</span>
34+
);
35+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { HTMLAttributes, ReactNode } from "react";
2+
3+
import { cn } from "@/lib/utils";
4+
5+
interface EyebrowProps extends HTMLAttributes<HTMLDivElement> {
6+
children: ReactNode;
7+
}
8+
9+
/**
10+
* Small uppercase label used above section titles or content blocks.
11+
* @param children - Label text
12+
*/
13+
export function Eyebrow({ children, className, ...props }: EyebrowProps) {
14+
return (
15+
<div
16+
className={cn(
17+
"text-[11px] font-medium tracking-[0.14em] uppercase text-ink-muted",
18+
className,
19+
)}
20+
{...props}
21+
>
22+
{children}
23+
</div>
24+
);
25+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { HTMLAttributes } from "react";
2+
3+
import { cn } from "@/lib/utils";
4+
5+
interface HairlineProps extends HTMLAttributes<HTMLDivElement> {
6+
/** Renders as a vertical 1px line instead of a horizontal one. */
7+
vertical?: boolean;
8+
}
9+
10+
/**
11+
* 1px visual separator; horizontal by default.
12+
* @param vertical - Render as a vertical divider instead
13+
*/
14+
export function Hairline({ vertical, className, ...props }: HairlineProps) {
15+
return (
16+
<div
17+
className={cn(
18+
"bg-[var(--border)]",
19+
vertical ? "w-px self-stretch" : "h-px w-full",
20+
className,
21+
)}
22+
{...props}
23+
/>
24+
);
25+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { cn } from "@/lib/utils";
2+
3+
interface MatchBadgeProps {
4+
/** Match percentage (0–100). */
5+
pct: number;
6+
className?: string;
7+
}
8+
9+
/**
10+
* Compact inline pill displaying a match percentage in Forest Green.
11+
* @param pct - Match percentage (0–100)
12+
*/
13+
export function MatchBadge({ pct, className }: MatchBadgeProps) {
14+
return (
15+
<span
16+
className={cn(
17+
"inline-flex items-center gap-1 px-[9px] py-[3px]",
18+
"bg-forest-green-20 text-forest-green",
19+
"rounded-sm font-mono text-[11.5px] font-medium",
20+
className,
21+
)}
22+
>
23+
{pct}% match
24+
</span>
25+
);
26+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { cn } from "@/lib/utils";
2+
3+
interface MatchMeterProps {
4+
/** Match percentage (0–100). */
5+
pct: number;
6+
/** Bar width in pixels; defaults to `120`. */
7+
width?: number;
8+
className?: string;
9+
}
10+
11+
/**
12+
* Horizontal progress bar showing a match percentage with colour-coded fill.
13+
* Fill colour: Forest Green (≥70%), Rebel's Gold (≥45%), muted otherwise.
14+
* @param pct - Match percentage (0–100)
15+
* @param width - Bar width in pixels; defaults to `120`
16+
*/
17+
export function MatchMeter({ pct, width = 120, className }: MatchMeterProps) {
18+
const fillColor =
19+
pct >= 70
20+
? "var(--color-forest-green)"
21+
: pct >= 45
22+
? "var(--color-rebels-gold)"
23+
: "var(--color-ink-faint)";
24+
25+
return (
26+
<div className={cn("inline-flex items-center gap-2", className)}>
27+
<div
28+
className="h-[6px] rounded-[2px] overflow-hidden bg-portland-stone shrink-0"
29+
style={{ width }}
30+
>
31+
<div
32+
className="h-full rounded-[2px] transition-[width] duration-[240ms] ease-[cubic-bezier(0.2,0,0,1)]"
33+
style={{ width: `${pct}%`, background: fillColor }}
34+
/>
35+
</div>
36+
<span className="font-mono text-[12px] font-medium text-ink tabular-nums text-right min-w-[32px]">
37+
{pct}%
38+
</span>
39+
</div>
40+
);
41+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { cn } from "@/lib/utils";
2+
3+
type StatusType = "open" | "pending" | "urgent" | "closed" | "accepted";
4+
5+
interface StatusPillProps {
6+
status: StatusType;
7+
/** Optional label override; falls back to the status default. */
8+
label?: string;
9+
className?: string;
10+
}
11+
12+
const STATUS_CONFIG: Record<
13+
StatusType,
14+
{ color: string; defaultLabel: string }
15+
> = {
16+
open: { color: "var(--color-forest-green)", defaultLabel: "Open" },
17+
pending: {
18+
color: "var(--color-mandarin-orange)",
19+
defaultLabel: "Awaiting review",
20+
},
21+
urgent: {
22+
color: "var(--color-jubilee-red)",
23+
defaultLabel: "Closing today",
24+
},
25+
closed: { color: "var(--color-ink-muted)", defaultLabel: "Closed" },
26+
accepted: { color: "var(--color-forest-green)", defaultLabel: "Accepted" },
27+
};
28+
29+
/**
30+
* Coloured dot with a label indicating project or request status.
31+
* @param status - One of the defined status states
32+
* @param label - Optional override for the default status label
33+
*/
34+
export function StatusPill({ status, label, className }: StatusPillProps) {
35+
const config = STATUS_CONFIG[status];
36+
return (
37+
<span
38+
className={cn(
39+
"inline-flex items-center gap-1.5 font-mono text-[11.5px] font-medium",
40+
className,
41+
)}
42+
style={{ color: config.color }}
43+
>
44+
<span
45+
className="w-2 h-2 rounded-full shrink-0"
46+
style={{ background: config.color }}
47+
/>
48+
{label ?? config.defaultLabel}
49+
</span>
50+
);
51+
}

0 commit comments

Comments
 (0)