@bundu/ui 0.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.
@@ -0,0 +1,166 @@
1
+ ---
2
+ /**
3
+ * SocialIcon — platform-aware social link.
4
+ *
5
+ * Detects the platform from the URL hostname and renders the right
6
+ * SVG glyph + accessible label. Falls back to a generic globe icon
7
+ * when the hostname doesn't match any known platform — the link
8
+ * still works; only the icon defaults.
9
+ *
10
+ * Used on /team across bundu, nyuchi, mukoko (one record per
11
+ * author in Sanity → many platform links per author). The Sanity
12
+ * field is `sameAs: string[]` on the author doc, so editors don't
13
+ * have to pick a platform from a dropdown — they just paste the
14
+ * URL and the render picks the right icon.
15
+ *
16
+ * Supported platforms: LinkedIn, X (Twitter), Instagram, GitHub,
17
+ * YouTube, Threads, Bluesky, Mastodon, Facebook, TikTok, ORCID,
18
+ * plus a generic fallback. Add a host → key mapping below to
19
+ * extend.
20
+ */
21
+
22
+ interface Props {
23
+ /** Full URL to the social profile. The protocol must be present
24
+ * (https://...); we pass it through to the anchor href. */
25
+ url: string;
26
+ /** Icon size in px. Default 18, which sits well in a 36px round
27
+ * button (margin handled by the wrapping `.w-9 h-9`). */
28
+ size?: number;
29
+ /** Override the auto-detected label (otherwise the platform name
30
+ * or hostname is used for aria-label + title). */
31
+ label?: string;
32
+ }
33
+
34
+ const { url, size = 18, label } = Astro.props;
35
+
36
+ function getPlatform(rawUrl: string): { name: string; key: string } | null {
37
+ let host: string;
38
+ try {
39
+ host = new URL(rawUrl).hostname.toLowerCase().replace(/^www\./, '');
40
+ } catch {
41
+ return null;
42
+ }
43
+ const exactMap: Record<string, { name: string; key: string }> = {
44
+ 'linkedin.com': { name: 'LinkedIn', key: 'linkedin' },
45
+ 'lnkd.in': { name: 'LinkedIn', key: 'linkedin' },
46
+ 'x.com': { name: 'X', key: 'x' },
47
+ 'twitter.com': { name: 'X', key: 'x' },
48
+ 't.co': { name: 'X', key: 'x' },
49
+ 'instagram.com': { name: 'Instagram', key: 'instagram' },
50
+ 'github.com': { name: 'GitHub', key: 'github' },
51
+ 'youtube.com': { name: 'YouTube', key: 'youtube' },
52
+ 'youtu.be': { name: 'YouTube', key: 'youtube' },
53
+ 'threads.net': { name: 'Threads', key: 'threads' },
54
+ 'bsky.app': { name: 'Bluesky', key: 'bluesky' },
55
+ 'facebook.com': { name: 'Facebook', key: 'facebook' },
56
+ 'fb.me': { name: 'Facebook', key: 'facebook' },
57
+ 'tiktok.com': { name: 'TikTok', key: 'tiktok' },
58
+ 'orcid.org': { name: 'ORCID', key: 'orcid' },
59
+ };
60
+ if (exactMap[host]) return exactMap[host];
61
+ // Mastodon federation: every server has its own hostname. Match
62
+ // common patterns (mastodon.*, *.social) rather than enumerating.
63
+ if (/(^|\.)mastodon\./.test(host) || host.endsWith('.social')) {
64
+ return { name: 'Mastodon', key: 'mastodon' };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ let hostname = '';
70
+ try {
71
+ hostname = new URL(url).hostname.replace(/^www\./, '');
72
+ } catch {
73
+ // leave hostname empty; the link still renders, just falls back
74
+ // to the generic icon below
75
+ }
76
+
77
+ const platform = getPlatform(url);
78
+ const displayLabel = label ?? platform?.name ?? hostname ?? 'External link';
79
+ const key = platform?.key ?? 'generic';
80
+ ---
81
+
82
+ <a
83
+ href={url}
84
+ target="_blank"
85
+ rel="me noopener noreferrer"
86
+ aria-label={displayLabel}
87
+ title={displayLabel}
88
+ class="inline-flex items-center justify-center w-9 h-9 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
89
+ data-social-platform={key}
90
+ >
91
+ {key === 'linkedin' && (
92
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
93
+ <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.062 2.062 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
94
+ </svg>
95
+ )}
96
+
97
+ {key === 'x' && (
98
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
99
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
100
+ </svg>
101
+ )}
102
+
103
+ {key === 'instagram' && (
104
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
105
+ <rect x="2" y="2" width="20" height="20" rx="5" ry="5"/>
106
+ <path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/>
107
+ <line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/>
108
+ </svg>
109
+ )}
110
+
111
+ {key === 'github' && (
112
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
113
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
114
+ </svg>
115
+ )}
116
+
117
+ {key === 'youtube' && (
118
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
119
+ <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
120
+ </svg>
121
+ )}
122
+
123
+ {key === 'threads' && (
124
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
125
+ <path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-1.104-3.96-3.898-5.984-8.304-6.015-2.91.022-5.11.936-6.54 2.717C4.307 6.504 3.616 8.914 3.589 12c.027 3.086.718 5.496 2.057 7.164 1.43 1.781 3.631 2.695 6.54 2.717 2.623-.02 4.358-.631 5.8-2.045 1.647-1.613 1.618-3.593 1.09-4.798-.31-.71-.873-1.3-1.634-1.75-.192 1.352-.622 2.446-1.284 3.272-.886 1.102-2.14 1.704-3.73 1.79-1.202.065-2.361-.218-3.259-.801-1.063-.689-1.685-1.74-1.752-2.964-.065-1.19.408-2.285 1.33-3.082.88-.76 2.119-1.207 3.583-1.291a13.853 13.853 0 0 1 3.02.142c-.126-.742-.375-1.332-.75-1.757-.513-.586-1.308-.883-2.359-.89h-.029c-.844 0-1.992.232-2.721 1.32L7.734 7.847c.98-1.454 2.568-2.256 4.478-2.256h.044c3.194.02 5.097 1.975 5.287 5.388.108.046.216.094.32.143 1.49.7 2.58 1.761 3.154 3.07.797 1.82.871 4.79-1.548 7.158-1.85 1.81-4.094 2.628-7.277 2.65zm1.003-11.69c-.242 0-.487.007-.739.021-1.836.103-2.98.945-2.916 2.143.067 1.256 1.452 1.84 2.784 1.767 1.224-.065 2.818-.543 3.086-3.71a10.5 10.5 0 0 0-2.215-.221z"/>
126
+ </svg>
127
+ )}
128
+
129
+ {key === 'bluesky' && (
130
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
131
+ <path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
132
+ </svg>
133
+ )}
134
+
135
+ {key === 'mastodon' && (
136
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
137
+ <path d="M23.193 7.88c0-5.207-3.412-6.733-3.412-6.733C18.062.357 15.108.027 12.041 0h-.076c-3.069.027-6.02.357-7.74 1.147 0 0-3.412 1.526-3.412 6.732 0 1.193-.023 2.619.015 4.13.124 5.092.934 10.11 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.13.539c-2.165-.074-4.449-.234-4.799-2.892a5.45 5.45 0 0 1-.048-.745s2.125.52 4.82.643c1.647.076 3.193-.097 4.762-.283 3.007-.36 5.626-2.216 5.956-3.913.52-2.673.477-6.521.477-6.521zm-4.024 6.704h-2.497V8.469c0-1.29-.541-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.484v-3.35c0-1.535-.602-2.31-1.802-2.31-1.087 0-1.628.653-1.628 1.943v6.115H4.831V8.285c0-1.29.328-2.314.987-3.07.68-.757 1.57-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.042c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.389 2.674 1.146.658.756.987 1.78.987 3.07z"/>
138
+ </svg>
139
+ )}
140
+
141
+ {key === 'facebook' && (
142
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
143
+ <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
144
+ </svg>
145
+ )}
146
+
147
+ {key === 'tiktok' && (
148
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
149
+ <path d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5.8 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1.84-.1z"/>
150
+ </svg>
151
+ )}
152
+
153
+ {key === 'orcid' && (
154
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
155
+ <path d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0zM7.369 4.378c.525 0 .947.431.947.947 0 .525-.422.947-.947.947-.525 0-.946-.422-.946-.947 0-.516.421-.947.946-.947zm-.722 3.038h1.444v10.041H6.647V7.416zm3.562 0h3.9c3.712 0 5.344 2.653 5.344 5.025 0 2.578-2.016 5.025-5.325 5.025h-3.919V7.416zm1.444 1.303v7.444h2.297c3.272 0 4.022-2.484 4.022-3.722 0-2.016-1.284-3.722-4.097-3.722h-2.222z"/>
156
+ </svg>
157
+ )}
158
+
159
+ {key === 'generic' && (
160
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
161
+ <circle cx="12" cy="12" r="10"/>
162
+ <line x1="2" y1="12" x2="22" y2="12"/>
163
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
164
+ </svg>
165
+ )}
166
+ </a>
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Breadcrumb helpers. The Astro `Breadcrumb` component consumes
3
+ * `BreadcrumbItem[]`; this module derives that array from a URL
4
+ * pathname plus a per-app label map.
5
+ *
6
+ * Labels: each app declares a `Record<string, string>` mapping URL
7
+ * segments (or full paths) to display names. The deriver walks the
8
+ * path one segment at a time, prefers a full-path hit over a
9
+ * segment hit, and falls back to a title-cased rendering of the
10
+ * segment itself. Segments without a humane label can be marked
11
+ * `null` to skip the segment entirely (useful when a routing
12
+ * segment is structural rather than user-facing, e.g. `/blog/tag/`
13
+ * where `tag` is a routing token).
14
+ */
15
+
16
+ export interface BreadcrumbItem {
17
+ /** Display label. */
18
+ name: string;
19
+ /** Absolute URL or site-relative path. Omit on the current page
20
+ * and the component renders it as `aria-current="page"`. */
21
+ url?: string;
22
+ }
23
+
24
+ export type BreadcrumbLabelMap = Record<string, string | null>;
25
+
26
+ /** Title-case fallback: "k12-digital-campus" → "K12 Digital Campus".
27
+ * Used when a segment isn't in the label map. */
28
+ function humanize(segment: string): string {
29
+ return segment
30
+ .replace(/[-_]+/g, " ")
31
+ .replace(/\b\w/g, (c) => c.toUpperCase());
32
+ }
33
+
34
+ /**
35
+ * Build a breadcrumb trail from a URL pathname.
36
+ *
37
+ * @param pathname e.g. `Astro.url.pathname` — `/education/k12-digital-campus`
38
+ * @param labels per-app label map. Keys can be full paths
39
+ * (`/education/k12-digital-campus`) or single
40
+ * segments (`education`). Full-path keys win.
41
+ * @param homeName the label for the root crumb. Pass the app's
42
+ * short name (e.g. "Nyuchi", "Bundu", "Mukoko").
43
+ * @param currentName optional label override for the final crumb
44
+ * (e.g. an article title that isn't representable
45
+ * in a static label map).
46
+ */
47
+ export function deriveBreadcrumbs(
48
+ pathname: string,
49
+ labels: BreadcrumbLabelMap,
50
+ homeName: string,
51
+ currentName?: string,
52
+ ): BreadcrumbItem[] {
53
+ const segments = pathname.split("/").filter(Boolean);
54
+ if (segments.length === 0) return [];
55
+
56
+ const items: BreadcrumbItem[] = [{ name: homeName, url: "/" }];
57
+ let cumulative = "";
58
+
59
+ for (let i = 0; i < segments.length; i++) {
60
+ const segment = segments[i];
61
+ cumulative += `/${segment}`;
62
+ const isLast = i === segments.length - 1;
63
+
64
+ // Full-path label wins over single-segment.
65
+ const explicit = Object.prototype.hasOwnProperty.call(labels, cumulative)
66
+ ? labels[cumulative]
67
+ : Object.prototype.hasOwnProperty.call(labels, segment)
68
+ ? labels[segment]
69
+ : undefined;
70
+
71
+ // `null` in the label map means "skip this segment entirely"
72
+ // (used for routing tokens like `/blog/tag/` where `tag` adds
73
+ // no breadcrumb value).
74
+ if (explicit === null) continue;
75
+
76
+ const name =
77
+ isLast && currentName ? currentName : (explicit ?? humanize(segment));
78
+
79
+ items.push(isLast ? { name } : { name, url: cumulative });
80
+ }
81
+
82
+ return items;
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ deriveBreadcrumbs,
3
+ type BreadcrumbItem,
4
+ type BreadcrumbLabelMap,
5
+ } from "./breadcrumbs";
@@ -0,0 +1,44 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { extendTailwindMerge } from "tailwind-merge";
3
+
4
+ /**
5
+ * Tailwind-merge configured for the Nyuchi Design System's custom type
6
+ * scale. Without this, merge treats the named font sizes (`text-body`,
7
+ * `text-h2`, …) as *colours* and drops a legitimately co-existing
8
+ * `text-primary-foreground`. Declaring them in the `font-size` group
9
+ * keeps colour + size independent, the way raw Tailwind generates them.
10
+ */
11
+ const twMerge = extendTailwindMerge({
12
+ extend: {
13
+ classGroups: {
14
+ "font-size": [
15
+ {
16
+ text: [
17
+ "display",
18
+ "display-sm",
19
+ "h1",
20
+ "h2",
21
+ "h3",
22
+ "h4",
23
+ "h5",
24
+ "h6",
25
+ "body-lg",
26
+ "body",
27
+ "body-sm",
28
+ "caption",
29
+ ],
30
+ },
31
+ ],
32
+ },
33
+ },
34
+ });
35
+
36
+ /**
37
+ * `cn` — the shadcn class-merge helper the Nyuchi Design System is built
38
+ * on. Merges conditional class lists (clsx) and resolves Tailwind
39
+ * conflicts last-wins (tailwind-merge). Every UI component composes its
40
+ * variant classes through this.
41
+ */
42
+ export function cn(...inputs: ClassValue[]) {
43
+ return twMerge(clsx(inputs));
44
+ }
@@ -0,0 +1,99 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * Alert — shadcn CVA pattern mapped onto the Five-African-Minerals
8
+ * container tokens. Variants name the semantic role, not the colour.
9
+ * `role="alert"` announces the message to assistive tech.
10
+ */
11
+ export const alertVariants = cva(
12
+ "relative w-full rounded-lg border px-4 py-3 text-body-sm",
13
+ {
14
+ variants: {
15
+ variant: {
16
+ default: "bg-card text-card-foreground border-border",
17
+ info: "bg-cobalt-container text-cobalt-on-container border-transparent",
18
+ success:
19
+ "bg-malachite-container text-malachite-on-container border-transparent",
20
+ warning:
21
+ "bg-gold-container text-gold-on-container border-transparent",
22
+ destructive: "bg-card text-destructive border-destructive/40",
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ variant: "default",
27
+ },
28
+ },
29
+ );
30
+
31
+ export interface AlertProps
32
+ extends React.HTMLAttributes<HTMLDivElement>,
33
+ VariantProps<typeof alertVariants> {
34
+ class?: string;
35
+ className?: string;
36
+ }
37
+
38
+ export function Alert({
39
+ variant,
40
+ class: astroClass,
41
+ className,
42
+ children,
43
+ ...props
44
+ }: AlertProps) {
45
+ return (
46
+ <div
47
+ role="alert"
48
+ data-slot="alert"
49
+ className={cn(alertVariants({ variant }), astroClass, className)}
50
+ {...props}
51
+ >
52
+ {children}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export interface AlertTitleProps
58
+ extends React.HTMLAttributes<HTMLParagraphElement> {
59
+ class?: string;
60
+ className?: string;
61
+ }
62
+
63
+ export function AlertTitle({
64
+ class: astroClass,
65
+ className,
66
+ children,
67
+ ...props
68
+ }: AlertTitleProps) {
69
+ return (
70
+ <p
71
+ data-slot="alert-title"
72
+ className={cn("mb-1 font-medium leading-none", astroClass, className)}
73
+ {...props}
74
+ >
75
+ {children}
76
+ </p>
77
+ );
78
+ }
79
+
80
+ export function AlertDescription({
81
+ class: astroClass,
82
+ className,
83
+ children,
84
+ ...props
85
+ }: AlertTitleProps) {
86
+ return (
87
+ <div
88
+ data-slot="alert-description"
89
+ className={cn(
90
+ "text-body-sm [&_p]:leading-relaxed opacity-90",
91
+ astroClass,
92
+ className,
93
+ )}
94
+ {...props}
95
+ >
96
+ {children}
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,69 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * Avatar — dependency-light image avatar with an initials fallback.
8
+ *
9
+ * NOT Radix: kept minimal and SSR-friendly. Renders the image when
10
+ * `src` is set (falling back to initials on load error via a tiny
11
+ * inline handler), otherwise renders the `fallback` initials. Colours
12
+ * come from the semantic `muted` surface token — no hex.
13
+ */
14
+ export const avatarVariants = cva(
15
+ "relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground font-medium select-none",
16
+ {
17
+ variants: {
18
+ size: {
19
+ sm: "h-8 w-8 text-caption",
20
+ md: "h-10 w-10 text-body-sm",
21
+ lg: "h-14 w-14 text-body",
22
+ },
23
+ },
24
+ defaultVariants: {
25
+ size: "md",
26
+ },
27
+ },
28
+ );
29
+
30
+ export interface AvatarProps extends VariantProps<typeof avatarVariants> {
31
+ /** Image URL. When omitted, the initials fallback renders. */
32
+ src?: string;
33
+ /** Alt text for the image / accessible label. */
34
+ alt?: string;
35
+ /** Initials (or short text) shown when there's no image. */
36
+ fallback?: string;
37
+ class?: string;
38
+ className?: string;
39
+ }
40
+
41
+ export function Avatar({
42
+ size,
43
+ src,
44
+ alt,
45
+ fallback,
46
+ class: astroClass,
47
+ className,
48
+ }: AvatarProps) {
49
+ return (
50
+ <span
51
+ data-slot="avatar"
52
+ role="img"
53
+ aria-label={alt ?? fallback}
54
+ className={cn(avatarVariants({ size }), astroClass, className)}
55
+ >
56
+ {src ? (
57
+ <img
58
+ src={src}
59
+ alt={alt ?? ""}
60
+ className="h-full w-full object-cover"
61
+ loading="lazy"
62
+ data-slot="avatar-image"
63
+ />
64
+ ) : (
65
+ <span data-slot="avatar-fallback">{fallback}</span>
66
+ )}
67
+ </span>
68
+ );
69
+ }
@@ -0,0 +1,58 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * Badge — shadcn CVA pattern mapped onto the Five-African-Minerals
8
+ * container tokens. Variants name the semantic role, not the colour, so
9
+ * a brand re-skin lands everywhere at once.
10
+ */
11
+ export const badgeVariants = cva(
12
+ "inline-flex items-center rounded-full font-medium",
13
+ {
14
+ variants: {
15
+ variant: {
16
+ default: "bg-muted text-muted-foreground",
17
+ primary: "bg-cobalt-container text-cobalt-on-container",
18
+ success: "bg-malachite-container text-malachite-on-container",
19
+ warning: "bg-gold-container text-gold-on-container",
20
+ info: "bg-cobalt-container text-cobalt-on-container",
21
+ accent: "bg-terracotta-container text-terracotta-on-container",
22
+ premium: "bg-tanzanite-container text-tanzanite-on-container",
23
+ outline: "border border-border text-foreground",
24
+ },
25
+ size: {
26
+ sm: "px-2 py-0.5 text-caption",
27
+ md: "px-3 py-1 text-body-sm",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "md",
33
+ },
34
+ },
35
+ );
36
+
37
+ export interface BadgeProps extends VariantProps<typeof badgeVariants> {
38
+ class?: string;
39
+ className?: string;
40
+ children?: React.ReactNode;
41
+ }
42
+
43
+ export function Badge({
44
+ variant,
45
+ size,
46
+ class: astroClass,
47
+ className,
48
+ children,
49
+ }: BadgeProps) {
50
+ return (
51
+ <span
52
+ className={cn(badgeVariants({ variant, size }), astroClass, className)}
53
+ data-slot="badge"
54
+ >
55
+ {children}
56
+ </span>
57
+ );
58
+ }
@@ -0,0 +1,114 @@
1
+ import * as React from "react";
2
+ import { cva, type VariantProps } from "class-variance-authority";
3
+
4
+ import { cn } from "../lib/utils";
5
+
6
+ /**
7
+ * Button — shadcn CVA pattern (the source the Nyuchi Design System is
8
+ * built on), mapped onto the Five-African-Minerals semantic tokens.
9
+ *
10
+ * Astro adaptation: renders an <a> when `href` is set, otherwise a
11
+ * <button>. Touch targets follow the Ubuntu checklist — 56px (h-14)
12
+ * for the large size, 48px (h-12) minimum — for outdoor, all-ages use.
13
+ */
14
+ export const buttonVariants = cva(
15
+ "inline-flex items-center justify-center gap-2 font-medium rounded-full transition-all duration-200 ease-soft outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed",
16
+ {
17
+ variants: {
18
+ variant: {
19
+ primary: "bg-primary text-primary-foreground hover:opacity-90",
20
+ secondary: "bg-foreground text-background hover:opacity-90",
21
+ outline:
22
+ "border border-foreground text-foreground hover:bg-foreground hover:text-background",
23
+ ghost: "text-foreground hover:bg-muted",
24
+ },
25
+ size: {
26
+ sm: "h-12 px-4 text-body-sm",
27
+ md: "h-12 px-6 text-body",
28
+ lg: "h-14 px-8 text-body-lg",
29
+ },
30
+ fullWidth: {
31
+ true: "w-full",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "primary",
36
+ size: "md",
37
+ },
38
+ },
39
+ );
40
+
41
+ const ArrowRight = () => (
42
+ <svg
43
+ className="h-4 w-4"
44
+ fill="none"
45
+ stroke="currentColor"
46
+ viewBox="0 0 24 24"
47
+ aria-hidden="true"
48
+ >
49
+ <path
50
+ strokeLinecap="round"
51
+ strokeLinejoin="round"
52
+ strokeWidth={1.75}
53
+ d="M17 8l4 4m0 0l-4 4m4-4H3"
54
+ />
55
+ </svg>
56
+ );
57
+
58
+ export interface ButtonProps extends VariantProps<typeof buttonVariants> {
59
+ /** Render as a link when set. */
60
+ href?: string;
61
+ /** Opens in a new tab with safe rel. */
62
+ external?: boolean;
63
+ /** Append a trailing arrow glyph (keeps the SVG out of pages). */
64
+ arrow?: boolean;
65
+ type?: "button" | "submit" | "reset";
66
+ disabled?: boolean;
67
+ class?: string;
68
+ className?: string;
69
+ children?: React.ReactNode;
70
+ }
71
+
72
+ export function Button({
73
+ variant,
74
+ size,
75
+ fullWidth,
76
+ href,
77
+ external,
78
+ arrow,
79
+ type = "button",
80
+ disabled,
81
+ class: astroClass,
82
+ className,
83
+ children,
84
+ }: ButtonProps) {
85
+ const classes = cn(
86
+ buttonVariants({ variant, size, fullWidth }),
87
+ astroClass,
88
+ className,
89
+ );
90
+
91
+ if (href) {
92
+ const ext = external
93
+ ? { target: "_blank", rel: "noopener noreferrer" }
94
+ : {};
95
+ return (
96
+ <a href={href} className={classes} data-slot="button" {...ext}>
97
+ {children}
98
+ {arrow && <ArrowRight />}
99
+ </a>
100
+ );
101
+ }
102
+
103
+ return (
104
+ <button
105
+ type={type}
106
+ disabled={disabled}
107
+ className={classes}
108
+ data-slot="button"
109
+ >
110
+ {children}
111
+ {arrow && <ArrowRight />}
112
+ </button>
113
+ );
114
+ }