mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Some checks failed
ci / Detect CI change scopes (push) Successful in 0s
visual-baseline / Capture visual baselines (push) Waiting to run
landing-page-ci / Validate landing page (push) Failing after 2s
landing-page-staging / Deploy landing page to staging (push) Has been skipped
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 2s
ci / Workspace unit tests (push) Failing after 2s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 2s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
* feat(landing-page): surface Discord + X in header, restructure site footer
Two related public-chrome adjustments:
- **Header gains compact Discord + X icon buttons.** Both community
channels were previously buried in the footer, so the typical
visitor never saw them on a page-deep scroll. They now sit before
the Download / Star CTAs in `nav-side`, share the ghost-button
outline language, and stay icon-only with `aria-label` so they
read as social affordances rather than competing with the text
CTAs. At ≤1080px the icon buttons hide alongside the existing
ghost CTA, so the bar still collapses cleanly into the hamburger
panel — Star stays in the bar at every breakpoint.
- **Footer restructured into 4 columns: Products / Plugins /
Resources / Connect.** The old `Plugins / Open Design / Connect`
three-column layout muddled three different things — sister
products, the artifact catalogue, and contributor channels —
under one roof, so visitors hunting for "the other thing this
team makes" had nowhere obvious to go.
- **Products** (new) lists the team's apps: Open Design (links
to homepage) and HTML Anything. Two entries by design — adding
more products without an editorial pass would dilute the
column.
- **Plugins** mirrors the topbar `Plugins` dropdown verbatim:
Templates / Skills / Systems / Craft, with no count prefix on
Systems / Craft so it reads identically to the nav.
- **Resources** (renamed from `Open Design`) carries the
docs-style links: Official source / Quickstart / Agents locaux
/ Compare / Claude Design alternative. The old column heading
was confusing because the OD logo + brand name already sit
under the column.
- **Connect** gains an X / Twitter row pointing at
`@nexudotio`. The brand entries on this column are
contributor / community surfaces only — code, releases,
chat, social, RSS, contact form.
Implementation:
- `_components/header.tsx` — `DISCORD` and `X_TWITTER` consts at
the top alongside `REPO`. Two `<a class="nav-icon">` blocks with
inline SVG before the existing Download / Star CTAs.
- `_components/site-footer.astro` — `HTML_ANYTHING` and `NEXU_IO`
consts. `<div class="sub-footer-col">` re-ordered to put
Products first, Plugins second (no longer carries `counts.*`
values), Resources third, Connect fourth (with the new X / Twitter
row).
- `globals.css` — `.nav-icon` rule cloned from the ghost CTA's
visual language (transparent + 1px line, fills on hover) but
square (36×36 round) so it reads as a social-icon affordance.
Added `display: none` for `.nav-side .nav-icon` to the existing
≤1080px and ≤880px media queries so the icons follow the same
collapse behaviour as the Download CTA.
- `sub-pages.css` — `.sub-footer-grid` switches from
`1.6fr 1fr 1fr 1fr` to `1.4fr 1fr 1fr 1fr 1fr` (brand + 4
columns). At ≤1080px it falls back to a 3-column shape so each
column has room to breathe; at ≤720px it stays a single column
(existing behaviour).
- `i18n.ts` — adds `products`, `resources`, `xTwitter`,
`sisterProjects`, `htmlAnything`, `nexuIo` to `LandingUiCopy.footer`
(the last three are kept around even though `sisterProjects` is no
longer rendered after the column was renamed Products — they're
harmless and avoid churning the type if a future iteration brings
the Sister-projects framing back). All 17 non-English landing
locales gain translations for the new keys via the existing
`LOCALIZED_LANDING_FOOTER_COPY` map (and the `LANDING_UI_COPY_OVERRIDES`
block for `zh` / `zh-tw`). Translations were generated with
`claude-haiku-4-5` over OpenRouter, with explicit instructions
to keep "Open Design", "HTML Anything", and "X / Twitter" in
English and to render "Products" / "Resources" in sentence case
per locale convention. Spot-checked against rendered pages on
`/zh/`, `/zh-tw/`, `/ja/`, `/ko/`, `/de/`, `/fr/` (and `/ar/` for
RTL) for natural phrasing.
Validation: `pnpm --filter @open-design/landing-page typecheck` ->
0 errors / 0 warnings; local dev server smoke-tested on en root
(`/html-anything/`) and 5 locale variants (`/zh/`, `/zh-tw/`,
`/ja/`, `/de/`, `/fr/`) — header renders 2 nav-icon buttons,
footer renders 4 localized column headings in the correct order
with the right link targets.
* fix(landing-page): address PR #3230 review — locale-aware HTML Anything link + drop unused const
Two non-blocking inline review points from @PerishCode on PR #3230:
- The HTML Anything entry in the new Products column hardcoded
`https://open-design.ai/html-anything/` via a top-level
`HTML_ANYTHING` const, but `/html-anything/` is a real localized
route in this app (`pages/[locale]/html-anything/index.astro`)
and `open-design.ai` is the same site's live domain. A visitor
on `/zh/…` clicking through landed on the English route and lost
locale context, and hardcoding the production domain meant a
preview build would surface a link that bounces visitors back
to prod. Switch to `href('/html-anything/')` so the locale prefix
+ the current site's domain (resolved by `localizedHref`) are
honored, matching every other footer link.
- `NEXU_IO` was declared at the top of the component but never
referenced — leftover from an earlier iteration that listed
`nexu.io` as a Sister-projects entry before the column was
renamed Products and reduced to OD + HTML Anything. Removed.
No behavior change beyond the locale routing fix; the i18n keys
and column structure stay as they landed in the original commit.
* fix(landing-page): correct nav-icon comment to match actual responsive behaviour
The JSX comment introduced for the new Discord + X icon buttons in
PR #3230 claimed the icons "survive at narrow widths while text-only
nav items get pushed off". The CSS that shipped in the same PR does
the opposite: both `@media (max-width: 1080px)` and `@media (max-width:
880px)` blocks add `.nav-side .nav-icon { display: none; }`, so at
narrow widths the icons collapse alongside the ghost Download CTA
while the text nav <ul> moves into the hamburger panel — only the
Star CTA remains visible in the bar.
Rewrite the comment to describe the actual responsive contract so
the next reader of `header.tsx` doesn't have to cross-reference
`globals.css` to figure out which surface stays. Reviewer flag from
@PerishCode on PR #3230.
No code-path change; comment-only.
* fix(landing-page): correct sub-footer 1080px comment to describe actual 3-column grid
The CSS comment introduced for the new sub-footer grid claimed the
≤1080px breakpoint drops to "brand + 2x2 grid of columns" — but the
rule produces a 3-column grid, not a 2x2.
`.sub-footer-grid` has 5 children at this breakpoint (the brand
block + the four footer columns) and `.sub-footer-brand` carries
no `grid-column` span, so with `grid-template-columns: 1.6fr
repeat(2, 1fr)` they flow as: row 1 = brand · Products · Plugins,
row 2 = Resources · Connect · empty cell. The brand sits inline
with two columns rather than on its own, and the four content
columns are not a clean 2x2.
The layout itself is fine; only the comment misleads the next
reader about how the columns wrap. Same flavor as the `header.tsx`
icon comment fixed in 744daec — describe what the rule actually
does so the comment doesn't drift from the CSS. Reviewer flag
from @PerishCode on PR #3230.
Comment-only change.
---------
Co-authored-by: Joey-nexu <joeylee12629@gmail.com>
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
/*
|
|
* Sticky Header — static markup rendered at build time. Headroom-style
|
|
* hide/show and the live GitHub star count are attached by the tiny inline
|
|
* scripts on each Astro page, so this marketing page ships no React runtime
|
|
* to the browser.
|
|
*
|
|
* The nav links go to internal multi-page routes (`/skills/`, `/systems/`,
|
|
* `/templates/`, `/craft/`) so Google sees a real site hierarchy. Numbers
|
|
* reflect the live counts of the canonical Markdown bundles in the repo
|
|
* root and are kept in sync with `getCatalogCounts()` at build time.
|
|
*/
|
|
|
|
import {
|
|
DEFAULT_LOCALE,
|
|
getCommonCopy,
|
|
getHeaderProductMenuCopy,
|
|
localizedHref,
|
|
type HeaderCopy,
|
|
type LandingLocaleCode,
|
|
} from '../i18n';
|
|
|
|
const REPO = 'https://github.com/nexu-io/open-design';
|
|
const REPO_RELEASES = `${REPO}/releases`;
|
|
const DISCORD = 'https://discord.gg/9ptkbbqRu';
|
|
const X_TWITTER = 'https://x.com/nexudotio';
|
|
|
|
const ext = {
|
|
target: '_blank',
|
|
rel: 'noreferrer noopener',
|
|
} as const;
|
|
|
|
export interface HeaderProps {
|
|
/** Nav highlight target. `'home'` is the default for `/`. */
|
|
active?:
|
|
| 'home'
|
|
| 'product'
|
|
| 'html-anything'
|
|
| 'plugins'
|
|
/*
|
|
* `library` is kept as an alias for the dropdown trigger so older
|
|
* pages that still pass `active="library"` keep working. New pages
|
|
* should pass `active="plugins"`.
|
|
*/
|
|
| 'library'
|
|
| 'skills'
|
|
| 'systems'
|
|
| 'templates'
|
|
| 'craft'
|
|
| 'blog'
|
|
| 'tutorials'
|
|
| 'community';
|
|
/**
|
|
* Live counts from the Markdown catalogs. Required so we can never
|
|
* silently render stale fallback numbers when a caller forgets to
|
|
* thread `getCatalogCounts()` through. Header only consumes these
|
|
* four scalar fields; the homepage passes the wider `CatalogCounts`
|
|
* value (with `byMode` / `byPlatform`) by structural subtyping.
|
|
*/
|
|
counts: {
|
|
skills: number;
|
|
systems: number;
|
|
templates: number;
|
|
craft: number;
|
|
};
|
|
github?: {
|
|
starsLabel: string;
|
|
};
|
|
/** UI locale for nav labels and accessibility text. */
|
|
locale?: LandingLocaleCode;
|
|
/** Optional override for callers that already resolved localized chrome. */
|
|
copy?: HeaderCopy;
|
|
/** Brand link target — `#top` on the homepage, `/` on sub-pages. */
|
|
brandHref?: string;
|
|
}
|
|
|
|
export function Header({
|
|
active = 'home',
|
|
counts,
|
|
github,
|
|
locale = DEFAULT_LOCALE,
|
|
copy,
|
|
brandHref = '#top',
|
|
}: HeaderProps) {
|
|
const linkClass = (key: NonNullable<HeaderProps['active']>) =>
|
|
active === key ? 'is-active' : undefined;
|
|
const headerCopy = copy ?? getCommonCopy(locale).header;
|
|
const href = (path: string) => localizedHref(path, locale);
|
|
const homeBrandHref = brandHref === '/' ? href('/') : brandHref;
|
|
const productMenuCopy = getHeaderProductMenuCopy(locale);
|
|
|
|
return (
|
|
<header className='nav' data-od-id='nav'>
|
|
<div className='container nav-inner'>
|
|
<a href={homeBrandHref} className='brand'>
|
|
<span className='brand-mark'>
|
|
<img src='/logo.webp' alt='' width={44} height={44} />
|
|
</span>
|
|
<span className='brand-name'>Open Design</span>
|
|
</a>
|
|
{/*
|
|
Mobile / tablet hamburger. Hidden by CSS at ≥1100px (the desktop
|
|
breakpoint where the full nav fits). At narrower widths it toggles
|
|
`.is-open` on the parent <header> via a small handler in
|
|
`header-enhancer.astro` — when open, the `<nav>` element below
|
|
drops down underneath the header bar as a vertical list.
|
|
*/}
|
|
<button
|
|
type='button'
|
|
className='nav-toggle'
|
|
aria-label={productMenuCopy.toggleNavigationMenu}
|
|
aria-controls='primary-nav'
|
|
aria-expanded='false'
|
|
data-nav-toggle
|
|
>
|
|
<span className='nav-toggle-icon' aria-hidden='true' />
|
|
</button>
|
|
<nav id='primary-nav' data-nav-primary>
|
|
<ul className='nav-links'>
|
|
<li className='has-dropdown'>
|
|
{/*
|
|
Product menu — top-level group exposing the Open Design family.
|
|
CSS-only dropdown via :hover / :focus-within (no JS), so this
|
|
still renders correctly under static export with no React
|
|
runtime on the client. The trigger is a focusable <a> rather
|
|
than a button so it remains a keyboard tab stop, with
|
|
aria-haspopup signaling the submenu to assistive tech.
|
|
*/}
|
|
<a
|
|
href={href('/')}
|
|
className={
|
|
active === 'product' ||
|
|
active === 'home' ||
|
|
active === 'html-anything'
|
|
? 'is-active'
|
|
: undefined
|
|
}
|
|
aria-haspopup='true'
|
|
aria-expanded='false'
|
|
>
|
|
{productMenuCopy.product}
|
|
<span className='dropdown-caret' aria-hidden='true'>▾</span>
|
|
</a>
|
|
<ul className='nav-dropdown' role='menu'>
|
|
<li role='none'>
|
|
<a
|
|
role='menuitem'
|
|
href={href('/')}
|
|
className={
|
|
active === 'home' || active === 'product'
|
|
? 'is-active'
|
|
: undefined
|
|
}
|
|
>
|
|
<span className='dropdown-name'>{productMenuCopy.openDesignName}</span>
|
|
<span className='dropdown-blurb'>
|
|
{productMenuCopy.openDesignBlurb}
|
|
</span>
|
|
</a>
|
|
</li>
|
|
<li role='none'>
|
|
<a
|
|
role='menuitem'
|
|
href={href('/html-anything/')}
|
|
className={linkClass('html-anything')}
|
|
>
|
|
<span className='dropdown-name'>{productMenuCopy.htmlAnythingName}</span>
|
|
<span className='dropdown-blurb'>
|
|
{productMenuCopy.htmlAnythingBlurb}
|
|
</span>
|
|
</a>
|
|
</li>
|
|
{/* Tutorials is a top-level nav item (see Library section
|
|
below). Don't list it here too — duplicating it once at
|
|
Product/Tutorials and again at top-level confuses users
|
|
about whether the two link to the same page. */}
|
|
</ul>
|
|
</li>
|
|
{/*
|
|
Plugins — catalog facets (Templates / Skills / Systems / Craft)
|
|
collapsed under one parent. Each row keeps its count badge
|
|
inside the panel and the trigger highlights when any of the
|
|
four facet pages is active. Same CSS-only :hover /
|
|
:focus-within mechanic from Product.
|
|
*/}
|
|
<li className='has-dropdown'>
|
|
<a
|
|
href={href('/plugins/')}
|
|
className={
|
|
active === 'plugins' ||
|
|
active === 'library' ||
|
|
active === 'skills' ||
|
|
active === 'systems' ||
|
|
active === 'templates' ||
|
|
active === 'craft'
|
|
? 'is-active'
|
|
: undefined
|
|
}
|
|
aria-haspopup='true'
|
|
aria-expanded='false'
|
|
>
|
|
{headerCopy.nav.plugins}
|
|
<span className='dropdown-caret' aria-hidden='true'>▾</span>
|
|
</a>
|
|
<ul className='nav-dropdown' role='menu'>
|
|
<li role='none'>
|
|
<a
|
|
role='menuitem'
|
|
href={href('/plugins/templates/')}
|
|
className={linkClass('templates')}
|
|
>
|
|
<span className='dropdown-name'>{headerCopy.nav.templates}</span>
|
|
</a>
|
|
</li>
|
|
<li role='none'>
|
|
<a
|
|
role='menuitem'
|
|
href={href('/plugins/skills/')}
|
|
className={linkClass('skills')}
|
|
>
|
|
<span className='dropdown-name'>{headerCopy.nav.skills}</span>
|
|
</a>
|
|
</li>
|
|
<li role='none'>
|
|
<a
|
|
role='menuitem'
|
|
href={href('/plugins/systems/')}
|
|
className={linkClass('systems')}
|
|
>
|
|
<span className='dropdown-name'>{headerCopy.nav.systems}</span>
|
|
</a>
|
|
</li>
|
|
<li role='none'>
|
|
<a
|
|
role='menuitem'
|
|
href={href('/plugins/craft/')}
|
|
className={linkClass('craft')}
|
|
>
|
|
<span className='dropdown-name'>{headerCopy.nav.craft}</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</li>
|
|
<li>
|
|
<a href={href('/tutorials/')} className={linkClass('tutorials')}>
|
|
{headerCopy.nav.tutorials}
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a href={href('/blog/')} className={linkClass('blog')}>
|
|
{headerCopy.nav.blog}
|
|
</a>
|
|
</li>
|
|
{/*
|
|
Community is a static contributors / ambassadors page served
|
|
from `apps/landing-page/public/community/index.html` — Astro
|
|
copies `public/` verbatim, so this hits Cloudflare Pages as a
|
|
first-party route at `/community/`.
|
|
|
|
The href is the literal `/community/` rather than
|
|
`href('/community/')` because the page is a single non-
|
|
locale-aware destination — locale-prefixed variants like
|
|
`/zh/community/` would fall through to a 404 since the
|
|
`[locale]/[...path].astro` catch-all does not generate it.
|
|
*/}
|
|
<li>
|
|
<a href='/community/' className={linkClass('community')}>
|
|
{headerCopy.nav.community}
|
|
</a>
|
|
</li>
|
|
{/*
|
|
Contact intentionally NOT exposed in the top nav: it's a
|
|
page-internal anchor (`#contact` on the homepage CTA section)
|
|
that the footer already surfaces. Keeping it out of the bar
|
|
frees a slot at narrow widths where the row was overflowing.
|
|
*/}
|
|
</ul>
|
|
</nav>
|
|
<div className='nav-side'>
|
|
{/*
|
|
Discord + X icon buttons live before Download / Star so the
|
|
community channels are reachable from every page without
|
|
burning a nav text slot. The icons are aria-labeled and
|
|
otherwise unlabeled. At ≤1080px they collapse alongside the
|
|
ghost Download CTA and the text-only nav <ul> (the latter
|
|
moves into the hamburger panel) — only the Star CTA stays
|
|
visible in the bar.
|
|
*/}
|
|
<a
|
|
className='nav-icon'
|
|
href={DISCORD}
|
|
aria-label='Join Open Design on Discord'
|
|
title='Discord'
|
|
{...ext}
|
|
>
|
|
<svg viewBox='0 0 24 24' width='18' height='18' fill='currentColor' aria-hidden='true'>
|
|
<path d='M19.27 5.33A18 18 0 0 0 14.72 4l-.2.4a13.7 13.7 0 0 0-5.04 0L9.27 4a18 18 0 0 0-4.54 1.33C2.4 8.94 1.78 12.45 2.09 15.9a18.4 18.4 0 0 0 5.6 2.83l1.13-1.55a11.6 11.6 0 0 1-1.78-.86l.44-.34a13 13 0 0 0 11.04 0l.44.34c-.55.33-1.16.61-1.78.86l1.13 1.55a18.3 18.3 0 0 0 5.6-2.83c.45-4.05-.5-7.53-2.64-10.57ZM9.5 14.07c-1.07 0-1.95-.99-1.95-2.21 0-1.22.86-2.22 1.95-2.22 1.1 0 1.97 1 1.95 2.22 0 1.22-.86 2.21-1.95 2.21Zm5 0c-1.07 0-1.95-.99-1.95-2.21 0-1.22.87-2.22 1.96-2.22 1.1 0 1.96 1 1.95 2.22 0 1.22-.86 2.21-1.96 2.21Z' />
|
|
</svg>
|
|
</a>
|
|
<a
|
|
className='nav-icon'
|
|
href={X_TWITTER}
|
|
aria-label='Follow Open Design on X'
|
|
title='X / Twitter'
|
|
{...ext}
|
|
>
|
|
<svg viewBox='0 0 24 24' width='16' height='16' fill='currentColor' aria-hidden='true'>
|
|
<path d='M17.53 3H21l-7.39 8.45L22 21h-6.83l-5.36-6.99L3.7 21H.23l7.9-9.04L0 3h7l4.85 6.41L17.53 3Zm-2.39 16h2.04L5.96 4.9H3.78L15.14 19Z' />
|
|
</svg>
|
|
</a>
|
|
<a
|
|
className='nav-cta ghost'
|
|
href={REPO_RELEASES}
|
|
aria-label={headerCopy.downloadAria}
|
|
title={headerCopy.downloadTitle}
|
|
{...ext}
|
|
>
|
|
{headerCopy.download}
|
|
</a>
|
|
<a
|
|
className='nav-cta'
|
|
href={REPO}
|
|
aria-label={headerCopy.starAria}
|
|
title={headerCopy.starTitle}
|
|
{...ext}
|
|
>
|
|
{headerCopy.starPrefix} ·{' '}
|
|
<span data-github-stars>{github?.starsLabel ?? '40K+'}</span>
|
|
</a>
|
|
<span className='status-dot' aria-hidden='true' />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|