@spatika/react 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/composites/AccentRule.d.ts +6 -0
- package/dist/composites/AccentRule.d.ts.map +1 -0
- package/dist/composites/AccentRule.js +6 -0
- package/dist/composites/AvailabilityBadge.d.ts +9 -0
- package/dist/composites/AvailabilityBadge.d.ts.map +1 -0
- package/dist/composites/AvailabilityBadge.js +9 -0
- package/dist/composites/CareerCard.d.ts +20 -0
- package/dist/composites/CareerCard.d.ts.map +1 -0
- package/dist/composites/CareerCard.js +17 -0
- package/dist/composites/ContactLink.d.ts +15 -0
- package/dist/composites/ContactLink.d.ts.map +1 -0
- package/dist/composites/ContactLink.js +8 -0
- package/dist/composites/DisplayHeading.d.ts +9 -0
- package/dist/composites/DisplayHeading.d.ts.map +1 -0
- package/dist/composites/DisplayHeading.js +6 -0
- package/dist/composites/FloatChip.d.ts +15 -0
- package/dist/composites/FloatChip.d.ts.map +1 -0
- package/dist/composites/FloatChip.js +7 -0
- package/dist/composites/GradientText.d.ts +11 -0
- package/dist/composites/GradientText.d.ts.map +1 -0
- package/dist/composites/GradientText.js +6 -0
- package/dist/composites/IconTile.d.ts +10 -0
- package/dist/composites/IconTile.d.ts.map +1 -0
- package/dist/composites/IconTile.js +7 -0
- package/dist/composites/MetaChip.d.ts +9 -0
- package/dist/composites/MetaChip.d.ts.map +1 -0
- package/dist/composites/MetaChip.js +6 -0
- package/dist/composites/PresenceDot.d.ts +10 -0
- package/dist/composites/PresenceDot.d.ts.map +1 -0
- package/dist/composites/PresenceDot.js +10 -0
- package/dist/composites/ProjectCard.d.ts +12 -0
- package/dist/composites/ProjectCard.d.ts.map +1 -0
- package/dist/composites/ProjectCard.js +9 -0
- package/dist/composites/SectionHeading.d.ts +16 -0
- package/dist/composites/SectionHeading.d.ts.map +1 -0
- package/dist/composites/SectionHeading.js +12 -0
- package/dist/composites/SiteFooter.d.ts +18 -0
- package/dist/composites/SiteFooter.d.ts.map +1 -0
- package/dist/composites/SiteFooter.js +15 -0
- package/dist/composites/SiteNav.d.ts +20 -0
- package/dist/composites/SiteNav.d.ts.map +1 -0
- package/dist/composites/SiteNav.js +40 -0
- package/dist/composites/StatCard.d.ts +6 -1
- package/dist/composites/StatCard.d.ts.map +1 -1
- package/dist/composites/StatCard.js +4 -1
- package/dist/composites/StatusDot.d.ts +1 -1
- package/dist/composites/Tag.d.ts +11 -0
- package/dist/composites/Tag.d.ts.map +1 -0
- package/dist/composites/Tag.js +33 -0
- package/dist/composites/Wordmark.d.ts +19 -0
- package/dist/composites/Wordmark.d.ts.map +1 -0
- package/dist/composites/Wordmark.js +15 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -0
- package/dist/primitives/Button.d.ts +4 -4
- package/dist/primitives/Button.d.ts.map +1 -1
- package/dist/primitives/Button.js +2 -0
- package/package.json +4 -3
- package/src/composites/AccentRule.tsx +12 -0
- package/src/composites/AvailabilityBadge.tsx +32 -0
- package/src/composites/CareerCard.tsx +111 -0
- package/src/composites/ContactLink.tsx +66 -0
- package/src/composites/DisplayHeading.tsx +27 -0
- package/src/composites/FloatChip.tsx +63 -0
- package/src/composites/GradientText.tsx +31 -0
- package/src/composites/IconTile.tsx +33 -0
- package/src/composites/MetaChip.tsx +24 -0
- package/src/composites/PresenceDot.tsx +42 -0
- package/src/composites/ProjectCard.tsx +65 -0
- package/src/composites/SectionHeading.tsx +61 -0
- package/src/composites/SiteFooter.tsx +79 -0
- package/src/composites/SiteNav.tsx +140 -0
- package/src/composites/StatCard.tsx +32 -1
- package/src/composites/Tag.tsx +50 -0
- package/src/composites/Wordmark.tsx +46 -0
- package/src/composites/marketing.test.tsx +188 -0
- package/src/index.ts +23 -0
- package/src/primitives/Button.tsx +3 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { Badge } from "../primitives/Badge";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "./Card";
|
|
5
|
+
import { Tag } from "./Tag";
|
|
6
|
+
|
|
7
|
+
export type ProjectCardProps = {
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
company?: ReactNode;
|
|
10
|
+
outcome?: ReactNode;
|
|
11
|
+
description?: ReactNode;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Marketing project tile: title, company, outcome pill, body, tech tags. */
|
|
17
|
+
export function ProjectCard({
|
|
18
|
+
title,
|
|
19
|
+
company,
|
|
20
|
+
outcome,
|
|
21
|
+
description,
|
|
22
|
+
tags = [],
|
|
23
|
+
className,
|
|
24
|
+
}: ProjectCardProps) {
|
|
25
|
+
return (
|
|
26
|
+
<Card
|
|
27
|
+
variant="panel"
|
|
28
|
+
padding="md"
|
|
29
|
+
className={cn(
|
|
30
|
+
"group flex h-full flex-col hover:border-primary/30 hover:shadow-[0_12px_32px_color-mix(in_srgb,var(--primary)_7%,transparent)]",
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
<CardHeader className="mb-2">
|
|
35
|
+
<div className="min-w-0">
|
|
36
|
+
<CardTitle className="text-[15px] leading-snug font-[900] group-hover:text-primary sm:text-[16px]">
|
|
37
|
+
{title}
|
|
38
|
+
</CardTitle>
|
|
39
|
+
{company ? (
|
|
40
|
+
<p className="mt-0.5 text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
|
41
|
+
{company}
|
|
42
|
+
</p>
|
|
43
|
+
) : null}
|
|
44
|
+
</div>
|
|
45
|
+
{outcome ? (
|
|
46
|
+
<Badge variant="warm" className="rounded-full px-2.5 py-1 text-[10px] font-black">
|
|
47
|
+
{outcome}
|
|
48
|
+
</Badge>
|
|
49
|
+
) : null}
|
|
50
|
+
</CardHeader>
|
|
51
|
+
<CardContent className="flex flex-1 flex-col">
|
|
52
|
+
{description ? (
|
|
53
|
+
<p className="mb-3 flex-1 text-[13px] leading-relaxed text-muted-foreground">{description}</p>
|
|
54
|
+
) : null}
|
|
55
|
+
{tags.length > 0 ? (
|
|
56
|
+
<div className="flex flex-wrap gap-1.5">
|
|
57
|
+
{tags.map((tag) => (
|
|
58
|
+
<Tag key={tag}>{tag}</Tag>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
) : null}
|
|
62
|
+
</CardContent>
|
|
63
|
+
</Card>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { AccentRule } from "./AccentRule";
|
|
4
|
+
import { GradientText } from "./GradientText";
|
|
5
|
+
|
|
6
|
+
export type SectionHeadingProps = {
|
|
7
|
+
eyebrow?: ReactNode;
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
subtitle?: ReactNode;
|
|
10
|
+
align?: "left" | "center";
|
|
11
|
+
className?: string;
|
|
12
|
+
/** Hide the accent rule under the copy. */
|
|
13
|
+
showRule?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Marketing / page-section title: optional eyebrow, heading, subtitle, and accent rule.
|
|
18
|
+
* Renders a `div` (not `<header>`) so it never competes with the site navbar.
|
|
19
|
+
*/
|
|
20
|
+
export function SectionHeading({
|
|
21
|
+
eyebrow,
|
|
22
|
+
title,
|
|
23
|
+
subtitle,
|
|
24
|
+
align = "left",
|
|
25
|
+
className,
|
|
26
|
+
showRule = true,
|
|
27
|
+
}: SectionHeadingProps) {
|
|
28
|
+
const centered = align === "center";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
data-slot="section-heading"
|
|
33
|
+
className={cn("mb-8 sm:mb-10", centered && "text-center", className)}
|
|
34
|
+
>
|
|
35
|
+
{eyebrow ? (
|
|
36
|
+
<p
|
|
37
|
+
className={cn(
|
|
38
|
+
"mb-2 text-[11px] font-bold uppercase tracking-[0.14em]",
|
|
39
|
+
centered && "text-center",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<GradientText>{eyebrow}</GradientText>
|
|
43
|
+
</p>
|
|
44
|
+
) : null}
|
|
45
|
+
<h2 className="text-[clamp(1.6rem,3.6vw,2.1rem)] font-[800] leading-[1.12] tracking-tight text-foreground">
|
|
46
|
+
{title}
|
|
47
|
+
</h2>
|
|
48
|
+
{subtitle ? (
|
|
49
|
+
<p
|
|
50
|
+
className={cn(
|
|
51
|
+
"mt-2 text-[14px] leading-relaxed text-muted-foreground",
|
|
52
|
+
centered ? "mx-auto max-w-xl" : "max-w-lg",
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{subtitle}
|
|
56
|
+
</p>
|
|
57
|
+
) : null}
|
|
58
|
+
{showRule ? <AccentRule className={cn("mt-4", centered && "mx-auto")} /> : null}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import type { SiteNavLink } from "./SiteNav";
|
|
4
|
+
|
|
5
|
+
export type SiteFooterProps = {
|
|
6
|
+
brand?: ReactNode;
|
|
7
|
+
copyright?: ReactNode;
|
|
8
|
+
links?: SiteNavLink[];
|
|
9
|
+
/** Render each link (e.g. Next.js `<Link>`). Defaults to `<a>`. */
|
|
10
|
+
renderLink?: (link: SiteNavLink, className: string) => ReactNode;
|
|
11
|
+
/** Trailing meta row (badges, “powered by”, etc.). */
|
|
12
|
+
meta?: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const linkClass =
|
|
17
|
+
"font-semibold text-muted-foreground transition-colors hover:text-primary";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Marketing-site footer: brand, copyright, links, optional meta chips.
|
|
21
|
+
* Frosted surface bar with a primary hairline — matches sreelal.me.
|
|
22
|
+
*/
|
|
23
|
+
export function SiteFooter({
|
|
24
|
+
brand,
|
|
25
|
+
copyright,
|
|
26
|
+
links = [],
|
|
27
|
+
renderLink,
|
|
28
|
+
meta,
|
|
29
|
+
className,
|
|
30
|
+
}: SiteFooterProps) {
|
|
31
|
+
const renderItem = (link: SiteNavLink) => {
|
|
32
|
+
if (renderLink) return renderLink(link, linkClass);
|
|
33
|
+
return (
|
|
34
|
+
<a href={link.href} className={linkClass}>
|
|
35
|
+
{link.label}
|
|
36
|
+
</a>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<footer
|
|
42
|
+
data-slot="site-footer"
|
|
43
|
+
className={cn(
|
|
44
|
+
"relative border-t border-border/80 bg-[color-mix(in_srgb,var(--background)_85%,transparent)] backdrop-blur-md",
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/35 to-transparent"
|
|
50
|
+
aria-hidden
|
|
51
|
+
/>
|
|
52
|
+
<div className="mx-auto max-w-6xl px-6 py-12 lg:px-8">
|
|
53
|
+
<div className="flex flex-col items-center justify-between gap-6 md:flex-row">
|
|
54
|
+
<div className="flex items-center gap-3">
|
|
55
|
+
{brand}
|
|
56
|
+
{brand && copyright ? (
|
|
57
|
+
<span className="text-border" aria-hidden>
|
|
58
|
+
|
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
{copyright ? (
|
|
62
|
+
<span className="text-[13px] text-muted-foreground">{copyright}</span>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{links.length > 0 ? (
|
|
67
|
+
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[12px] text-muted-foreground">
|
|
68
|
+
{links.map((link) => (
|
|
69
|
+
<span key={`${link.href}-${link.label}`}>{renderItem(link)}</span>
|
|
70
|
+
))}
|
|
71
|
+
</nav>
|
|
72
|
+
) : null}
|
|
73
|
+
|
|
74
|
+
{meta}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</footer>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useId, useState, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type SiteNavLink = {
|
|
5
|
+
href: string;
|
|
6
|
+
label: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SiteNavProps = {
|
|
10
|
+
brand: ReactNode;
|
|
11
|
+
links: SiteNavLink[];
|
|
12
|
+
/** Render each link (e.g. Next.js `<Link>`). Defaults to `<a>`. */
|
|
13
|
+
renderLink?: (link: SiteNavLink, className: string) => ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Extra classes for the inner max-width row. */
|
|
16
|
+
innerClassName?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const desktopLinkClass =
|
|
20
|
+
"relative rounded-full px-3.5 py-2 text-[13px] font-bold text-muted-foreground transition-colors hover:bg-primary/5 hover:text-foreground";
|
|
21
|
+
|
|
22
|
+
const mobileLinkClass =
|
|
23
|
+
"flex items-center rounded-2xl px-4 py-4 text-xl font-bold text-muted-foreground transition-colors hover:bg-primary/5 hover:text-primary";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Marketing-site navbar: fixed bar, brand, desktop links, and a 3-line mobile menu.
|
|
27
|
+
* Matches the sreelal.me chrome: transparent at rest, frosted surface after scroll.
|
|
28
|
+
*/
|
|
29
|
+
export function SiteNav({
|
|
30
|
+
brand,
|
|
31
|
+
links,
|
|
32
|
+
renderLink,
|
|
33
|
+
className,
|
|
34
|
+
innerClassName,
|
|
35
|
+
}: SiteNavProps) {
|
|
36
|
+
const [scrolled, setScrolled] = useState(false);
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const menuId = useId();
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const onScroll = () => setScrolled(window.scrollY > 20);
|
|
42
|
+
onScroll();
|
|
43
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
44
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!open) return;
|
|
49
|
+
const onKey = (event: KeyboardEvent) => {
|
|
50
|
+
if (event.key === "Escape") setOpen(false);
|
|
51
|
+
};
|
|
52
|
+
window.addEventListener("keydown", onKey);
|
|
53
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
54
|
+
}, [open]);
|
|
55
|
+
|
|
56
|
+
const renderItem = (link: SiteNavLink, itemClass: string) => {
|
|
57
|
+
const close = () => setOpen(false);
|
|
58
|
+
if (renderLink) {
|
|
59
|
+
return (
|
|
60
|
+
<span className="contents" onClick={close}>
|
|
61
|
+
{renderLink(link, itemClass)}
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return (
|
|
66
|
+
<a href={link.href} className={itemClass} onClick={close}>
|
|
67
|
+
{link.label}
|
|
68
|
+
</a>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<nav
|
|
74
|
+
data-slot="site-nav"
|
|
75
|
+
data-scrolled={scrolled ? "true" : "false"}
|
|
76
|
+
className={cn(
|
|
77
|
+
"spk-site-nav fixed inset-x-0 top-0 z-50 transition-all duration-500",
|
|
78
|
+
scrolled
|
|
79
|
+
? "border-b border-border/90 bg-[color-mix(in_srgb,var(--background)_88%,transparent)] shadow-[0_8px_30px_rgba(15,13,18,0.06)] backdrop-blur-xl"
|
|
80
|
+
: "bg-transparent",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<div className={cn("mx-auto max-w-6xl px-4 sm:px-6 lg:px-8", innerClassName)}>
|
|
85
|
+
<div className="flex h-14 items-center justify-between sm:h-16">
|
|
86
|
+
<div className="flex min-w-0 items-center">{brand}</div>
|
|
87
|
+
|
|
88
|
+
<div className="hidden items-center gap-0.5 md:flex">
|
|
89
|
+
{links.map((link) => (
|
|
90
|
+
<span key={`${link.href}-${link.label}`}>{renderItem(link, desktopLinkClass)}</span>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<button
|
|
95
|
+
type="button"
|
|
96
|
+
className="spk-touch-target -mr-2 p-2 md:hidden"
|
|
97
|
+
aria-label="Toggle menu"
|
|
98
|
+
aria-expanded={open}
|
|
99
|
+
aria-controls={menuId}
|
|
100
|
+
onClick={() => setOpen((value) => !value)}
|
|
101
|
+
>
|
|
102
|
+
<span className="flex h-4 w-6 flex-col justify-between" aria-hidden>
|
|
103
|
+
<span
|
|
104
|
+
className={cn(
|
|
105
|
+
"block h-[2px] w-6 rounded-full bg-foreground transition-transform duration-200",
|
|
106
|
+
open && "translate-y-[7px] rotate-45",
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
<span
|
|
110
|
+
className={cn(
|
|
111
|
+
"block h-[2px] w-6 rounded-full bg-foreground transition-opacity duration-200",
|
|
112
|
+
open && "opacity-0",
|
|
113
|
+
)}
|
|
114
|
+
/>
|
|
115
|
+
<span
|
|
116
|
+
className={cn(
|
|
117
|
+
"block h-[2px] w-6 rounded-full bg-foreground transition-transform duration-200",
|
|
118
|
+
open && "-translate-y-[7px] -rotate-45",
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
</span>
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{open ? (
|
|
127
|
+
<div
|
|
128
|
+
id={menuId}
|
|
129
|
+
className="border-b border-border bg-[color-mix(in_srgb,var(--background)_95%,transparent)] shadow-2xl backdrop-blur-2xl md:hidden"
|
|
130
|
+
>
|
|
131
|
+
<div className="flex flex-col gap-2 px-6 py-8">
|
|
132
|
+
{links.map((link) => (
|
|
133
|
+
<div key={`${link.href}-${link.label}`}>{renderItem(link, mobileLinkClass)}</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
) : null}
|
|
138
|
+
</nav>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -7,10 +7,41 @@ export type StatCardProps = {
|
|
|
7
7
|
hint?: string;
|
|
8
8
|
icon?: ReactNode;
|
|
9
9
|
className?: string;
|
|
10
|
+
/**
|
|
11
|
+
* `app` — elevated app-card tile (label then value).
|
|
12
|
+
* `metric` — centered value-first tile for marketing stats.
|
|
13
|
+
*/
|
|
14
|
+
variant?: "app" | "metric";
|
|
10
15
|
};
|
|
11
16
|
|
|
12
17
|
/** Compact metric tile using elevated app-card surface. */
|
|
13
|
-
export function StatCard({
|
|
18
|
+
export function StatCard({
|
|
19
|
+
label,
|
|
20
|
+
value,
|
|
21
|
+
hint,
|
|
22
|
+
icon,
|
|
23
|
+
className,
|
|
24
|
+
variant = "app",
|
|
25
|
+
}: StatCardProps) {
|
|
26
|
+
if (variant === "metric") {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
data-slot="stat-card"
|
|
30
|
+
data-variant="metric"
|
|
31
|
+
className={cn(
|
|
32
|
+
"flex flex-col items-center justify-center gap-1 rounded-[1.35rem] border border-border/80 bg-background/90 px-2 py-6 text-center shadow-[0_4px_24px_rgba(15,13,18,0.04)] backdrop-blur-sm",
|
|
33
|
+
className,
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<p className="text-xl font-black text-primary sm:text-2xl">{value}</p>
|
|
37
|
+
<p className="text-[10px] font-bold leading-none text-muted-foreground sm:text-[11px]">
|
|
38
|
+
{label}
|
|
39
|
+
</p>
|
|
40
|
+
{hint ? <p className="text-[10px] text-muted-foreground">{hint}</p> : null}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
14
45
|
return (
|
|
15
46
|
<div data-slot="stat-card" className={cn("app-stat-card p-4 sm:p-5", className)}>
|
|
16
47
|
<div className="flex items-start justify-between gap-3">
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
const tagVariants = cva(
|
|
6
|
+
"inline-flex items-center gap-1.5 whitespace-nowrap border font-bold",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default:
|
|
11
|
+
"border-border/80 bg-[color-mix(in_srgb,var(--foreground)_4.5%,var(--background))] text-foreground",
|
|
12
|
+
primary: "border-primary/15 bg-primary/10 text-primary",
|
|
13
|
+
outline: "border-border/80 bg-transparent text-foreground",
|
|
14
|
+
muted: "border-transparent bg-secondary text-secondary-foreground",
|
|
15
|
+
red: "border-red-100 bg-red-50 text-red-700",
|
|
16
|
+
green: "border-green-200 bg-green-50 text-green-700",
|
|
17
|
+
orange: "border-orange-200 bg-orange-50 text-orange-700",
|
|
18
|
+
blue: "border-blue-200 bg-blue-50 text-blue-700",
|
|
19
|
+
purple: "border-purple-200 bg-purple-50 text-purple-700",
|
|
20
|
+
cyan: "border-cyan-200 bg-cyan-50 text-cyan-700",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
sm: "rounded-md px-2 py-0.5 text-[11px]",
|
|
24
|
+
md: "rounded-lg px-2.5 py-1 text-[12px]",
|
|
25
|
+
lg: "rounded-xl px-3 py-1.5 text-[13px]",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
variant: "default",
|
|
30
|
+
size: "sm",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export type TagProps = React.ComponentProps<"span"> & VariantProps<typeof tagVariants>;
|
|
36
|
+
|
|
37
|
+
/** Static label pill for skills, tech stacks, and metadata. */
|
|
38
|
+
const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
39
|
+
({ className, variant, size, ...props }, ref) => (
|
|
40
|
+
<span
|
|
41
|
+
ref={ref}
|
|
42
|
+
data-slot="tag"
|
|
43
|
+
className={cn(tagVariants({ variant, size }), className)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
Tag.displayName = "Tag";
|
|
49
|
+
|
|
50
|
+
export { Tag, tagVariants };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { GradientText } from "./GradientText";
|
|
4
|
+
|
|
5
|
+
export type WordmarkProps = {
|
|
6
|
+
/** Primary name, rendered in foreground. */
|
|
7
|
+
name: ReactNode;
|
|
8
|
+
/** Trailing bit (`.me`, last name) in the marketing gradient. */
|
|
9
|
+
accent?: ReactNode;
|
|
10
|
+
/** Extra punctuation in primary (the period in `Name.`). */
|
|
11
|
+
mark?: ReactNode;
|
|
12
|
+
/** Unstyled trailing copy after the mark (`me` in `Sreelal.me`). */
|
|
13
|
+
rest?: ReactNode;
|
|
14
|
+
size?: "sm" | "md" | "lg" | "display";
|
|
15
|
+
as?: "span" | "h1";
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sizeClass = {
|
|
20
|
+
sm: "text-[14px] font-semibold tracking-tight",
|
|
21
|
+
md: "text-xl font-[900] tracking-tight",
|
|
22
|
+
lg: "text-[clamp(1.6rem,3.6vw,2.1rem)] font-[800] tracking-tight",
|
|
23
|
+
display: "text-[clamp(2.5rem,8vw,5.5rem)] font-[900] leading-[0.95] tracking-tight",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Marketing wordmark: solid name, optional gradient accent, optional primary mark.
|
|
28
|
+
*/
|
|
29
|
+
export function Wordmark({
|
|
30
|
+
name,
|
|
31
|
+
accent,
|
|
32
|
+
mark,
|
|
33
|
+
rest,
|
|
34
|
+
size = "md",
|
|
35
|
+
as: Tag = "span",
|
|
36
|
+
className,
|
|
37
|
+
}: WordmarkProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Tag data-slot="wordmark" className={cn(sizeClass[size], className)}>
|
|
40
|
+
<span className="text-foreground">{name}</span>
|
|
41
|
+
{accent ? <GradientText>{accent}</GradientText> : null}
|
|
42
|
+
{mark ? <span className="text-primary">{mark}</span> : null}
|
|
43
|
+
{rest ? <span className="text-foreground">{rest}</span> : null}
|
|
44
|
+
</Tag>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { Tag } from "./Tag";
|
|
5
|
+
import { SectionHeading } from "./SectionHeading";
|
|
6
|
+
import { SiteNav } from "./SiteNav";
|
|
7
|
+
import { SiteFooter } from "./SiteFooter";
|
|
8
|
+
import { StatCard } from "./StatCard";
|
|
9
|
+
import { GradientText } from "./GradientText";
|
|
10
|
+
import { Wordmark } from "./Wordmark";
|
|
11
|
+
import { AvailabilityBadge } from "./AvailabilityBadge";
|
|
12
|
+
import { FloatChip } from "./FloatChip";
|
|
13
|
+
import { MetaChip } from "./MetaChip";
|
|
14
|
+
import { IconTile } from "./IconTile";
|
|
15
|
+
import { ContactLink } from "./ContactLink";
|
|
16
|
+
import { CareerCard, CareerTimeline } from "./CareerCard";
|
|
17
|
+
import { ProjectCard } from "./ProjectCard";
|
|
18
|
+
import { PresenceDot } from "./PresenceDot";
|
|
19
|
+
import { AccentRule } from "./AccentRule";
|
|
20
|
+
|
|
21
|
+
describe("Tag", () => {
|
|
22
|
+
it("renders label text", () => {
|
|
23
|
+
render(<Tag>Java</Tag>);
|
|
24
|
+
expect(screen.getByText("Java")).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("supports color tones from the marketing site", () => {
|
|
28
|
+
render(<Tag variant="orange">AWS</Tag>);
|
|
29
|
+
expect(screen.getByText("AWS").className).toMatch(/orange/);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("SectionHeading", () => {
|
|
34
|
+
it("renders eyebrow, title, and subtitle without a header element", () => {
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<SectionHeading eyebrow="Career" title="Where I've worked" subtitle="7+ years." />,
|
|
37
|
+
);
|
|
38
|
+
expect(screen.getByText("Career")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByRole("heading", { name: "Where I've worked" })).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText("7+ years.")).toBeInTheDocument();
|
|
41
|
+
expect(container.querySelector("header")).toBeNull();
|
|
42
|
+
expect(container.querySelector("[data-slot='section-heading']")).toBeTruthy();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("SiteNav", () => {
|
|
47
|
+
it("renders brand and links", async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
render(
|
|
50
|
+
<SiteNav
|
|
51
|
+
brand={<a href="/">Brand</a>}
|
|
52
|
+
links={[
|
|
53
|
+
{ href: "#about", label: "About" },
|
|
54
|
+
{ href: "/blog", label: "Blog" },
|
|
55
|
+
]}
|
|
56
|
+
/>,
|
|
57
|
+
);
|
|
58
|
+
expect(screen.getByText("Brand")).toBeInTheDocument();
|
|
59
|
+
expect(screen.getAllByText("About").length).toBeGreaterThan(0);
|
|
60
|
+
expect(screen.getByRole("navigation").className).toContain("spk-site-nav");
|
|
61
|
+
await user.click(screen.getByRole("button", { name: "Toggle menu" }));
|
|
62
|
+
expect(screen.getByLabelText("Toggle menu")).toHaveAttribute("aria-expanded", "true");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("SiteFooter", () => {
|
|
67
|
+
it("renders brand, copyright, and links", () => {
|
|
68
|
+
render(
|
|
69
|
+
<SiteFooter
|
|
70
|
+
brand={<span>Acme</span>}
|
|
71
|
+
copyright="© 2026"
|
|
72
|
+
links={[{ href: "#about", label: "About" }]}
|
|
73
|
+
meta={<MetaChip>Next.js</MetaChip>}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
expect(screen.getByText("Acme")).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByText("© 2026")).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText("About")).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByText("Next.js")).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("StatCard metric", () => {
|
|
84
|
+
it("renders value-first metric layout", () => {
|
|
85
|
+
render(<StatCard variant="metric" label="Years Exp." value="7+" />);
|
|
86
|
+
expect(screen.getByText("7+")).toBeInTheDocument();
|
|
87
|
+
expect(screen.getByText("Years Exp.")).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("marketing chrome", () => {
|
|
92
|
+
it("renders a gradient wordmark", () => {
|
|
93
|
+
render(<Wordmark name="Sreelal" accent=".me" />);
|
|
94
|
+
expect(screen.getByText("Sreelal")).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByText(".me")).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders gradient text", () => {
|
|
99
|
+
render(<GradientText>Chalil</GradientText>);
|
|
100
|
+
expect(screen.getByText("Chalil")).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("renders an availability badge with a live pip", () => {
|
|
104
|
+
render(<AvailabilityBadge>Open for work</AvailabilityBadge>);
|
|
105
|
+
expect(screen.getByText("Open for work")).toBeInTheDocument();
|
|
106
|
+
expect(document.querySelector("[data-slot='presence-dot']")).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders a float chip", () => {
|
|
110
|
+
render(<FloatChip icon="☁️" title="AWS" subtitle="Certified" />);
|
|
111
|
+
expect(screen.getByText("AWS")).toBeInTheDocument();
|
|
112
|
+
expect(screen.getByText("Certified")).toBeInTheDocument();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("renders a live float chip", () => {
|
|
116
|
+
render(<FloatChip live>Open for Work</FloatChip>);
|
|
117
|
+
expect(screen.getByText("Open for Work")).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("renders a presence dot", () => {
|
|
121
|
+
render(<PresenceDot label="Online" />);
|
|
122
|
+
expect(screen.getByLabelText("Online")).toBeInTheDocument();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders an accent rule", () => {
|
|
126
|
+
const { container } = render(<AccentRule />);
|
|
127
|
+
expect(container.querySelector("[data-slot='accent-rule']")).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("IconTile", () => {
|
|
132
|
+
it("renders title and description", () => {
|
|
133
|
+
render(<IconTile icon="🏦" title="Banking" description="Markets" />);
|
|
134
|
+
expect(screen.getByText("Banking")).toBeInTheDocument();
|
|
135
|
+
expect(screen.getByText("Markets")).toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("ContactLink", () => {
|
|
140
|
+
it("renders name, label, and href", () => {
|
|
141
|
+
render(
|
|
142
|
+
<ContactLink href="mailto:hi@example.com" name="Email" label="hi@example.com" icon="@" />,
|
|
143
|
+
);
|
|
144
|
+
const link = screen.getByRole("link", { name: /Email/ });
|
|
145
|
+
expect(link).toHaveAttribute("href", "mailto:hi@example.com");
|
|
146
|
+
expect(screen.getByText("hi@example.com")).toBeInTheDocument();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("CareerCard", () => {
|
|
151
|
+
it("renders a current role with tags", () => {
|
|
152
|
+
render(
|
|
153
|
+
<CareerTimeline>
|
|
154
|
+
<CareerCard
|
|
155
|
+
role="Engineer"
|
|
156
|
+
company="Acme"
|
|
157
|
+
location="Remote"
|
|
158
|
+
period="2024 — Present"
|
|
159
|
+
current
|
|
160
|
+
bullets={["Shipped the thing."]}
|
|
161
|
+
tags={["Java"]}
|
|
162
|
+
/>
|
|
163
|
+
</CareerTimeline>
|
|
164
|
+
);
|
|
165
|
+
expect(screen.getByText("Engineer")).toBeInTheDocument();
|
|
166
|
+
expect(screen.getByText("Acme")).toBeInTheDocument();
|
|
167
|
+
expect(screen.getByText("Shipped the thing.")).toBeInTheDocument();
|
|
168
|
+
expect(screen.getByText("Java")).toBeInTheDocument();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("ProjectCard", () => {
|
|
173
|
+
it("renders title, company, outcome, and tags", () => {
|
|
174
|
+
render(
|
|
175
|
+
<ProjectCard
|
|
176
|
+
title="PIM"
|
|
177
|
+
company="Nissan"
|
|
178
|
+
outcome="60% cost saved"
|
|
179
|
+
description="Cloud rewrite."
|
|
180
|
+
tags={["AWS"]}
|
|
181
|
+
/>,
|
|
182
|
+
);
|
|
183
|
+
expect(screen.getByText("PIM")).toBeInTheDocument();
|
|
184
|
+
expect(screen.getByText("Nissan")).toBeInTheDocument();
|
|
185
|
+
expect(screen.getByText("60% cost saved")).toBeInTheDocument();
|
|
186
|
+
expect(screen.getByText("AWS")).toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
});
|