Files
bootnode/web/app/layout.tsx
T
zeekay 8f353bf348 one console shell, one image, brand from the Host header
The shell and design system half of the console merge. lux.cloud's look and
feel comes across as tokens and shell geometry, so all 67 existing routes
inherit it without one page being edited, and the per-deployment brand is
replaced by a hostname resolve.

DESIGN SYSTEM — lux.cloud's palette becomes the token layer
  app/globals.css now states lux.cloud's scheme in the variables Tailwind
  already reads: #000 ground, #f5f5f5 ink, 55% muted, 8% hairline / 18% strong
  border, #1a1a1a active fill, its status greens/ambers/reds, 10px/16px radii,
  clamp() display type, -0.01em tracking. Light is a faithful inversion, kept
  because the app ships a theme toggle. Because these are the same variables
  `bg-background` / `border-border` / `text-muted-foreground` always resolved
  to, every page changed appearance without changing.

  The shared primitives then say it in their own geometry: Card is lux's
  `.panel` (16px, hairline, no shadow — a shadow is invisible on black and
  grime on white), Table is `.vtable` (12px uppercase heads, border on the TOP
  of each row so the header needs no rule and the table ends flush), Badge is
  `.pill` with the status dot, Button and Input are `.btn` and `.stake-input`.

SHELL
  One header for marketing and console, as on lux.cloud — 57px, blurred, one
  hairline, brand lockup with the retracting wordmark, actions hard right,
  collapsing behind a single hamburger below `lg` into an OPAQUE sheet (a
  nested backdrop-filter re-roots the backdrop, the bug lux.cloud documents).
  ConsoleLayout adds the one thing the console needs: a 236px sticky rail that
  becomes a horizontally-scrolling strip on a phone — 7% of a 390x844 screen
  measured, against the 27% a wrapped grid of pills costs.

  Deleted: unified-navigation.tsx, a second console-only top bar with its own
  logo, an org switcher whose options changed nothing, and a bell wired to no
  notification source. The hard-coded `pl-64 pt-16` offsets are now the
  --header-h / --sidebar-w tokens, so the header and rail cannot disagree.

  Loading, empty and error are three components in one file instead of nine
  hand-rolled spinners at four sizes, and the callback page's `bg-green-100` /
  `text-red-600` — the only two colours in the app that ignored the tokens and
  so stayed light-mode on a black page — are gone.

WHITE-LABEL BY HOSTNAME — one image, five hosts
  lib/brand.ts resolves brand, theme, API origin, IAM issuer and OIDC client
  from window.location.hostname. Every triple was verified against the live
  /v1/iam/oauth/authorize (302 = registered, 400 = not):

    id.bootno.de  bootnode-platform  bootno.de/auth/callback           302
    hanzo.id      hanzo-web3         web3.hanzo.ai/auth/callback       302
    lux.id        lux-cloud          lux.cloud/auth/callback           302
    zoolabs.id    zoo-cloud          zoo.cloud/auth/callback           302
    zoolabs.id    zoo-cloud          cloud.zoo.network/auth/callback   302
    zoolabs.id    zoo-web3           cloud.zoo.network/auth/callback   400  ← was shipping

  Deleted, because all three were the same defect — a value picked at deploy
  time while ONE deployment serves five hosts:
    • public/config.json + config.example.json (the shipped catalog had the
      400 client above, and named apiUrls whose hosts do not resolve)
    • the Dockerfile's NEXT_PUBLIC_BRAND / _API_URL / _WS_URL, which were also
      inert — the runtime is the Go static binary, which never reads env
    • four near-identical Deployments differing only in those variables, now
      one, with the nine ingress rules pointing at the single service
    • NEXT_PUBLIC_AUTH_MODE and four NEXT_PUBLIC_* docs URLs

  Matching is exact-or-dot-suffix, never substring: `includes("lux.cloud")`
  hands lux.cloud's OIDC client to lux.cloud.attacker.example.

  Each brand also gets its own mark. public/logo/{brand}-icon.svg existed for
  all five and nothing used them — every brand pointed at one shared
  /logo.svg. bootnode's is fill="currentColor" and lux's is fill="white",
  which as an <img> (an isolated document) render invisible on black and on
  white respectively, so those two are painted in the current text colour via
  a CSS mask — which is what lux.cloud does with its own pyramid.

