Skip to content

Commit 1bdb153

Browse files
author
Hanzo Dev
committed
feat(ui-shadcn): MarketingNav — one bar for the five properties that copied it
hanzo.id, hanzo.network, hanzo.one, sensei.group and hanzo.app each carried their own 160-line DesktopNav.tsx. Measured, they were not variations on a theme: hanzo.one vs sensei.group BYTE-IDENTICAL hanzo.network vs hanzo.app 2 lines — text-neutral-400 → text-purple-400 hanzo.network vs hanzo.id ~11 lines Five copies of one menu, drifting an accent colour at a time. So the menus become data (menus.ts), the accent becomes a prop, and there is one renderer. ROUTER-AGNOSTIC by construction, which is the part that matters. The copies hard-imported react-router-dom's <Link to>; that is precisely why hanzo.ai — a Next app using <Link href> — could never share them and grew a SIXTH nav instead. The host now passes `link`, so a Vite SPA, a Next app and a plain-anchor page use the same component. External destinations bypass it and render a guarded anchor, because a client router cannot navigate off-site — and the data carries `external` so the renderer decides from the value, not from a string check at the call site. Placed in the v5/Tailwind lane beside hanzo-shell because that is the lane these properties are on. hanzo-shell is the SIGNED-IN app chrome (billing, account, console, chat, platform); this is the signed-out marketing bar. Same repo, different job — deliberately not a second take on either. NOT sourced from @hanzo/products: that models the product FAMILY (six-product launcher, installs, per-property HEADERS), a different menu with an overlapping name. hanzo.ai's own nav proves the distinction, carrying Philosophy, Papers, Startups and Security that no product catalogue knows about. One value model per concern. 6 tests pin the data: bar order, every href absolute-or-rooted, `external` set iff the URL is absolute, no duplicate destination within a column, glyph+note only on the two featured rows. 5.9.1 -> 5.9.2 (npm latest is 5.9.1; patch forward). Co-authored-by: Hanzo Dev <dev@hanzo.ai>
1 parent 036b117 commit 1bdb153

6 files changed

Lines changed: 388 additions & 1 deletion

File tree

