diff --git a/src/__tests__/NotFoundUI.copy.test.ts b/src/__tests__/NotFoundUI.copy.test.ts new file mode 100644 index 0000000..ad534f9 --- /dev/null +++ b/src/__tests__/NotFoundUI.copy.test.ts @@ -0,0 +1,25 @@ +import fs from 'fs' +import path from 'path' +import { describe, expect, it } from 'vitest' + +const notFound = fs.readFileSync(path.join(process.cwd(), 'src/components/NotFoundUI.tsx'), 'utf8') +const errorFallback = fs.readFileSync(path.join(process.cwd(), 'src/components/ErrorFallbackUI.tsx'), 'utf8') + +describe('Hive Commons 404 and error copy', () => { + it('uses Hive Commons themed 404 copy and Hive-specific docs links', () => { + expect(notFound).toContain('This page may have moved while we reorganized the Hive docs') + expect(notFound).toContain('/docs/hive/readme') + expect(notFound).toContain('/docs/hive/getting-started') + expect(notFound).toContain('/docs/hive/documentation-map') + expect(notFound).toContain('/docs/community/meetings') + expect(notFound).toContain('Search the docs') + expect(notFound).not.toMatch(/KubeStellar|Console theme|Kubernetes dashboard|multi-cluster management/i) + }) + + it('uses Hive Commons themed runtime error copy', () => { + expect(errorFallback).toContain('Hive Commons Docs') + expect(errorFallback).toContain('The docs hit a temporary runtime error') + expect(errorFallback).toContain('/docs/hive/readme') + expect(errorFallback).not.toMatch(/KubeStellar|Console theme|Kubernetes dashboard|multi-cluster management/i) + }) +}) diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..bfac9ab --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ErrorFallbackUI from "@/components/ErrorFallbackUI"; + +export default function Error({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + return ; +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 775f79e..cad4194 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,93 +1,10 @@ "use client"; -import type { CSSProperties } from "react"; - -// Root-level error boundary (catches errors thrown by root layouts too). -// Like the 404 pages, this uses inline styles ONLY and English-only strings: -// when this boundary renders, the app (and possibly its CSS bundle and i18n -// message loading) has already failed, so it must not depend on anything -// external. Please keep it free of Tailwind classes and external assets. - -// Brand palette (mirrors src/app/globals.css / tailwind.config.ts) -const SPACE_DARK = "#0a0a0a"; -const ACCENT_BLUE = "#3b82f6"; -const GRADIENT = - "linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)"; -const GRADIENT_FALLBACK = "#764ba2"; // if background-clip: text is unsupported -const TEXT_PRIMARY = "#f3f4f6"; -const TEXT_MUTED = "#9ca3af"; -const SUBTLE_BG = "rgba(255, 255, 255, 0.05)"; -const SUBTLE_BORDER = "1px solid rgba(59, 130, 246, 0.35)"; // accent-tinted, not a stark white edge - -const styles: Record = { - body: { - margin: 0, - minHeight: "100vh", - display: "flex", - alignItems: "center", - justifyContent: "center", - backgroundColor: SPACE_DARK, - color: TEXT_PRIMARY, - textAlign: "center", - padding: "3rem 1.5rem", - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', - }, - wrap: { maxWidth: "44rem" }, - code: { - fontSize: "clamp(3.5rem, 12vw, 6rem)", - fontWeight: 800, - lineHeight: 1, - backgroundImage: GRADIENT, - WebkitBackgroundClip: "text", - backgroundClip: "text", - WebkitTextFillColor: "transparent", - color: GRADIENT_FALLBACK, - marginBottom: "1.25rem", - }, - heading: { - fontSize: "1.75rem", - fontWeight: 600, - margin: "0 0 0.9rem", - }, - message: { - fontSize: "1.05rem", - color: TEXT_MUTED, - lineHeight: 1.6, - margin: "0 0 2.25rem", - }, - actions: { - display: "flex", - flexWrap: "wrap", - gap: "0.75rem", - justifyContent: "center", - }, - buttonPrimary: { - display: "inline-block", - padding: "0.75rem 2rem", - borderRadius: "0.5rem", - fontSize: "1rem", - fontWeight: 500, - textDecoration: "none", - color: "#ffffff", - backgroundColor: ACCENT_BLUE, - border: `1px solid ${ACCENT_BLUE}`, - cursor: "pointer", - fontFamily: "inherit", - }, - buttonSecondary: { - display: "inline-block", - padding: "0.75rem 2rem", - borderRadius: "0.5rem", - fontSize: "1rem", - fontWeight: 500, - textDecoration: "none", - color: TEXT_PRIMARY, - backgroundColor: SUBTLE_BG, - border: SUBTLE_BORDER, - }, -}; +import ErrorFallbackUI from "@/components/ErrorFallbackUI"; +// Root-level error boundary. Keep this dependency-light: the shared fallback +// uses inline Hive Commons theme styles so it still renders during asset-load +// or runtime failures. export default function GlobalError({ reset, }: { @@ -96,31 +13,8 @@ export default function GlobalError({ }) { return ( - -
-
Oops
-

Something went wrong

-

- An unexpected error occurred while loading this page. It may be a - temporary problem — trying again usually fixes it. -

-
- - {/* Plain is deliberate here: when the global error boundary - renders, the app (router included) has already crashed, so a - full-page navigation is the most reliable way out. */} - {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} - - Hive Commons home - - {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} - - Browse the docs - -
-
+ + ); diff --git a/src/components/ErrorFallbackUI.tsx b/src/components/ErrorFallbackUI.tsx new file mode 100644 index 0000000..25b6ddb --- /dev/null +++ b/src/components/ErrorFallbackUI.tsx @@ -0,0 +1,113 @@ +"use client"; + +import Link from "next/link"; +import type { CSSProperties } from "react"; + +const styles: Record = { + page: { + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "2rem", + background: "var(--ef-bg)", + color: "var(--ef-ink)", + fontFamily: + 'var(--font-inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif)', + }, + card: { + width: "min(100%, 48rem)", + border: "1px solid var(--ef-line)", + borderRadius: "1.5rem", + background: "var(--ef-bg-2)", + boxShadow: "0 24px 80px rgba(0,0,0,.22)", + padding: "clamp(1.5rem, 5vw, 3rem)", + textAlign: "center", + }, + logo: { width: 48, height: 48, margin: "0 auto 1rem", display: "block" }, + eyebrow: { + color: "var(--ef-honey)", + fontWeight: 750, + letterSpacing: "0.08em", + textTransform: "uppercase", + fontSize: "0.85rem", + marginBottom: "0.75rem", + }, + title: { + color: "var(--ef-ink)", + fontSize: "clamp(2rem, 5vw, 3.5rem)", + lineHeight: 1.05, + letterSpacing: "-0.04em", + margin: "0 0 1rem", + }, + message: { color: "var(--ef-ink-2)", fontSize: "1.05rem", lineHeight: 1.7, margin: "0 auto 1.75rem", maxWidth: "38rem" }, + actions: { display: "flex", flexWrap: "wrap", justifyContent: "center", gap: "0.75rem" }, + primary: { + border: "1px solid var(--ef-honey)", + background: "var(--ef-honey)", + color: "var(--ef-honey-ink)", + borderRadius: "0.75rem", + padding: "0.8rem 1rem", + font: "inherit", + fontWeight: 750, + cursor: "pointer", + textDecoration: "none", + }, + secondary: { + border: "1px solid var(--ef-line)", + background: "transparent", + color: "var(--ef-ink)", + borderRadius: "0.75rem", + padding: "0.8rem 1rem", + fontWeight: 700, + textDecoration: "none", + }, +}; + +function ThemeVars() { + return ( + + ); +} + +export default function ErrorFallbackUI({ reset }: { reset: () => void }) { + return ( +
+ +
+ Hive Commons logo +
Hive Commons Docs
+

Something went wrong

+

+ The docs hit a temporary runtime error. Try again, or jump back into the Hive docs while we recover this page. +

+
+ + Hive intro + Docs home + Community meetings +
+
+
+ ); +} diff --git a/src/components/NotFoundUI.tsx b/src/components/NotFoundUI.tsx index bb6dac1..9bb127a 100644 --- a/src/components/NotFoundUI.tsx +++ b/src/components/NotFoundUI.tsx @@ -1,207 +1,478 @@ "use client"; -import { useTranslations } from "next-intl"; -import { usePathname } from "next/navigation"; import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { FormEvent, useMemo, useState } from "react"; import type { CSSProperties } from "react"; -// This component intentionally uses INLINE STYLES ONLY (no Tailwind classes -// for layout/color) and explicit width/height attributes on the SVG mark. -// -// Why: the 404 page is exactly what users see during deploy propagation -// windows, when the previous deploy's hashed CSS bundle can 404. A 404 page -// styled via external CSS then renders unstyled -- unsized inline SVGs blow -// up to full viewport width (the "giant blue glyph" incident). Inline styles -// and sized SVGs cannot fail to load, so this page always renders correctly. -// Please keep it free of Tailwind classes and external assets. - -// Brand palette (mirrors src/app/globals.css / tailwind.config.ts) -const SPACE_DARK = "#0a0a0a"; -const ACCENT_BLUE = "#3b82f6"; -const GRADIENT = - "linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)"; -const GRADIENT_FALLBACK = "#764ba2"; // if background-clip: text is unsupported -const TEXT_PRIMARY = "#f3f4f6"; -const TEXT_MUTED = "#9ca3af"; -const PATH_ACCENT = "#c084fc"; -const SUBTLE_BG = "rgba(255, 255, 255, 0.05)"; -const SUBTLE_BORDER = "1px solid rgba(59, 130, 246, 0.35)"; // accent-tinted, not a stark white edge +// The 404 page must survive deploy propagation windows where hashed CSS can be +// unavailable, so it mirrors the docs theme with inline styles and CSS custom +// properties instead of Tailwind classes. + +type SearchResult = { + title: string; + url: string; + category: string; + snippet: string; +}; + +type QuickLink = { + href: string; + label: string; + primary?: boolean; +}; + +const HIVE_LINKS: QuickLink[] = [ + { href: "/docs/hive/readme", label: "Hive intro", primary: true }, + { href: "/docs/hive/getting-started", label: "Getting Started" }, + { href: "/docs/hive/documentation-map", label: "Documentation map" }, + { href: "/docs/community/meetings", label: "Community meetings" }, + { href: "/docs", label: "Docs home" }, +]; + +const GENERAL_LINKS: QuickLink[] = [ + { href: "/docs", label: "Docs home", primary: true }, + { href: "/docs/community/what-is-hive-commons", label: "What is Hive Commons?" }, + { href: "/docs/community/join-hive-commons", label: "Join Hive Commons" }, + { href: "/docs/community/meetings", label: "Community meetings" }, + { href: "https://github.com/hivecommons", label: "GitHub" }, +]; + +const LOGO_SIZE = 44; +const MAX_RESULTS = 5; const styles: Record = { page: { minHeight: "100vh", display: "flex", flexDirection: "column", - backgroundColor: SPACE_DARK, - color: TEXT_PRIMARY, + background: "var(--nf-page-backdrop)", + backgroundSize: "auto, auto, 56px 97px, auto", + backgroundRepeat: "no-repeat, no-repeat, repeat, no-repeat", + color: "var(--nf-ink)", fontFamily: 'var(--font-inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif)', }, - header: { padding: "1.25rem 1.5rem" }, + header: { + borderBottom: "1px solid var(--nf-line)", + background: "color-mix(in srgb, var(--nf-bg) 86%, transparent)", + backdropFilter: "blur(14px)", + }, + headerInner: { + maxWidth: "88rem", + margin: "0 auto", + padding: "1rem 1.5rem", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "1rem", + }, brand: { display: "inline-flex", alignItems: "center", - gap: "0.6rem", + gap: "0.7rem", + color: "var(--nf-ink)", + fontWeight: 700, + fontSize: "1.1rem", + textDecoration: "none", + }, + docsLink: { + color: "var(--nf-ink-2)", + fontSize: "0.95rem", textDecoration: "none", - color: TEXT_PRIMARY, - fontWeight: 600, - fontSize: "1.05rem", }, main: { flex: 1, - display: "flex", + width: "100%", + maxWidth: "88rem", + margin: "0 auto", + padding: "clamp(2.5rem, 7vw, 6rem) 1.5rem", + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 24rem), 1fr))", + gap: "2rem", alignItems: "center", - justifyContent: "center", - padding: "3rem 1.5rem", - textAlign: "center", }, - wrap: { maxWidth: "44rem" }, + panel: { + border: "1px solid var(--nf-line)", + borderRadius: "1.5rem", + background: + "linear-gradient(135deg, color-mix(in srgb, var(--nf-bg-2) 94%, transparent), color-mix(in srgb, var(--nf-bg) 86%, transparent))", + boxShadow: "0 24px 80px color-mix(in srgb, var(--nf-shadow) 38%, transparent)", + padding: "clamp(1.5rem, 4vw, 3rem)", + }, + eyebrow: { + display: "inline-flex", + alignItems: "center", + gap: "0.5rem", + color: "var(--nf-honey)", + fontSize: "0.85rem", + fontWeight: 700, + letterSpacing: "0.08em", + textTransform: "uppercase", + marginBottom: "1rem", + }, code: { - fontSize: "clamp(4.5rem, 16vw, 8rem)", + color: "var(--nf-honey)", + fontSize: "clamp(4rem, 14vw, 8rem)", fontWeight: 800, - lineHeight: 1, - backgroundImage: GRADIENT, - WebkitBackgroundClip: "text", - backgroundClip: "text", - WebkitTextFillColor: "transparent", - color: GRADIENT_FALLBACK, - marginBottom: "1.25rem", - }, - description: { - fontSize: "1.25rem", - color: TEXT_PRIMARY, - lineHeight: 1.6, - marginBottom: "1.5rem", + lineHeight: 0.9, + letterSpacing: "-0.06em", + margin: "0 0 1rem", + textShadow: "0 0 40px color-mix(in srgb, var(--nf-honey) 34%, transparent)", + }, + heading: { + color: "var(--nf-ink)", + fontSize: "clamp(2rem, 5vw, 4.5rem)", + lineHeight: 1.03, + letterSpacing: "-0.05em", + margin: "0 0 1rem", + }, + message: { + color: "var(--nf-ink-2)", + fontSize: "1.1rem", + lineHeight: 1.7, + margin: "0 0 1.5rem", }, pathBox: { display: "inline-flex", + flexWrap: "wrap", alignItems: "center", gap: "0.5rem", - padding: "0.5rem 1rem", - borderRadius: "0.5rem", - backgroundColor: SUBTLE_BG, - border: SUBTLE_BORDER, - marginBottom: "1.5rem", maxWidth: "100%", + padding: "0.65rem 0.8rem", + borderRadius: "0.75rem", + border: "1px solid var(--nf-line)", + background: "color-mix(in srgb, var(--nf-bg-3) 72%, transparent)", + marginBottom: "1.5rem", }, - pathLabel: { fontSize: "0.875rem", color: TEXT_MUTED }, + pathLabel: { color: "var(--nf-ink-3)", fontSize: "0.85rem" }, pathValue: { - fontSize: "0.875rem", - color: PATH_ACCENT, + color: "var(--nf-honey)", + fontSize: "0.85rem", + overflowWrap: "anywhere", fontFamily: "var(--font-jetbrains-mono, ui-monospace, SFMono-Regular, Menlo, monospace)", - overflowWrap: "anywhere", - }, - message: { - fontSize: "1.05rem", - color: TEXT_MUTED, - lineHeight: 1.6, - marginBottom: "2.5rem", }, actions: { display: "flex", flexWrap: "wrap", gap: "0.75rem", - justifyContent: "center", + marginTop: "1.5rem", }, buttonPrimary: { - display: "inline-block", - padding: "0.75rem 2rem", - borderRadius: "0.5rem", - fontSize: "1rem", - fontWeight: 500, + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minHeight: "2.75rem", + padding: "0.75rem 1rem", + borderRadius: "0.75rem", + border: "1px solid var(--nf-honey)", + background: "var(--nf-honey)", + color: "var(--nf-honey-ink)", + fontWeight: 700, textDecoration: "none", - color: "#ffffff", - backgroundColor: ACCENT_BLUE, - border: `1px solid ${ACCENT_BLUE}`, }, buttonSecondary: { - display: "inline-block", - padding: "0.75rem 2rem", - borderRadius: "0.5rem", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + minHeight: "2.75rem", + padding: "0.75rem 1rem", + borderRadius: "0.75rem", + border: "1px solid var(--nf-line)", + background: "color-mix(in srgb, var(--nf-bg-2) 70%, transparent)", + color: "var(--nf-ink)", + fontWeight: 650, + textDecoration: "none", + }, + sidePanel: { + border: "1px solid var(--nf-line)", + borderRadius: "1.25rem", + background: "color-mix(in srgb, var(--nf-bg-2) 82%, transparent)", + padding: "1.25rem", + }, + sideTitle: { + color: "var(--nf-ink)", fontSize: "1rem", - fontWeight: 500, + fontWeight: 750, + margin: "0 0 0.75rem", + }, + linkList: { + display: "grid", + gap: "0.65rem", + margin: "0 0 1.25rem", + padding: 0, + listStyle: "none", + }, + listLink: { + display: "flex", + justifyContent: "space-between", + gap: "0.75rem", + padding: "0.75rem 0.85rem", + borderRadius: "0.75rem", + border: "1px solid var(--nf-line)", + color: "var(--nf-ink)", + background: "color-mix(in srgb, var(--nf-bg) 64%, transparent)", + textDecoration: "none", + fontWeight: 650, + }, + searchForm: { display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }, + searchInput: { + flex: 1, + minWidth: 0, + border: "1px solid var(--nf-line)", + borderRadius: "0.75rem", + background: "var(--nf-bg)", + color: "var(--nf-ink)", + padding: "0.75rem 0.85rem", + font: "inherit", + }, + searchButton: { + border: "1px solid var(--nf-honey)", + borderRadius: "0.75rem", + background: "var(--nf-honey)", + color: "var(--nf-honey-ink)", + padding: "0.75rem 0.95rem", + font: "inherit", + fontWeight: 750, + cursor: "pointer", + }, + searchHint: { color: "var(--nf-ink-3)", fontSize: "0.9rem", margin: "0" }, + resultLink: { + display: "block", + padding: "0.75rem 0", + color: "var(--nf-ink)", textDecoration: "none", - color: TEXT_PRIMARY, - backgroundColor: SUBTLE_BG, - border: SUBTLE_BORDER, + borderTop: "1px solid var(--nf-line)", + }, + resultMeta: { color: "var(--nf-honey)", fontSize: "0.8rem", marginBottom: "0.2rem" }, + resultSnippet: { color: "var(--nf-ink-2)", fontSize: "0.9rem", lineHeight: 1.5, margin: "0.25rem 0 0" }, + footer: { + borderTop: "1px solid var(--nf-line)", + padding: "1rem 1.5rem", + color: "var(--nf-ink-3)", + textAlign: "center", + fontSize: "0.9rem", }, }; -const LOGO_SIZE = 28; // px; explicit size so the logo can never render unsized +function ThemeVars() { + return ( + + ); +} -// Hive Commons logo mark, served from public/ (small, cached). -const LOGO_DATA_URI = "/hive-commons-logo.png"; +function Brand() { + return ( + + Hive Commons logo + Hive Commons Docs + + ); +} -function BrandMark() { +function isExternal(href: string) { + return href.startsWith("http://") || href.startsWith("https://"); +} + +function QuickLinks({ links }: { links: QuickLink[] }) { return ( - Hive Commons +
    + {links.map((link) => ( +
  • + {isExternal(link.href) ? ( + + {link.label} + + + ) : ( + + {link.label} + + + )} +
  • + ))} +
); } export default function NotFoundUI() { - const t = useTranslations("notFound"); const pathname = usePathname(); + const isHiveDocs = pathname?.startsWith("/docs/hive/") || pathname === "/docs/hive"; + const quickLinks = isHiveDocs ? HIVE_LINKS : GENERAL_LINKS; + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searchState, setSearchState] = useState<"idle" | "loading" | "done" | "error">("idle"); + + const message = useMemo(() => { + if (isHiveDocs) { + return "This page may have moved while we reorganized the Hive docs. Try the Hive docs map, getting started guide, or search below."; + } + return "This page may have moved while we reorganized Hive Commons documentation. Try the docs home, community pages, or search below."; + }, [isHiveDocs]); + + async function handleSearch(event: FormEvent) { + event.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + setSearchState("loading"); + try { + const response = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); + if (!response.ok) throw new Error("Search failed"); + const data = (await response.json()) as { results?: SearchResult[] }; + setResults((data.results ?? []).slice(0, MAX_RESULTS)); + setSearchState("done"); + } catch { + setResults([]); + setSearchState("error"); + } + } return (
- {/* Inline styles can't reach /. When the hashed CSS bundle - is unavailable (the exact scenario this page is built for), there is - no Tailwind preflight, so the UA default body margin (8px) shows as - a white border around the dark page. This inline +
- - - Hive Commons - +
+ + + Docs home + +
-
+
+
Page not found
404
- -

{t("description")}

+

+ This Hive Commons page is not here. +

+

{message}

{pathname && (
- {t("requestedPath")} + Requested path {pathname}
)} -

{t("message")}

-
- - {t("homeButton")} - - - {t("docsButton")} - - - GitHub - + {quickLinks.slice(0, 3).map((link) => ( + + {link.label} + + ))}
+ +
+ +
); }