Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion apps/docs/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { ThemeProvider } from "ghost-ui";
import { Navigate, Route, Routes } from "react-router";
import { Navigate, Route, Routes, useParams } from "react-router";
import DriftEngineIndex from "@/app/docs/page";
import WorkflowPage from "@/app/docs/workflow/page";
import HomePage from "@/app/page";
import ToolsIndex from "@/app/tools/page";
import ComponentPage from "@/app/ui/components/[name]/page";
import ComponentsIndex from "@/app/ui/components/page";
import ColorsPage from "@/app/ui/foundations/colors/page";
import FoundationsIndex from "@/app/ui/foundations/page";
import TypographyPage from "@/app/ui/foundations/typography/page";
import DesignLanguageIndex from "@/app/ui/page";
import { Dock } from "@/components/docs/dock";
import { mdxDocsRoutes } from "@/routes/docs-routes";

function ComponentRedirect() {
const { name } = useParams<{ name: string }>();
return <Navigate to={`/ui/components/${name}`} replace />;
}

export function App() {
return (
<ThemeProvider
Expand All @@ -28,6 +39,17 @@ export function App() {
{/* MDX-authored doc pages */}
{mdxDocsRoutes()}

{/* Design Language (ghost-ui catalogue — not linked from home/dock) */}
<Route path="ui" element={<DesignLanguageIndex />} />
<Route path="ui/foundations" element={<FoundationsIndex />} />
<Route path="ui/foundations/colors" element={<ColorsPage />} />
<Route
path="ui/foundations/typography"
element={<TypographyPage />}
/>
<Route path="ui/components" element={<ComponentsIndex />} />
<Route path="ui/components/:name" element={<ComponentPage />} />

{/* Redirects from old /docs/* URLs */}
<Route path="docs" element={<Navigate to="/tools/drift" replace />} />
<Route
Expand All @@ -46,6 +68,25 @@ export function App() {
path="tools/drift/concepts"
element={<Navigate to="/tools/drift/workflow" replace />}
/>

{/* Redirects from legacy root /foundations and /components URLs */}
<Route
path="foundations"
element={<Navigate to="/ui/foundations" replace />}
/>
<Route
path="foundations/colors"
element={<Navigate to="/ui/foundations/colors" replace />}
/>
<Route
path="foundations/typography"
element={<Navigate to="/ui/foundations/typography" replace />}
/>
<Route
path="components"
element={<Navigate to="/ui/components" replace />}
/>
<Route path="components/:name" element={<ComponentRedirect />} />
</Routes>
</main>
</ThemeProvider>
Expand Down
59 changes: 59 additions & 0 deletions apps/docs/src/app/ui/components/[name]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Navigate, useParams } from "react-router";
import { ComponentPageShell } from "@/components/docs/component-page-shell";
import { getComponentDoc } from "@/lib/component-docs";
import {
getCategory,
getComponent,
getComponentsByCategory,
} from "@/lib/component-registry";
import { getComponentSpec } from "@/lib/component-source";

// ── Import demo source files as raw strings at build time ──

const demoSourceModules = import.meta.glob(
[
"/src/components/docs/primitives/*-demo.tsx",
"/src/components/docs/ai-elements/*-demo.tsx",
],
{ query: "?raw", eager: true },
) as Record<string, { default: string }>;

function getDemoSource(
slug: string,
source: "primitives" | "ai-elements",
): string | null {
const key = `/src/components/docs/${source}/${slug}-demo.tsx`;
return demoSourceModules[key]?.default ?? null;
}

export default function ComponentPage() {
const { name } = useParams<{ name: string }>();

if (!name) return <Navigate to="/ui/components" replace />;

const component = getComponent(name);
if (!component) return <Navigate to="/ui/components" replace />;

const category = getCategory(component.primaryCategory);
const siblings = getComponentsByCategory(component.primaryCategory);
const currentIndex = siblings.findIndex((c) => c.slug === name);
const prev = currentIndex > 0 ? siblings[currentIndex - 1] : null;
const next =
currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null;

const demoSource = getDemoSource(component.slug, component.demoSource);
const spec = getComponentSpec(component.slug);
const docs = getComponentDoc(name);

return (
<ComponentPageShell
component={component}
categoryName={category?.name ?? component.primaryCategory}
demoSource={demoSource}
spec={spec}
prev={prev ? { slug: prev.slug, name: prev.name } : null}
next={next ? { slug: next.slug, name: next.name } : null}
docs={docs}
/>
);
}
170 changes: 170 additions & 0 deletions apps/docs/src/app/ui/components/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"use client";

import { useStaggerReveal } from "ghost-ui";
import { useMemo, useState } from "react";
import { Link } from "react-router";
import { AnimatedPageHeader } from "@/components/docs/animated-page-header";
import { SectionWrapper } from "@/components/docs/wrappers";
import {
categories,
getAllComponents,
getComponentsByCategory,
} from "@/lib/component-registry";

/* ── Fuzzy match ─────────────────────────────────────────────────────── */

function fuzzyMatch(query: string, target: string): number {
const q = query.toLowerCase();
const t = target.toLowerCase();

// exact substring match scores highest
if (t.includes(q)) return 1;

// character-by-character fuzzy: every query char must appear in order
let qi = 0;
let score = 0;
let lastIdx = -1;

for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
// bonus for consecutive matches
score += ti === lastIdx + 1 ? 2 : 1;
lastIdx = ti;
qi++;
}
}

// all query characters must be found
if (qi < q.length) return 0;

// normalise to 0–1 range (below 1 so substring match always wins)
return (score / (q.length * 2)) * 0.9;
}

/* ── Page ─────────────────────────────────────────────────────────────── */

export default function ComponentsIndex() {
const [query, setQuery] = useState("");
const allComponents = useMemo(() => getAllComponents(), []);

const filtered = useMemo(() => {
if (!query.trim()) return null;
return allComponents
.map((c) => ({ ...c, score: fuzzyMatch(query, c.name) }))
.filter((c) => c.score > 0)
.sort((a, b) => b.score - a.score);
}, [query, allComponents]);

const isSearching = query.trim().length > 0;

return (
<SectionWrapper>
<AnimatedPageHeader
kicker="Registry"
title="Components"
description="Production-ready building blocks. Every component follows Ghost UI — pill-first, monochromatic, accessible."
/>

{/* Search */}
<div className="mb-10">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search components…"
className="w-full max-w-md rounded-full border border-border-card bg-card px-5 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:border-foreground/25 transition-colors duration-200"
/>
</div>

{/* Search results */}
{isSearching && (
<div className="pb-16">
{filtered && filtered.length > 0 ? (
<div className="flex flex-wrap gap-2">
{filtered.map((item) => (
<ComponentPill
key={item.slug}
slug={item.slug}
name={item.name}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
No components match "{query}"
</p>
)}
</div>
)}

{/* Category sections */}
{!isSearching && (
<div className="grid gap-10 pb-16">
{categories.map((cat) => {
const items = getComponentsByCategory(cat.slug);
if (items.length === 0) return null;
return (
<CategorySection
key={cat.slug}
name={cat.name}
description={cat.description}
items={items}
/>
);
})}
</div>
)}
</SectionWrapper>
);
}

/* ── Pill ─────────────────────────────────────────────────────────────── */

function ComponentPill({ slug, name }: { slug: string; name: string }) {
return (
<Link
to={`/ui/components/${slug}`}
className="component-card group relative inline-block overflow-hidden rounded-full border border-border-card hover:border-foreground/25 bg-card px-4 py-1.5 transition-colors duration-300"
>
<span className="absolute inset-0 bg-foreground origin-left scale-x-0 group-hover:scale-x-100 transition-transform duration-300 ease-out" />
<span className="relative z-10 text-sm font-medium transition-colors duration-300 group-hover:text-background">
{name}
</span>
</Link>
);
}

/* ── Category section ─────────────────────────────────────────────────── */

function CategorySection({
name,
description,
items,
}: {
name: string;
description: string;
items: { slug: string; name: string }[];
}) {
const ref = useStaggerReveal<HTMLDivElement>(".component-card", {
stagger: 0.04,
y: 24,
duration: 0.6,
});

return (
<div ref={ref}>
<h2
className="font-display font-bold tracking-[-0.02em] mb-1"
style={{ fontSize: "var(--heading-sub-font-size)" }}
>
{name}
</h2>
<p className="text-sm text-muted-foreground mb-4">{description}</p>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<ComponentPill key={item.slug} slug={item.slug} name={item.name} />
))}
</div>
</div>
);
}
32 changes: 32 additions & 0 deletions apps/docs/src/app/ui/foundations/colors/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { useScrollReveal } from "ghost-ui";
import { AnimatedPageHeader } from "@/components/docs/animated-page-header";
import { ColorsDemos } from "@/components/docs/foundations/colors";
import { SectionWrapper } from "@/components/docs/wrappers";

export default function ColorsPage() {
const contentRef = useScrollReveal<HTMLDivElement>({
y: 50,
duration: 0.9,
ease: "expo.out",
});

return (
<>
<SectionWrapper>
<AnimatedPageHeader
kicker="Foundations"
title="Colors"
description="A pure monochromatic scale with selective semantic color for status and utility."
/>
</SectionWrapper>

<SectionWrapper>
<div ref={contentRef}>
<ColorsDemos />
</div>
</SectionWrapper>
</>
);
}
Loading
Loading