@jimmy_harika/websitekit 0.4.0 → 0.5.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 CHANGED
@@ -26,6 +26,7 @@ perf moat heavy runtimes can't match.
26
26
  | `@jimmy_harika/websitekit/renderer` | `PageRenderer` / `PageFonts` (RSC-safe production render) |
27
27
  | `@jimmy_harika/websitekit/store` | zustand + zundo store |
28
28
  | `@jimmy_harika/websitekit/blocks` | Design-system-agnostic blocks — render off `--pb-*` theme tokens, no app-UI imports (this is what makes a block portable to every app) |
29
+ | `@jimmy_harika/websitekit/blocks` (v0.5 additions) | Site-system blocks for photographer-caliber templates — `NavBlock`, `HeroSlideshowBlock`, `BannerQuoteBlock`, `LogoBandBlock`, `TeaserCardsBlock`, `CategoryShowcaseBlock`, `TestimonialSliderBlock`, `SiteFooterBlock`, plus the `Reveal` scroll-reveal + shared `Slider` islands and theme `buttonStyle`/`headingCase`/`reveal` |
29
30
  | `@jimmy_harika/websitekit/catalogs/author` | Author-website catalog + templates |
30
31
 
31
32
  ## The app contract
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jimmy_harika/websitekit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Snow Labs website builder engine. Block-agnostic, curated-section. One shared builder across every Snow Labs app — engine + design-system-agnostic blocks + per-industry catalogs (author, photographer, …). Each app plugs in a catalog + theme + persistence adapter.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ /**
4
+ * BackToTop — micro client island for SiteFooterBlock. Smooth-scrolls to the
5
+ * top (instant under prefers-reduced-motion).
6
+ */
7
+ import { BODY, MUTED } from "./primitives";
8
+
9
+ export function BackToTop() {
10
+ return (
11
+ <button
12
+ type="button"
13
+ onClick={() => {
14
+ const reduced =
15
+ typeof window.matchMedia === "function" &&
16
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
17
+ window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" });
18
+ }}
19
+ className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
20
+ style={{ fontFamily: BODY, color: MUTED }}
21
+ >
22
+ Back to top ↑
23
+ </button>
24
+ );
25
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * BannerQuoteBlock — a single statement quote as a full-width band. With
3
+ * `image` it renders over the photo behind a scrim; without, a flat tinted
4
+ * band in theme tokens. Server-safe, edit-aware.
5
+ */
6
+ import { renderText, type EditPrimitives } from "../editable";
7
+ import { ACCENT, BODY, HEADING, MUTED, Section, TEXT, TINT } from "./primitives";
8
+
9
+ export interface BannerQuoteBlockProps {
10
+ quote: string;
11
+ attribution?: string;
12
+ /** Background photo — rendered behind a dark scrim with light text. */
13
+ image?: string;
14
+ align?: "center" | "split";
15
+ edit?: EditPrimitives;
16
+ }
17
+
18
+ export function BannerQuoteBlock({
19
+ quote,
20
+ attribution,
21
+ image,
22
+ align = "center",
23
+ edit,
24
+ }: BannerQuoteBlockProps) {
25
+ const onPhoto = Boolean(image);
26
+ const quoteColor = onPhoto ? "#ffffff" : TEXT;
27
+ const metaColor = onPhoto ? "rgba(255,255,255,0.75)" : MUTED;
28
+
29
+ const quoteEl = renderText(edit, {
30
+ field: "quote",
31
+ value: quote,
32
+ as: "blockquote",
33
+ placeholder: "A line you want remembered.",
34
+ multiline: true,
35
+ className:
36
+ align === "split"
37
+ ? "text-[clamp(1.4rem,3.2vw,2.25rem)] font-light italic leading-snug"
38
+ : "mx-auto max-w-3xl text-center text-[clamp(1.4rem,3.5vw,2.5rem)] font-light italic leading-snug",
39
+ style: { fontFamily: HEADING, color: quoteColor },
40
+ });
41
+
42
+ const attributionEl =
43
+ attribution || edit
44
+ ? renderText(edit, {
45
+ field: "attribution",
46
+ value: attribution ?? "",
47
+ as: "p",
48
+ placeholder: "Someone kind",
49
+ className:
50
+ align === "split"
51
+ ? "text-[11px] uppercase tracking-[0.3em]"
52
+ : "mt-6 text-center text-[11px] uppercase tracking-[0.3em]",
53
+ style: { fontFamily: BODY, color: onPhoto ? metaColor : ACCENT },
54
+ })
55
+ : null;
56
+
57
+ const body =
58
+ align === "split" ? (
59
+ <div className="mx-auto grid max-w-5xl items-end gap-8 md:grid-cols-[1fr_auto]">
60
+ <div>{quoteEl}</div>
61
+ <div className="md:pb-2">{attributionEl}</div>
62
+ </div>
63
+ ) : (
64
+ <div className="mx-auto max-w-4xl">
65
+ {quoteEl}
66
+ {attributionEl}
67
+ </div>
68
+ );
69
+
70
+ if (!onPhoto) {
71
+ return <Section style={{ background: TINT }}>{body}</Section>;
72
+ }
73
+
74
+ return (
75
+ <section className="relative w-full overflow-hidden">
76
+ <div className="absolute inset-0">
77
+ {/* eslint-disable-next-line @next/next/no-img-element */}
78
+ <img src={image} alt="" loading="lazy" className="h-full w-full object-cover" />
79
+ </div>
80
+ <div className="absolute inset-0" style={{ background: "rgba(0,0,0,0.5)" }} />
81
+ <div
82
+ className="relative px-6 sm:px-10"
83
+ style={{ paddingBlock: "calc(var(--pb-spacing, 1) * 7rem)" }}
84
+ >
85
+ {body}
86
+ </div>
87
+ </section>
88
+ );
89
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * CategoryShowcaseBlock — editorial category columns (e.g. Weddings /
3
+ * Portraits / Editorial) with staggered heights and small-caps captions.
4
+ * Even columns drop down on desktop for the staggered-editorial look.
5
+ * Server-safe, edit-aware heading.
6
+ */
7
+ import { renderText, type EditPrimitives } from "../editable";
8
+ import {
9
+ BODY,
10
+ HEADING,
11
+ HEADING_CASE,
12
+ MUTED,
13
+ RADIUS,
14
+ Section,
15
+ TEXT,
16
+ TokenButton,
17
+ } from "./primitives";
18
+
19
+ export interface CategoryShowcaseItem {
20
+ title: string;
21
+ image: string;
22
+ href?: string;
23
+ }
24
+
25
+ export interface CategoryShowcaseBlockProps {
26
+ heading?: string;
27
+ items?: CategoryShowcaseItem[];
28
+ ctaLabel?: string;
29
+ ctaHref?: string;
30
+ edit?: EditPrimitives;
31
+ }
32
+
33
+ /** Literal class strings so Tailwind's scanner picks them up. */
34
+ const GRID_COLS: Record<number, string> = {
35
+ 2: "sm:grid-cols-2",
36
+ 3: "sm:grid-cols-2 md:grid-cols-3",
37
+ 4: "sm:grid-cols-2 md:grid-cols-4",
38
+ };
39
+
40
+ export function CategoryShowcaseBlock({
41
+ heading,
42
+ items = [],
43
+ ctaLabel,
44
+ ctaHref,
45
+ edit,
46
+ }: CategoryShowcaseBlockProps) {
47
+ const cols = Math.min(Math.max(items.length, 2), 4);
48
+
49
+ return (
50
+ <Section>
51
+ {(heading || edit) &&
52
+ renderText(edit, {
53
+ field: "heading",
54
+ value: heading ?? "",
55
+ as: "h2",
56
+ placeholder: "Explore the work",
57
+ className: "mb-12 text-center text-[clamp(1.6rem,4vw,2.5rem)] font-light",
58
+ style: { fontFamily: HEADING, color: TEXT, textTransform: HEADING_CASE },
59
+ })}
60
+ {items.length === 0 ? (
61
+ <p className="py-8 text-center text-sm" style={{ color: MUTED, fontFamily: BODY }}>
62
+ Add categories (title + image).
63
+ </p>
64
+ ) : (
65
+ <div className={`grid gap-6 md:gap-8 ${GRID_COLS[cols]}`}>
66
+ {items.map((item, i) => {
67
+ const staggered = i % 2 === 1;
68
+ const figure = (
69
+ <figure className={staggered ? "md:mt-14" : undefined}>
70
+ <div
71
+ className={`w-full overflow-hidden ${staggered ? "aspect-[3/4]" : "aspect-[7/10]"}`}
72
+ style={{ borderRadius: RADIUS }}
73
+ >
74
+ {/* eslint-disable-next-line @next/next/no-img-element */}
75
+ <img
76
+ src={item.image}
77
+ alt={item.title}
78
+ loading="lazy"
79
+ className="h-full w-full object-cover transition duration-500 hover:scale-[1.03]"
80
+ />
81
+ </div>
82
+ <figcaption
83
+ className="mt-4 text-center text-[11px] uppercase tracking-[0.3em]"
84
+ style={{ fontFamily: BODY, color: TEXT }}
85
+ >
86
+ {item.title}
87
+ </figcaption>
88
+ </figure>
89
+ );
90
+ return item.href ? (
91
+ <a key={i} href={item.href} className="block">
92
+ {figure}
93
+ </a>
94
+ ) : (
95
+ <div key={i}>{figure}</div>
96
+ );
97
+ })}
98
+ </div>
99
+ )}
100
+ {ctaLabel && ctaHref && (
101
+ <div className="mt-12 text-center">
102
+ <TokenButton href={ctaHref}>{ctaLabel}</TokenButton>
103
+ </div>
104
+ )}
105
+ </Section>
106
+ );
107
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * HeroSlideshowBlock — full-bleed crossfade hero ("fade") or center-dominant
3
+ * filmstrip with peeking neighbors ("filmstrip"). Server-safe: the images and
4
+ * copy render here; index state lives in the shared <Slider> island.
5
+ */
6
+ import { renderText, type EditPrimitives } from "../editable";
7
+ import { BODY, HEADING, HEADING_CASE, MUTED, RADIUS, TEXT } from "./primitives";
8
+ import { Slider } from "./slider";
9
+
10
+ export interface HeroSlideshowBlockProps {
11
+ images: string[];
12
+ headline?: string;
13
+ sub?: string;
14
+ align?: "center" | "left" | "bottom-left";
15
+ height?: "full" | "tall";
16
+ variant?: "fade" | "filmstrip";
17
+ /** Auto-advance every ~5s (off by default; disabled under reduced motion). */
18
+ autoplay?: boolean;
19
+ edit?: EditPrimitives;
20
+ }
21
+
22
+ const ALIGN: Record<NonNullable<HeroSlideshowBlockProps["align"]>, string> = {
23
+ center: "items-center justify-center text-center",
24
+ left: "items-center justify-start text-left",
25
+ "bottom-left": "items-end justify-start pb-16 text-left",
26
+ };
27
+
28
+ export function HeroSlideshowBlock({
29
+ images,
30
+ headline,
31
+ sub,
32
+ align = "center",
33
+ height = "full",
34
+ variant = "fade",
35
+ autoplay = false,
36
+ edit,
37
+ }: HeroSlideshowBlockProps) {
38
+ const shots = images ?? [];
39
+ const autoplayMs = autoplay ? 5000 : 0;
40
+
41
+ if (shots.length === 0) {
42
+ return (
43
+ <section className="flex min-h-[40vh] items-center justify-center px-6">
44
+ <p className="text-sm" style={{ color: MUTED, fontFamily: BODY }}>
45
+ Add slideshow images.
46
+ </p>
47
+ </section>
48
+ );
49
+ }
50
+
51
+ if (variant === "filmstrip") {
52
+ return (
53
+ <section
54
+ className="w-full overflow-hidden"
55
+ style={{ paddingBlock: "calc(var(--pb-spacing, 1) * 4rem)" }}
56
+ >
57
+ {(headline || edit) && (
58
+ <div className="mx-auto mb-10 max-w-3xl px-6 text-center">
59
+ {renderText(edit, {
60
+ field: "headline",
61
+ value: headline ?? "",
62
+ as: "h1",
63
+ placeholder: "A headline for your work",
64
+ className: "text-[clamp(2rem,5vw,3.5rem)] font-light leading-tight",
65
+ style: { fontFamily: HEADING, color: TEXT, textTransform: HEADING_CASE },
66
+ })}
67
+ {(sub || edit) &&
68
+ renderText(edit, {
69
+ field: "sub",
70
+ value: sub ?? "",
71
+ as: "p",
72
+ placeholder: "A short supporting line.",
73
+ className: "mt-4 text-base leading-relaxed",
74
+ style: { fontFamily: BODY, color: MUTED },
75
+ })}
76
+ </div>
77
+ )}
78
+ <Slider
79
+ variant="track"
80
+ arrows="below"
81
+ autoplayMs={autoplayMs}
82
+ slideWidth="min(72vw, 880px)"
83
+ label={headline || "Featured work"}
84
+ slides={shots.map((src, i) => (
85
+ // eslint-disable-next-line @next/next/no-img-element
86
+ <img
87
+ key={i}
88
+ src={src}
89
+ alt=""
90
+ loading={i === 0 ? "eager" : "lazy"}
91
+ className="aspect-[3/2] w-full object-cover"
92
+ style={{ borderRadius: RADIUS }}
93
+ />
94
+ ))}
95
+ />
96
+ </section>
97
+ );
98
+ }
99
+
100
+ // fade — overlay copy defines the container height.
101
+ const minH = height === "tall" ? "min-h-[70vh]" : "min-h-[100svh]";
102
+ return (
103
+ <Slider
104
+ variant="fade"
105
+ arrows="overlay"
106
+ autoplayMs={autoplayMs}
107
+ label={headline || "Slideshow"}
108
+ slides={shots.map((src, i) => (
109
+ // eslint-disable-next-line @next/next/no-img-element
110
+ <img
111
+ key={i}
112
+ src={src}
113
+ alt=""
114
+ loading={i === 0 ? "eager" : "lazy"}
115
+ fetchPriority={i === 0 ? "high" : "auto"}
116
+ className="h-full w-full object-cover"
117
+ />
118
+ ))}
119
+ overlay={
120
+ <>
121
+ <div
122
+ className="absolute inset-0"
123
+ style={{ background: "linear-gradient(to bottom, rgba(0,0,0,0.25), rgba(0,0,0,0.45))" }}
124
+ />
125
+ <div className={`relative flex ${minH} w-full px-6 sm:px-14 ${ALIGN[align]}`}>
126
+ <div className="max-w-3xl">
127
+ {(headline || edit) &&
128
+ renderText(edit, {
129
+ field: "headline",
130
+ value: headline ?? "",
131
+ as: "h1",
132
+ placeholder: "A headline for your work",
133
+ className: "text-[clamp(2.25rem,7vw,5rem)] font-light leading-[1.02] text-white",
134
+ style: { fontFamily: HEADING, textTransform: HEADING_CASE },
135
+ })}
136
+ {(sub || edit) &&
137
+ renderText(edit, {
138
+ field: "sub",
139
+ value: sub ?? "",
140
+ as: "p",
141
+ placeholder: "A short supporting line.",
142
+ className: "mt-5 max-w-xl text-[13px] uppercase tracking-[0.3em] text-white/80",
143
+ style: { fontFamily: BODY },
144
+ })}
145
+ </div>
146
+ </div>
147
+ </>
148
+ }
149
+ />
150
+ );
151
+ }
@@ -10,15 +10,26 @@ export {
10
10
  ACCENT,
11
11
  BG,
12
12
  BODY,
13
+ BTN_BG,
14
+ BTN_BORDER,
15
+ BTN_FG,
16
+ HAIRLINE,
13
17
  HEADING,
18
+ HEADING_CASE,
14
19
  MUTED,
15
20
  RADIUS,
16
21
  TEXT,
22
+ TINT,
17
23
  Section,
18
24
  Eyebrow,
19
25
  AccentLink,
26
+ TokenButton,
20
27
  } from "./primitives";
21
28
 
29
+ // v0.5 system layer — reveal + shared slider island
30
+ export { Reveal, type RevealProps } from "./reveal";
31
+ export { Slider, type SliderProps } from "./slider";
32
+
22
33
  export { AuthorHero, type AuthorHeroProps } from "./author-hero";
23
34
  export { AuthorBio, type AuthorBioProps } from "./author-bio";
24
35
  export { BookSpotlight, type BookSpotlightProps } from "./book-spotlight";
@@ -30,3 +41,32 @@ export { Form, type FormProps } from "./form";
30
41
  export { Pricing, type PricingProps } from "./pricing";
31
42
  export { Testimonials, type TestimonialsProps } from "./testimonials";
32
43
  export { Faq, type FaqProps } from "./faq";
44
+
45
+ // v0.5 blocks — the Pixieset-caliber photographer-template layer
46
+ export { NavBlock, type NavBlockProps, type NavLinkItem } from "./nav";
47
+ export { NavMenu } from "./nav-menu";
48
+ export {
49
+ HeroSlideshowBlock,
50
+ type HeroSlideshowBlockProps,
51
+ } from "./hero-slideshow";
52
+ export { BannerQuoteBlock, type BannerQuoteBlockProps } from "./banner-quote";
53
+ export { LogoBandBlock, type LogoBandBlockProps, type LogoBandItem } from "./logo-band";
54
+ export { TeaserCardsBlock, type TeaserCardsBlockProps, type TeaserCardItem } from "./teaser-cards";
55
+ export {
56
+ CategoryShowcaseBlock,
57
+ type CategoryShowcaseBlockProps,
58
+ type CategoryShowcaseItem,
59
+ } from "./category-showcase";
60
+ export {
61
+ TestimonialSliderBlock,
62
+ type TestimonialSliderBlockProps,
63
+ type TestimonialSlideItem,
64
+ } from "./testimonial-slider";
65
+ export {
66
+ SiteFooterBlock,
67
+ type SiteFooterBlockProps,
68
+ type SiteFooterNavLink,
69
+ type SiteFooterSocial,
70
+ type SiteFooterSocialKind,
71
+ } from "./site-footer";
72
+ export { BackToTop } from "./back-to-top";
@@ -0,0 +1,62 @@
1
+ /**
2
+ * LogoBandBlock — press / "as seen in" strip on a tinted band. Items render as
3
+ * grayscale image logos when `image` is set, otherwise as letterspaced
4
+ * small-caps text names. Server-safe, edit-aware heading.
5
+ */
6
+ import { renderText, type EditPrimitives } from "../editable";
7
+ import { ACCENT, BODY, MUTED, Section } from "./primitives";
8
+
9
+ export interface LogoBandItem {
10
+ name: string;
11
+ image?: string;
12
+ }
13
+
14
+ export interface LogoBandBlockProps {
15
+ heading?: string;
16
+ items?: LogoBandItem[];
17
+ edit?: EditPrimitives;
18
+ }
19
+
20
+ export function LogoBandBlock({ heading, items = [], edit }: LogoBandBlockProps) {
21
+ return (
22
+ <Section tint style={{ paddingBlock: "calc(var(--pb-spacing, 1) * 3rem)" }}>
23
+ {(heading || edit) &&
24
+ renderText(edit, {
25
+ field: "heading",
26
+ value: heading ?? "",
27
+ as: "p",
28
+ placeholder: "As seen in",
29
+ className: "mb-8 text-center text-[11px] uppercase tracking-[0.3em]",
30
+ style: { fontFamily: BODY, color: ACCENT },
31
+ })}
32
+ {items.length === 0 ? (
33
+ <p className="text-center text-sm" style={{ color: MUTED, fontFamily: BODY }}>
34
+ Add logos or publication names.
35
+ </p>
36
+ ) : (
37
+ <ul className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
38
+ {items.map((item, i) => (
39
+ <li key={i} className="flex items-center">
40
+ {item.image ? (
41
+ // eslint-disable-next-line @next/next/no-img-element
42
+ <img
43
+ src={item.image}
44
+ alt={item.name}
45
+ loading="lazy"
46
+ className="h-7 w-auto object-contain opacity-70 grayscale"
47
+ />
48
+ ) : (
49
+ <span
50
+ className="text-[13px] uppercase tracking-[0.25em]"
51
+ style={{ fontFamily: BODY, color: MUTED }}
52
+ >
53
+ {item.name}
54
+ </span>
55
+ )}
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ )}
60
+ </Section>
61
+ );
62
+ }
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Mobile nav island for NavBlock — hamburger button + full-screen overlay menu.
5
+ * Only this ships JS; the desktop nav stays pure server markup. Reads the same
6
+ * `--pb-*` tokens (they inherit into the fixed overlay through the DOM tree).
7
+ */
8
+ import { useEffect, useState } from "react";
9
+ import { BG, BODY, HEADING, MUTED, TEXT } from "./primitives";
10
+ import type { NavLinkItem } from "./nav";
11
+
12
+ export function NavMenu({
13
+ links,
14
+ logoText,
15
+ light,
16
+ }: {
17
+ links: NavLinkItem[];
18
+ logoText?: string;
19
+ /** Render the trigger in light-on-dark (for transparent navs over photos). */
20
+ light?: boolean;
21
+ }) {
22
+ const [open, setOpen] = useState(false);
23
+
24
+ useEffect(() => {
25
+ if (!open) return;
26
+ const onKey = (e: KeyboardEvent) => {
27
+ if (e.key === "Escape") setOpen(false);
28
+ };
29
+ window.addEventListener("keydown", onKey);
30
+ document.body.style.overflow = "hidden";
31
+ return () => {
32
+ window.removeEventListener("keydown", onKey);
33
+ document.body.style.overflow = "";
34
+ };
35
+ }, [open]);
36
+
37
+ return (
38
+ <div className="md:hidden">
39
+ <button
40
+ type="button"
41
+ aria-label="Open menu"
42
+ aria-expanded={open}
43
+ onClick={() => setOpen(true)}
44
+ className="flex size-10 flex-col items-center justify-center gap-[5px]"
45
+ >
46
+ <span className="block h-px w-5" style={{ background: light ? "#ffffff" : TEXT }} />
47
+ <span className="block h-px w-5" style={{ background: light ? "#ffffff" : TEXT }} />
48
+ </button>
49
+
50
+ {open && (
51
+ <div
52
+ className="fixed inset-0 z-50 flex flex-col overflow-y-auto px-8 py-6"
53
+ style={{ background: BG, color: TEXT }}
54
+ >
55
+ <div className="flex items-center justify-between">
56
+ <span
57
+ className="text-[13px] uppercase tracking-[0.3em]"
58
+ style={{ fontFamily: BODY, color: TEXT }}
59
+ >
60
+ {logoText ?? ""}
61
+ </span>
62
+ <button
63
+ type="button"
64
+ aria-label="Close menu"
65
+ onClick={() => setOpen(false)}
66
+ className="flex size-10 items-center justify-center text-2xl font-light"
67
+ style={{ color: TEXT }}
68
+ >
69
+ ×
70
+ </button>
71
+ </div>
72
+ <nav className="mt-12 flex flex-1 flex-col gap-6">
73
+ {links.map((link, i) => (
74
+ <div key={i}>
75
+ <a
76
+ href={link.href || "#"}
77
+ onClick={() => setOpen(false)}
78
+ className="text-3xl font-light"
79
+ style={{ fontFamily: HEADING, color: TEXT }}
80
+ >
81
+ {link.label}
82
+ </a>
83
+ {link.children && link.children.length > 0 && (
84
+ <div className="mt-3 flex flex-col gap-2 pl-1">
85
+ {link.children.map((child, j) => (
86
+ <a
87
+ key={j}
88
+ href={child.href || "#"}
89
+ onClick={() => setOpen(false)}
90
+ className="text-[13px] uppercase tracking-[0.2em]"
91
+ style={{ fontFamily: BODY, color: MUTED }}
92
+ >
93
+ {child.label}
94
+ </a>
95
+ ))}
96
+ </div>
97
+ )}
98
+ </div>
99
+ ))}
100
+ </nav>
101
+ </div>
102
+ )}
103
+ </div>
104
+ );
105
+ }