FIXED ALONG THE WAY
  • AuthProvider's local getApiUrl() shadowed the import at the top of the
    file and its last line called itself — infinite recursion on localhost,
    127.0.0.1, 192.168.* and 10.*. Every checkAuth/login/register in local
    development overflowed the stack. Deleted; lib/api's resolver is the one.
  • The console nav could not reach eight of its own pages —
    /dashboard/settings, assets/{tokens,nfts}, infrastructure/{lux,services,
    observability}, /chains, /agents rendered and were linked from nowhere.
  • No row was ever marked current: the image serves clean URLs as directory
    indexes, so the pathname is `/dashboard/` and an equality test never
    matched. Nested routes also lit two rows at once. Both are one selection
    rule now — longest matching href wins.
  • Two dead links in the chrome: /products (a directory of pages with no
    index — the static server answers it with the home page) and
    /docs/tutorials (a plain 404).

TESTS — 26, node:test with native TS stripping, no new dependency
  The five-host contract including the substring-matching attack; the nav
  reaching every built route; the trailing-slash and most-specific-row rules;
  and a whole-source dead-link check that derives routes by walking app/ so it
  cannot go stale. That check holds the shell at ZERO dead links and ratchets
  the 36 that predate this work in the docs and product pages — a test that
  fails on the day it is written gets deleted, one that stops the bleeding
  gets kept.

VERIFIED
  pnpm build exit 0, 77/77 pages, route set byte-identical to the baseline
  taken before any change. In a real browser against the built static export:
  body #000 / #f5f5f5, --border rgba(255,255,255,.08), header 57px sticky
  blur(24px), sidebar 236px sticky at top:57px, exactly one active row with
  #1a1a1a fill and aria-current, the phone strip 7% of the viewport scrolling
  inside itself with the body never scrolling sideways, the sheet opaque with
  no nested backdrop-filter, and the favicon, mark and "Sign in with Bootnode
  ID" all coming from the resolved brand.

Not deployed. No page bodies touched.
2026-07-26 15:32:42 -07:00

64 lines
2.5 KiB
TypeScript

import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "@/components/theme-provider"
import { AuthProvider } from "@/lib/auth"
import { Providers } from "@/app/providers"
import { Toaster } from "@/components/ui/sonner"
import { BrandProvider } from "@/components/brand-provider"
import { getBrand } from "@/lib/brand"
import "@/app/globals.css"
// `output: "export"` prerenders this at build time, where there is no request
// and therefore no Host header — so the static <head> can only carry the
// default brand, and the real one is resolved from the hostname after
// hydration by BrandProvider (which sets document.title and the favicon).
//
// This is the one honest way to white-label a static export. Baking a brand in
// at build time via NEXT_PUBLIC_BRAND is the alternative, and it is what this
// app used to do: it produced four near-identical images, three of which were
// wrong for whichever host they happened to be serving.
const fallback = getBrand("")
export const metadata: Metadata = {
title: { default: `${fallback.name} — ${fallback.tagline}`, template: `%s | ${fallback.name}` },
description: fallback.description,
keywords: ["blockchain", "RPC", "API", "Web3", "NFT", "DeFi", "smart contracts", "developer tools"],
openGraph: {
type: "website",
locale: "en_US",
url: `https://${fallback.domain}`,
siteName: fallback.name,
title: `${fallback.name} — ${fallback.tagline}`,
description: fallback.description,
},
twitter: {
card: "summary_large_image",
title: `${fallback.name} — ${fallback.tagline}`,
description: fallback.description,
},
icons: { icon: fallback.favicon, apple: fallback.favicon, shortcut: fallback.favicon },
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>
{/* BrandProvider wraps ThemeProvider: the brand owns the default
colour scheme, so the theme cannot be resolved before the brand
is. */}
<BrandProvider>
<AuthProvider>
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
</AuthProvider>
</BrandProvider>
</Providers>
</body>
</html>
)
}