pkgs/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hanzo/ui-shadcn",
3-
"version": "5.9.1",
3+
"version": "5.9.2",
44
"description": "Multi-framework UI library with React, Vue, Svelte, and React Native support. Based on shadcn/ui with comprehensive framework coverage. (Formerly @hanzo/ui \u22645.x; @hanzo/ui@8+ is the @hanzo/gui-based unified lib.)",
55
"publishConfig": {
66
"registry": "https://registry.npmjs.org/",

pkgs/ui/src/navigation/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
export { HanzoHeader, AppSwitcher, UserOrgDropdown, useHanzoAuth, HanzoCommandPalette, DEFAULT_HANZO_APPS } from './hanzo-shell'
33
export type { HanzoApp, HanzoOrg, HanzoUser, HanzoShellProps, HanzoCommandItem, HanzoCommandPaletteProps } from './hanzo-shell'
44

5+
// MarketingNav – the ONE signed-out marketing bar (five properties shared five
6+
// byte-near-identical copies of it). hanzo-shell above is the SIGNED-IN chrome;
7+
// these are different jobs, not two takes on one.
8+
export { MarketingNav, HANZO_MARKETING_MENUS, isMenu as isMarketingMenu } from './marketing-nav'
9+
export type { MarketingNavProps, MarketingMenus, NavMenuSpec, NavColumn, NavLink } from './marketing-nav'
10+
511
// Navigation bar components
612
export { default as AdvancedNavigationBar } from "./advanced-navigation-bar"
713
export { default as AIModelSelectorNavigationBar } from "./ai-model-selector-navigation-bar"
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
'use client'
2+
3+
/**
4+
* MarketingNav — the ONE marketing bar the Hanzo properties render.
5+
*
6+
* It replaces five hand-maintained copies of the same 160-line `DesktopNav.tsx`
7+
* (hanzo.id, hanzo.network, hanzo.one, sensei.group, hanzo.app). Two of those were
8+
* byte-identical and two differed only in an accent colour, so the variation this
9+
* component actually needs is: the menus (data), the accent, and how the host
10+
* navigates.
11+
*
12+
* ROUTER-AGNOSTIC by construction. The copies hard-imported `react-router-dom`'s
13+
* `<Link to>`, which is why hanzo.ai — a Next app using `<Link href>` — could never
14+
* share them and grew a sixth nav instead. Here the host passes `link`, so a Vite
15+
* SPA, a Next app and a plain-anchor page all use the same component. External
16+
* destinations bypass it and render a guarded anchor, because a client router
17+
* cannot navigate off-site.
18+
*
19+
* This lives in the v5/Tailwind lane beside `hanzo-shell` because that is the lane
20+
* these properties are on. `hanzo-shell` is the SIGNED-IN app chrome (billing,
21+
* account, console, chat, platform); this is the signed-out marketing bar. Same
22+
* repo, different job — not a second implementation of either.
23+
*/
24+
import * as React from 'react'
25+
26+
import { HANZO_MARKETING_MENUS, isMenu, type MarketingMenus, type NavLink } from './menus'
27+
28+
/** How the host navigates internally — `next/link`, a router `Link`, or an anchor. */
29+
export type LinkRender = (props: {
30+
href: string
31+
className?: string
32+
onClick?: () => void
33+
children: React.ReactNode
34+
}) => React.ReactNode
35+
36+
const anchorLink: LinkRender = ({ href, className, onClick, children }) => (
37+
<a href={href} className={className} onClick={onClick}>
38+
{children}
39+
</a>
40+
)
41+
42+
export interface MarketingNavProps {
43+
/** Menus to render. Defaults to the shared Hanzo bar. */
44+
menus?: MarketingMenus
45+
/** Host navigation primitive. Defaults to a plain anchor. */
46+
link?: LinkRender
47+
/**
48+
* Brand accent, as a Tailwind colour STEM (`neutral`, `purple`, …). It is the
49+
* only thing that differed between two of the copies. Interpolating a stem into
50+
* a class name is safe here because the set is closed and declared in `safelist`
51+
* — never accept an arbitrary string from user input.
52+
*/
53+
accent?: string
54+
className?: string
55+
}
56+
57+
/** One destination. External links never go through the host router. */
58+
function Item({
59+
link,
60+
item,
61+
accent,
62+
close,
63+
}: {
64+
link: LinkRender
65+
item: NavLink
66+
accent: string
67+
close: () => void
68+
}) {
69+
const plain = 'text-sm text-neutral-300 hover:text-white transition-colors'
70+
const body = item.note ? (
71+
<span className="group flex items-start gap-2">
72+
{item.glyph ? <span className={`text-${accent}-400 text-lg`}>{item.glyph}</span> : null}
73+
<span>
74+
<span className={`text-sm text-white font-medium group-hover:text-${accent}-400 transition-colors`}>
75+
{item.label}
76+
</span>
77+
<p className="text-xs text-neutral-500">{item.note}</p>
78+
</span>
79+
</span>
80+
) : (
81+
item.label
82+
)
83+
84+
if (item.external) {
85+
return (
86+
<a href={item.href} target="_blank" rel="noopener noreferrer" className={plain}>
87+
{body}
88+
</a>
89+
)
90+
}
91+
return <>{link({ href: item.href, className: plain, onClick: close, children: body })}</>
92+
}
93+
94+
/** A dropdown, opened on click and dismissed on Escape or an outside click. */
95+
function Menu({
96+
label,
97+
columns,
98+
link,
99+
accent,
100+
}: {
101+
label: string
102+
columns: { title?: string; links: NavLink[] }[]
103+
link: LinkRender
104+
accent: string
105+
}) {
106+
const [open, setOpen] = React.useState(false)
107+
const root = React.useRef<HTMLDivElement | null>(null)
108+
const close = () => setOpen(false)
109+
110+
React.useEffect(() => {
111+
if (!open) return
112+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && close()
113+
const onDown = (e: MouseEvent) => {
114+
if (root.current && !root.current.contains(e.target as Node)) close()
115+
}
116+
document.addEventListener('keydown', onKey)
117+
document.addEventListener('mousedown', onDown)
118+
return () => {
119+
document.removeEventListener('keydown', onKey)
120+
document.removeEventListener('mousedown', onDown)
121+
}
122+
}, [open])
123+
124+
return (
125+
<div ref={root} className="relative">
126+
<button
127+
type="button"
128+
onClick={() => setOpen(!open)}
129+
aria-expanded={open}
130+
className="text-neutral-400 hover:text-white transition-colors text-sm font-medium"
131+
>
132+
{label}
133+
</button>
134+
{open ? (
135+
<div className="absolute left-0 top-full mt-3 z-50 rounded-xl border border-neutral-700/50 bg-neutral-900 p-6 shadow-xl">
136+
<div className="flex gap-8">
137+
{columns.map((col, i) => (
138+
<div key={col.title ?? i} className="min-w-[11rem]">
139+
{col.title ? (
140+
<h3 className="text-neutral-500 text-xs font-medium mb-3 uppercase tracking-wider">
141+
{col.title}
142+
</h3>
143+
) : null}
144+
<ul className="space-y-2">
145+
{col.links.map((l) => (
146+
<li key={l.href + l.label}>
147+
<Item link={link} item={l} accent={accent} close={close} />
148+
</li>
149+
))}
150+
</ul>
151+
</div>
152+
))}
153+
</div>
154+
</div>
155+
) : null}
156+
</div>
157+
)
158+
}
159+
160+
export function MarketingNav({
161+
menus = HANZO_MARKETING_MENUS,
162+
link = anchorLink,
163+
accent = 'neutral',
164+
className,
165+
}: MarketingNavProps) {
166+
return (
167+
<div className={className ?? 'hidden md:flex items-center space-x-6'}>
168+
{menus.map((item) =>
169+
isMenu(item) ? (
170+
<Menu key={item.label} label={item.label} columns={item.columns} link={link} accent={accent} />
171+
) : (
172+
<React.Fragment key={item.href}>
173+
{link({
174+
href: item.href,
175+
className: 'text-neutral-400 hover:text-white transition-colors text-sm font-medium',
176+
children: item.label,
177+
})}
178+
</React.Fragment>
179+
),
180+
)}
181+
</div>
182+
)
183+
}
184+
185+
export default MarketingNav
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// MarketingNav — the ONE signed-out marketing bar, replacing five hand-maintained
2+
// copies. Sibling to hanzo-shell (the signed-in app chrome), not a rival to it.
3+
export { MarketingNav, default } from './MarketingNav'
4+
export type { MarketingNavProps, LinkRender } from './MarketingNav'
5+
export { HANZO_MARKETING_MENUS, isMenu } from './menus'
6+
export type { MarketingMenus, NavMenuSpec, NavColumn, NavLink } from './menus'
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { HANZO_MARKETING_MENUS, isMenu, type NavLink, type NavMenuSpec } from './menus'
4+
5+
describe('isMenu', () => {
6+
it('discriminates dropdowns from plain links', () => {
7+
expect(isMenu({ label: 'Pricing', href: '/pricing' })).toBe(false)
8+
expect(isMenu({ label: 'Learn', columns: [] })).toBe(true)
9+
})
10+
})
11+
12+
describe('HANZO_MARKETING_MENUS', () => {
13+
const links = (): NavLink[] =>
14+
HANZO_MARKETING_MENUS.flatMap((i) =>
15+
isMenu(i) ? (i as NavMenuSpec).columns.flatMap((c) => c.links) : [i as NavLink],
16+
)
17+
18+
it('carries the bar the five properties rendered, in order', () => {
19+
expect(HANZO_MARKETING_MENUS.map((i) => (isMenu(i) ? i.label : i.label))).toEqual([
20+
'Meet Hanzo',
21+
'Pricing',
22+
'Learn',
23+
])
24+
})
25+
26+
it('every destination is a site-relative path or an absolute URL — never a bare word', () => {
27+
for (const l of links()) {
28+
expect(l.href, l.label).toMatch(/^(\/|https?:\/\/)/)
29+
}
30+
})
31+
32+
// The copies rendered off-site destinations as guarded anchors rather than
33+
// router links, because a client router cannot navigate off-site. Keeping the
34+
// flag honest is what lets the renderer make that choice from data alone.
35+
it('marks every absolute URL external, and no relative path', () => {
36+
for (const l of links()) {
37+
expect(Boolean(l.external), `${l.label}${l.href}`).toBe(l.href.startsWith('http'))
38+
}
39+
})
40+
41+
it('has no duplicate destination inside a single column', () => {
42+
for (const item of HANZO_MARKETING_MENUS) {
43+
if (!isMenu(item)) continue
44+
for (const col of item.columns) {
45+
const hrefs = col.links.map((l) => l.href)
46+
expect(new Set(hrefs).size, `${item.label}/${col.title}`).toBe(hrefs.length)
47+
}
48+
}
49+
})
50+
51+
it('only the featured rows carry a glyph + note', () => {
52+
const featured = links().filter((l) => l.glyph)
53+
expect(featured.map((l) => l.label)).toEqual(['Zen LM', 'Hanzo Dev'])
54+
for (const l of featured) expect(l.note, l.label).toBeTruthy()
55+
})
56+
})

0 commit comments

Comments
 (0)