@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nyuchi Africa (Pvt) Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @bundu/ui
2
+
3
+ The **marketing UI kit** for the Bundu Ecosystem — Nyuchi's implementation of the
4
+ [Mzizi](https://mzizi.dev) design system. It's the shared component layer behind the
5
+ marketing sites (bundu, nyuchi, mukoko): editorial Astro building blocks plus a set of
6
+ shadcn-style React primitives, all mapped onto the **seven African minerals** and the
7
+ semantic token system.
8
+
9
+ - **Astro marketing components** — `Hero`, `Section`, `SectionHeader`, `Container`,
10
+ `MineralStrip`, `Icon`, `SocialIcon`, and `Breadcrumb` (emits valid schema.org
11
+ `BreadcrumbList` JSON-LD for Google rich results).
12
+ - **shadcn CVA + `cn()` React primitives** — `Button`, `Card`, `Badge`, `Input`,
13
+ `Textarea`, `Select`, `Label`, `Alert`, `Avatar`, `Separator`, `Skeleton`, `Switch`,
14
+ `Checkbox`, `Tabs`, `Tooltip`.
15
+ - **Option-A styling** — one canonical `styles/globals.css` (all seven minerals, light +
16
+ dark, semantic tokens, component/utility classes) plus tiny `brand-*.css` overlays that
17
+ swap the brand primary, and a `tailwind-preset.mjs` that exposes the utility classes.
18
+
19
+ Every value flows through CSS custom properties / Tailwind tokens — **never a raw hex in
20
+ source**. All seven minerals are valid tokens: `cobalt`, `tanzanite`, `malachite`, `gold`,
21
+ `terracotta`, `sodalite`, `copper`.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ pnpm add @bundu/ui
27
+ # peer deps (for the React primitives)
28
+ pnpm add react react-dom
29
+ ```
30
+
31
+ ## Quick usage
32
+
33
+ **1. Styles** — import the canonical tokens, then the brand overlay for your site, into
34
+ your global stylesheet (after `@import "tailwindcss";`):
35
+
36
+ ```css
37
+ @import "tailwindcss";
38
+ @import "@bundu/ui/styles/globals.css";
39
+ @import "@bundu/ui/styles/brand-nyuchi.css"; /* or brand-bundu / brand-mukoko */
40
+ ```
41
+
42
+ **2. Tailwind** — add the preset so the mineral + semantic utility classes exist:
43
+
44
+ ```js
45
+ // tailwind.config.mjs
46
+ import preset from "@bundu/ui/tailwind-preset";
47
+
48
+ export default {
49
+ presets: [preset],
50
+ content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
51
+ };
52
+ ```
53
+
54
+ **3. Components:**
55
+
56
+ ```astro
57
+ ---
58
+ import Hero from "@bundu/ui/Hero.astro";
59
+ import Container from "@bundu/ui/Container.astro";
60
+ import MineralStrip from "@bundu/ui/MineralStrip.astro";
61
+ import Breadcrumb from "@bundu/ui/Breadcrumb.astro";
62
+ import { deriveBreadcrumbs } from "@bundu/ui";
63
+
64
+ const crumbs = deriveBreadcrumbs(Astro.url.pathname, { about: "About" }, "Nyuchi");
65
+ ---
66
+
67
+ <MineralStrip />
68
+ <Hero title="Build in the open" subtitle="Bundu Ecosystem" />
69
+ <Container>
70
+ <Breadcrumb items={crumbs} origin={Astro.site?.toString()} />
71
+ </Container>
72
+ ```
73
+
74
+ ```tsx
75
+ import { Button } from "@bundu/ui/ui/button";
76
+ import { Alert, AlertTitle } from "@bundu/ui/ui/alert";
77
+
78
+ export function CTA() {
79
+ return (
80
+ <>
81
+ <Alert variant="success">
82
+ <AlertTitle>Saved</AlertTitle>
83
+ </Alert>
84
+ <Button variant="primary" href="/start" arrow>
85
+ Get started
86
+ </Button>
87
+ </>
88
+ );
89
+ }
90
+ ```
91
+
92
+ See [BUILDING.md](./BUILDING.md) for the full toolchain — the mzizi MCP, the shadcn CLI, the
93
+ seven minerals, and the no-raw-hex rule.
94
+
95
+ ## License
96
+
97
+ [MIT](../../LICENSE) © Nyuchi Africa (Pvt) Ltd
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@bundu/ui",
3
+ "version": "0.1.0",
4
+ "description": "The marketing UI kit for the Bundu Ecosystem — Nyuchi's implementation of the Mzizi design system. Astro marketing components (Hero, Section, MineralStrip, Container, Breadcrumb with valid BreadcrumbList JSON-LD, …) plus shadcn CVA + cn() React primitives mapped onto the seven-African-mineral tokens, a canonical globals.css, tiny brand overrides, and a Tailwind preset.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Nyuchi Africa (Pvt) Ltd",
8
+ "homepage": "https://github.com/nyuchi/packages-ui#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/nyuchi/packages-ui.git",
12
+ "directory": "packages/bundu-ui"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/nyuchi/packages-ui/issues"
16
+ },
17
+ "main": "./src/index.ts",
18
+ "types": "./src/index.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/index.ts",
22
+ "import": "./src/index.ts"
23
+ },
24
+ "./ui/button": "./src/ui/button.tsx",
25
+ "./ui/card": "./src/ui/card.tsx",
26
+ "./ui/badge": "./src/ui/badge.tsx",
27
+ "./ui/input": "./src/ui/input.tsx",
28
+ "./ui/textarea": "./src/ui/textarea.tsx",
29
+ "./ui/select": "./src/ui/select.tsx",
30
+ "./ui/label": "./src/ui/label.tsx",
31
+ "./ui/alert": "./src/ui/alert.tsx",
32
+ "./ui/avatar": "./src/ui/avatar.tsx",
33
+ "./ui/separator": "./src/ui/separator.tsx",
34
+ "./ui/skeleton": "./src/ui/skeleton.tsx",
35
+ "./ui/switch": "./src/ui/switch.tsx",
36
+ "./ui/checkbox": "./src/ui/checkbox.tsx",
37
+ "./ui/tabs": "./src/ui/tabs.tsx",
38
+ "./ui/tooltip": "./src/ui/tooltip.tsx",
39
+ "./Hero.astro": "./src/Hero.astro",
40
+ "./Section.astro": "./src/Section.astro",
41
+ "./SectionHeader.astro": "./src/SectionHeader.astro",
42
+ "./MineralStrip.astro": "./src/MineralStrip.astro",
43
+ "./Container.astro": "./src/Container.astro",
44
+ "./Icon.astro": "./src/Icon.astro",
45
+ "./SocialIcon.astro": "./src/SocialIcon.astro",
46
+ "./Breadcrumb.astro": "./src/Breadcrumb.astro",
47
+ "./lib/utils": "./src/lib/utils.ts",
48
+ "./styles/globals.css": "./styles/globals.css",
49
+ "./styles/brand-bundu.css": "./styles/brand-bundu.css",
50
+ "./styles/brand-nyuchi.css": "./styles/brand-nyuchi.css",
51
+ "./styles/brand-mukoko.css": "./styles/brand-mukoko.css",
52
+ "./tailwind-preset": "./tailwind-preset.mjs"
53
+ },
54
+ "dependencies": {
55
+ "class-variance-authority": "^0.7.1",
56
+ "clsx": "^2.1.1",
57
+ "tailwind-merge": "^3.0.2"
58
+ },
59
+ "peerDependencies": {
60
+ "react": "^19.0.0",
61
+ "react-dom": "^19.0.0"
62
+ },
63
+ "files": [
64
+ "src",
65
+ "styles",
66
+ "tailwind-preset.mjs"
67
+ ],
68
+ "publishConfig": {
69
+ "access": "public"
70
+ },
71
+ "keywords": [
72
+ "bundu",
73
+ "mzizi",
74
+ "design-system",
75
+ "marketing",
76
+ "astro",
77
+ "ui"
78
+ ]
79
+ }
@@ -0,0 +1,105 @@
1
+ ---
2
+ /**
3
+ * Breadcrumb — visual nav + BreadcrumbList JSON-LD.
4
+ *
5
+ * Renders an `<nav aria-label="Breadcrumb">` with an ordered list
6
+ * of links, and emits a `BreadcrumbList` JSON-LD block so Google
7
+ * shows breadcrumb rich snippets in SERPs. Both halves come from
8
+ * the same `items` array — keeps the visible UI and the structured
9
+ * data in sync by construction.
10
+ *
11
+ * Convention: the final item is the current page. If its `url` is
12
+ * omitted (or matches the current URL), it renders as plain text
13
+ * with `aria-current="page"` instead of an anchor.
14
+ *
15
+ * The component renders nothing when fewer than two items are
16
+ * provided — a single-item trail is just "Home" with no value to
17
+ * the user or to Google.
18
+ */
19
+
20
+ import type { BreadcrumbItem } from "./breadcrumbs";
21
+
22
+ interface Props {
23
+ items: BreadcrumbItem[];
24
+ /** Fully-qualified origin used to expand site-relative `url`s
25
+ * into absolute URLs in the JSON-LD payload (Google requires
26
+ * absolute). Pass `Astro.site.toString()` or the app's domain
27
+ * constant. Falls back to the runtime origin when omitted, which
28
+ * works at request time but not at build time. */
29
+ origin?: string;
30
+ /** Extra classes for the wrapping <nav>. Spacing defaults assume
31
+ * the breadcrumb sits above the page hero or content. */
32
+ class?: string;
33
+ }
34
+
35
+ const { items, origin, class: extraClass } = Astro.props;
36
+
37
+ const shouldRender = items.length >= 2;
38
+
39
+ function toAbsolute(url: string): string {
40
+ if (/^https?:\/\//i.test(url)) return url;
41
+ const base = origin ?? Astro.site?.toString() ?? Astro.url.origin;
42
+ const baseTrimmed = base.replace(/\/$/, "");
43
+ const path = url.startsWith("/") ? url : `/${url}`;
44
+ return `${baseTrimmed}${path}`;
45
+ }
46
+
47
+ const jsonLd = shouldRender
48
+ ? {
49
+ "@context": "https://schema.org",
50
+ "@type": "BreadcrumbList",
51
+ itemListElement: items.map((item, index) => ({
52
+ "@type": "ListItem",
53
+ position: index + 1,
54
+ name: item.name,
55
+ // Schema.org requires an absolute URL on every item including
56
+ // the last; fall back to the current page URL when an item
57
+ // has no explicit href.
58
+ item: toAbsolute(item.url ?? Astro.url.pathname),
59
+ })),
60
+ }
61
+ : null;
62
+ ---
63
+
64
+ {shouldRender && (
65
+ <>
66
+ <nav
67
+ aria-label="Breadcrumb"
68
+ class:list={["text-body-sm text-muted-foreground", extraClass]}
69
+ >
70
+ <ol class="flex flex-wrap items-center gap-2">
71
+ {items.map((item, index) => {
72
+ const isLast = index === items.length - 1;
73
+ return (
74
+ <>
75
+ <li
76
+ class:list={[
77
+ isLast ? "text-foreground truncate" : undefined,
78
+ ]}
79
+ >
80
+ {isLast || !item.url ? (
81
+ <span aria-current={isLast ? "page" : undefined}>
82
+ {item.name}
83
+ </span>
84
+ ) : (
85
+ <a
86
+ href={item.url}
87
+ class="hover:text-foreground transition-colors"
88
+ >
89
+ {item.name}
90
+ </a>
91
+ )}
92
+ </li>
93
+ {!isLast && <li aria-hidden="true">/</li>}
94
+ </>
95
+ );
96
+ })}
97
+ </ol>
98
+ </nav>
99
+ <script
100
+ type="application/ld+json"
101
+ is:inline
102
+ set:html={JSON.stringify(jsonLd)}
103
+ />
104
+ </>
105
+ )}
@@ -0,0 +1,39 @@
1
+ ---
2
+ /**
3
+ * Container — the max-width content wrapper used across the Bundu
4
+ * Ecosystem sites. Centres content and applies the standard responsive
5
+ * horizontal padding, so pages stop hand-rolling
6
+ * `<div class="max-w-7xl mx-auto px-6 sm:px-8 lg:px-10">`.
7
+ *
8
+ * Sizes map onto the design-system container utilities:
9
+ * - `default` → `.container-custom` (max-w-7xl wide grid column)
10
+ * - `narrow` → `.container-narrow` (max-w-narrow editorial column)
11
+ * - `prose` → `.container-prose` (max-w-3xl reading measure)
12
+ *
13
+ * The utility classes live in the canonical globals.css
14
+ * (@layer components), so no hex or raw spacing values here.
15
+ */
16
+ interface Props {
17
+ /** Content width. Defaults to the wide grid column. */
18
+ size?: "default" | "narrow" | "prose";
19
+ /** Render element (defaults to a plain <div>). */
20
+ as?: "div" | "section" | "main" | "article";
21
+ id?: string;
22
+ class?: string;
23
+ }
24
+
25
+ const { size = "default", as = "div", id, class: className } = Astro.props;
26
+
27
+ const sizeClass = {
28
+ default: "container-custom",
29
+ narrow: "container-narrow",
30
+ prose: "container-prose",
31
+ }[size];
32
+
33
+ const Tag = as;
34
+ const classes = [sizeClass, className].filter(Boolean).join(" ");
35
+ ---
36
+
37
+ <Tag class={classes} id={id} data-slot="container">
38
+ <slot />
39
+ </Tag>
package/src/Hero.astro ADDED
@@ -0,0 +1,101 @@
1
+ ---
2
+ /**
3
+ * Hero — shared across all Bundu Ecosystem apps (was copy-pasted
4
+ * byte-identical into nyuchi/mukoko/bundu). Anthropic-restrained
5
+ * editorial: generous whitespace, serif Display headline, narrow body,
6
+ * an eyebrow tag, and at most two pill CTAs composed from <Button>.
7
+ */
8
+ import { Button } from "@bundu/ui/ui/button";
9
+
10
+ interface CTA {
11
+ text: string;
12
+ href: string;
13
+ external?: boolean;
14
+ }
15
+
16
+ interface Props {
17
+ title: string;
18
+ subtitle?: string;
19
+ description?: string;
20
+ primaryCTA?: CTA;
21
+ secondaryCTA?: CTA;
22
+ variant?: "default" | "gradient" | "light";
23
+ align?: "center" | "start";
24
+ }
25
+
26
+ const {
27
+ title,
28
+ subtitle,
29
+ description,
30
+ primaryCTA,
31
+ secondaryCTA,
32
+ variant = "default",
33
+ align = "start",
34
+ } = Astro.props;
35
+
36
+ const sectionClasses = {
37
+ default: "bg-canvas",
38
+ light: "bg-secondary",
39
+ gradient: "gradient-hero",
40
+ };
41
+
42
+ const alignClasses = {
43
+ start: "text-left",
44
+ center: "text-center mx-auto",
45
+ };
46
+ ---
47
+
48
+ <section
49
+ class:list={["py-12 md:py-16 lg:py-20", sectionClasses[variant]]}
50
+ data-slot="hero"
51
+ >
52
+ <div class="container-custom">
53
+ <div class:list={["max-w-3xl", alignClasses[align]]}>
54
+ {subtitle && <p class="eyebrow">{subtitle}</p>}
55
+
56
+ <h1 class="font-serif text-display text-foreground text-balance">
57
+ {title}
58
+ </h1>
59
+
60
+ {
61
+ description && (
62
+ <p
63
+ class:list={[
64
+ "mt-6 text-body-lg text-muted-foreground text-pretty max-w-2xl",
65
+ align === "center" && "mx-auto",
66
+ ]}
67
+ >
68
+ {description}
69
+ </p>
70
+ )
71
+ }
72
+
73
+ {
74
+ (primaryCTA || secondaryCTA) && (
75
+ <div
76
+ class:list={[
77
+ "mt-8 flex flex-wrap items-center gap-3",
78
+ align === "center" && "justify-center",
79
+ ]}
80
+ >
81
+ {primaryCTA && (
82
+ <Button href={primaryCTA.href} external={primaryCTA.external}>
83
+ {primaryCTA.text}
84
+ </Button>
85
+ )}
86
+ {secondaryCTA && (
87
+ <Button
88
+ href={secondaryCTA.href}
89
+ external={secondaryCTA.external}
90
+ variant="ghost"
91
+ arrow
92
+ >
93
+ {secondaryCTA.text}
94
+ </Button>
95
+ )}
96
+ </div>
97
+ )
98
+ }
99
+ </div>
100
+ </div>
101
+ </section>
package/src/Icon.astro ADDED
@@ -0,0 +1,50 @@
1
+ ---
2
+ /**
3
+ * Icon — the design system's inline-SVG set, so pages and components
4
+ * never hand-roll `<svg>` paths. Stroke-based, 1.75 weight, inherits
5
+ * `currentColor` and sizes to 1em by default (override via `class`).
6
+ *
7
+ * Add a glyph by adding one entry to PATHS — keep the set curated to
8
+ * what the sites actually use rather than pulling a whole icon font.
9
+ */
10
+ interface Props {
11
+ name: keyof typeof PATHS;
12
+ /** Accessible label; omitted → decorative (aria-hidden). */
13
+ title?: string;
14
+ class?: string;
15
+ }
16
+
17
+ const PATHS = {
18
+ "arrow-right": "M17 8l4 4m0 0l-4 4m4-4H3",
19
+ "arrow-up-right": "M7 17L17 7M17 7H7M17 7v10",
20
+ check: "M5 13l4 4L19 7",
21
+ "chevron-down": "M19 9l-7 7-7-7",
22
+ "chevron-right": "M9 5l7 7-7 7",
23
+ external: "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14",
24
+ close: "M6 18L18 6M6 6l12 12",
25
+ menu: "M4 6h16M4 12h16M4 18h16",
26
+ plus: "M12 5v14M5 12h14",
27
+ minus: "M5 12h14",
28
+ } as const;
29
+
30
+ const { name, title, class: className } = Astro.props;
31
+ const decorative = !title;
32
+ ---
33
+
34
+ <svg
35
+ class={["inline-block h-[1em] w-[1em]", className].filter(Boolean).join(" ")}
36
+ viewBox="0 0 24 24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ stroke-width="1.75"
40
+ stroke-linecap="round"
41
+ stroke-linejoin="round"
42
+ data-slot="icon"
43
+ data-icon={name}
44
+ role={decorative ? undefined : "img"}
45
+ aria-hidden={decorative ? "true" : undefined}
46
+ aria-label={decorative ? undefined : title}
47
+ >
48
+ {title && <title>{title}</title>}
49
+ <path d={PATHS[name]} />
50
+ </svg>
@@ -0,0 +1,51 @@
1
+ ---
2
+ /**
3
+ * MineralStrip — the decorative seven-mineral accent bar.
4
+ *
5
+ * A full-height, ~6px bar fixed to the LEFT edge of every page,
6
+ * split into seven equal vertical segments coloured with the seven
7
+ * African-mineral tokens (top→bottom: cobalt, tanzanite, malachite,
8
+ * gold, terracotta, sodalite, copper).
9
+ *
10
+ * Purely decorative: aria-hidden and pointer-events:none, so it never
11
+ * intercepts clicks or reaches the accessibility tree. Colours come
12
+ * exclusively from the `--color-*` custom properties defined in each
13
+ * app's src/styles/global.css — no hex here.
14
+ */
15
+ const minerals = [
16
+ "cobalt",
17
+ "tanzanite",
18
+ "malachite",
19
+ "gold",
20
+ "terracotta",
21
+ "sodalite",
22
+ "copper",
23
+ ] as const;
24
+ ---
25
+
26
+ <div class="mineral-strip" aria-hidden="true" data-slot="mineral-strip">
27
+ {
28
+ minerals.map((mineral) => (
29
+ <span style={`background-color: var(--color-${mineral});`} />
30
+ ))
31
+ }
32
+ </div>
33
+
34
+ <style>
35
+ .mineral-strip {
36
+ position: fixed;
37
+ top: 0;
38
+ left: 0;
39
+ width: 6px;
40
+ height: 100dvh;
41
+ z-index: 50;
42
+ display: flex;
43
+ flex-direction: column;
44
+ pointer-events: none;
45
+ }
46
+
47
+ .mineral-strip span {
48
+ flex: 1 1 0;
49
+ display: block;
50
+ }
51
+ </style>
@@ -0,0 +1,59 @@
1
+ ---
2
+ /**
3
+ * Section — the standard page band. Wraps content in a vertical-rhythm
4
+ * section + the site container so pages stop hand-rolling
5
+ * `<section class="section ..."><div class="container-custom">`.
6
+ *
7
+ * Part of the components-only standard (docs/design-system.md): pages
8
+ * compose Section/SectionHeader, they don't repeat the layout shell.
9
+ */
10
+ interface Props {
11
+ /** Background surface token. */
12
+ bg?: "canvas" | "secondary" | "transparent";
13
+ /** Vertical rhythm — maps to the .section* utilities. */
14
+ space?: "default" | "tight" | "loose";
15
+ /** Hairline bottom border (the quiet section divider). */
16
+ border?: boolean;
17
+ /** Wrap children in `.container-custom` (default) or render full-bleed. */
18
+ container?: boolean;
19
+ id?: string;
20
+ class?: string;
21
+ }
22
+
23
+ const {
24
+ bg = "canvas",
25
+ space = "default",
26
+ border = false,
27
+ container = true,
28
+ id,
29
+ class: className,
30
+ } = Astro.props;
31
+
32
+ const spaceClass = {
33
+ default: "section",
34
+ tight: "section-tight",
35
+ loose: "section-loose",
36
+ }[space];
37
+
38
+ const bgClass = {
39
+ canvas: "bg-canvas",
40
+ secondary: "bg-secondary",
41
+ transparent: "",
42
+ }[bg];
43
+
44
+ const classes = [spaceClass, bgClass, border && "border-b border-border", className]
45
+ .filter(Boolean)
46
+ .join(" ");
47
+ ---
48
+
49
+ <section class={classes} id={id} data-slot="section">
50
+ {
51
+ container ? (
52
+ <div class="container-custom">
53
+ <slot />
54
+ </div>
55
+ ) : (
56
+ <slot />
57
+ )
58
+ }
59
+ </section>
@@ -0,0 +1,73 @@
1
+ ---
2
+ /**
3
+ * SectionHeader — the eyebrow + serif heading + lead paragraph cluster
4
+ * that recurs at the top of ~every section (23 sections, 71 eyebrows in
5
+ * nyuchi alone). Composes it once instead of hand-rolling the markup.
6
+ *
7
+ * - `as` sets the heading level for a sane document outline; `size`
8
+ * decouples the *visual* size (so an h2-level heading can render at
9
+ * text-h1 to match an existing page).
10
+ * - `title`/`description` accept plain strings; for rich headings or
11
+ * leads (a coloured `<span>`, interpolation), pass markup via the
12
+ * `title` / `description` named slots instead.
13
+ */
14
+ interface Props {
15
+ eyebrow?: string;
16
+ title?: string;
17
+ description?: string;
18
+ align?: "start" | "center";
19
+ /** Heading level for correct document outline. */
20
+ as?: "h1" | "h2" | "h3";
21
+ /** Visual size override; defaults to match `as`. */
22
+ size?: "display" | "h1" | "h2" | "h3";
23
+ /** Tailwind max-width for the block (default constrains the lead text). */
24
+ max?: string;
25
+ /** Tint the eyebrow (e.g. text-gold / text-terracotta). */
26
+ eyebrowClass?: string;
27
+ class?: string;
28
+ }
29
+
30
+ const {
31
+ eyebrow,
32
+ title,
33
+ description,
34
+ align = "start",
35
+ as = "h2",
36
+ size,
37
+ max = "max-w-2xl",
38
+ eyebrowClass,
39
+ class: className,
40
+ } = Astro.props;
41
+
42
+ const Heading = as;
43
+ const sizeClass = {
44
+ display: "text-display",
45
+ h1: "text-h1",
46
+ h2: "text-h2",
47
+ h3: "text-h3",
48
+ }[size ?? as];
49
+
50
+ const wrapper = [max, align === "center" && "mx-auto text-center", className]
51
+ .filter(Boolean)
52
+ .join(" ");
53
+ const eyebrowClasses = ["eyebrow", align === "center" && "justify-center", eyebrowClass]
54
+ .filter(Boolean)
55
+ .join(" ");
56
+
57
+ const hasDescription = description != null || Astro.slots.has("description");
58
+ ---
59
+
60
+ <div class={wrapper} data-slot="section-header">
61
+ {eyebrow && <p class={eyebrowClasses}>{eyebrow}</p>}
62
+ <Heading class={`font-serif ${sizeClass} text-foreground text-balance`}>
63
+ {title}<slot name="title" />
64
+ </Heading>
65
+ {
66
+ hasDescription && (
67
+ <p class="mt-4 text-body text-muted-foreground text-pretty">
68
+ {description}
69
+ <slot name="description" />
70
+ </p>
71
+ )
72
+ }
73
+ </div>