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
4 changes: 2 additions & 2 deletions docs/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@
<link rel="manifest" href="/site.webmanifest" />
<link rel="preload" href="/fonts/geist-variable.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/geist-mono-variable.woff2" as="font" type="font/woff2" crossorigin />
<script type="module" crossorigin src="/assets/index-CWghM7vV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DEUrhNBj.css">
<script type="module" crossorigin src="/assets/index-BJroA_4L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BAsLyIyM.css">
<meta name="description" content="A Rust control plane for high-volume, agent-driven DevOps: admission control, policy gates that admit, defer or reject each intent, and a tamper-evident ledger.">
<link rel="canonical" href="https://floodwall.ai/">
<meta property="og:type" content="website">
Expand Down

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions docs/assets/index-BJroA_4L.js

Large diffs are not rendered by default.

67 changes: 0 additions & 67 deletions docs/assets/index-CWghM7vV.js

This file was deleted.

6 changes: 3 additions & 3 deletions docs/index.html

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions site/src/components/shared/HeaderAccount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { ArrowDown01Icon } from "@hugeicons/core-free-icons";
import { AUTH_COPY, AUTH_PATHS } from "../../constants/auth";
import { CONSOLE_PATHS } from "../../constants/console";
import { accountInitials } from "../../helpers/auth";
import { useDismiss } from "../../hooks/useDismiss";
import { useSession } from "../../hooks/useSession";
import { panelMotion } from "../../lib/panel";
import { Icon } from "./Icon";

const COPY = AUTH_COPY.header;

/**
* The header's account slot, beside the GitHub button: Sign in for a visitor, or the signed-in
* account's initials and address, with a menu of Account, Console and Sign out. The landing page
* is prerendered with no session, so until the service answers the slot holds its width and shows
* nothing: someone signed in never sees Sign in flash by. If the service can't be asked, it offers
* Sign in. Plain links, not router ones: the landing page renders without the router.
*/
export function HeaderAccount() {
const { session, signOut } = useSession();
const [open, setOpen] = useState(false);
const [leaving, setLeaving] = useState(false);
const [status, setStatus] = useState("");
const slotRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const signInRef = useRef<HTMLAnchorElement>(null);
const panelId = useId();
const close = useCallback(() => setOpen(false), []);
const onBlur = useDismiss(open, close, slotRef, buttonRef);

// Signing out from the menu replaces it with Sign in; the keyboard focus goes there, not to the page.
const focusSignIn = useRef(false);
useEffect(() => {
if (session.status !== "signedOut" || !focusSignIn.current) return;
focusSignIn.current = false;
signInRef.current?.focus();
}, [session.status]);

async function onSignOut() {
if (leaving) return;
setLeaving(true);
setStatus("");
focusSignIn.current = true;
try {
await signOut();
setOpen(false);
} catch {
focusSignIn.current = false;
setStatus(AUTH_COPY.signOutFailed);
} finally {
setLeaving(false);
}
}

if (session.status === "loading") return <div className="nav-account" data-state="loading" />;
if (session.status !== "signedIn") {
return (
<div className="nav-account" data-state="signed-out">
<a ref={signInRef} className="nav-sign-in" href={AUTH_PATHS.login}>
{COPY.signIn}
</a>
</div>
);
}

const { user } = session;
return (
<div className="nav-account" data-state="signed-in" ref={slotRef} onBlur={onBlur}>
<button
ref={buttonRef}
className="nav-account-button"
type="button"
aria-label={COPY.menu(user.email)}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
onClick={() => setOpen((shown) => !shown)}
>
<span className="nav-avatar">{accountInitials(user)}</span>
<span className="nav-account-name">{user.email}</span>
<Icon icon={ArrowDown01Icon} size={16} className="nav-menu-chevron" />
</button>
<AnimatePresence>
{open && (
<motion.div className="nav-account-panel" id={panelId} {...panelMotion}>
<p className="nav-account-who">
{COPY.signedInAs} <strong>{user.email}</strong>
</p>
<ul className="nav-account-links">
<li>
<a href={AUTH_PATHS.account}>{COPY.account}</a>
</li>
<li>
{/* A waiting account's console is the waitlist page, so the menu says so. */}
{user.waiting ? (
<a href={AUTH_PATHS.waitlist}>{COPY.waitlist}</a>
) : (
<a href={CONSOLE_PATHS.overview}>{COPY.console}</a>
)}
</li>
<li>
{/* aria-disabled, not disabled: disabling the focused button would drop focus, and with
it the menu, before a failure could be shown in it. */}
<button type="button" aria-disabled={leaving || undefined} onClick={() => void onSignOut()}>
{leaving ? COPY.signingOut : COPY.signOut}
</button>
</li>
</ul>
<p role="status" aria-live="polite" className="nav-account-status">
{status}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
63 changes: 18 additions & 45 deletions site/src/components/shared/SiteHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type FocusEvent, type RefObject } from "react";
import { useCallback, useId, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion } from "motion/react";
import {
ArrowDown01Icon,
Expand All @@ -11,9 +11,11 @@ import {
} from "@hugeicons/core-free-icons";
import { NAV_EXPLAINERS, NAV_LINKS, NAV_SECTION_IDS, REPO_URL } from "../../constants/links";
import { useActiveSection } from "../../hooks/useActiveSection";
import { useDismiss } from "../../hooks/useDismiss";
import { useFluidHover, useRegisterFluidHoverItem } from "../../hooks/use-fluid-hover";
import { spring } from "../../lib/springs";
import { panelMotion } from "../../lib/panel";
import { FluidHoverHighlight } from "../fluid-hover-highlight";
import { HeaderAccount } from "./HeaderAccount";
import { Icon } from "./Icon";
import { SiteBrandMark } from "./SiteBrandMark";

Expand All @@ -27,13 +29,6 @@ const EXPLAINER_ICONS = {
ledger: Link04Icon,
} as const;

/**
* The panel's entrance: it drops 4px into place and grows from 98% at its top edge. A spring, so a
* close that interrupts an open reverses from where it is; the close is the tier's quicker tween.
*/
const PANEL_SHOWN = { opacity: 1, y: 0, scale: 1 };
const PANEL_HIDDEN = { opacity: 0, y: -4, scale: 0.98 };

type RegisterItem = (index: number, element: HTMLElement | null) => void;

/** A top-level item's hover box: the element the rubber-band highlight springs to. */
Expand All @@ -49,7 +44,8 @@ function NavItemBox({ index, registerItem, label }: { index: number; registerIte

/**
* The sticky header on the landing page and /pricing: the logo (which goes home), a "How it works"
* menu over the sections that explain the wall, then Roadmap, Pricing and Waitlist. One hover
* menu over the sections that explain the wall, then Roadmap, Pricing and Waitlist, and at the
* right the account (Sign in, or who is signed in: HeaderAccount) and the GitHub button. One hover
* highlight glides between the top-level items, and another between the panel's sections. On the
* landing page the current item follows the section in view; /pricing (`onLanding` false) is the
* only other page with this header, so there Pricing is current and section links lead to "/#…".
Expand All @@ -64,28 +60,9 @@ export function SiteHeader({ onLanding = true }: { onLanding?: boolean }) {
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuId = useId();

// An outside press or Escape closes the menu; Escape hands focus back to its button.
useEffect(() => {
if (!menuOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setMenuOpen(false);
menuButtonRef.current?.focus();
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
// Tabbing out of the menu closes it too.
const onMenuBlur = (event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setMenuOpen(false);
};
// An outside press, Escape or tabbing out closes the menu; Escape hands focus back to its button.
const closeMenu = useCallback(() => setMenuOpen(false), []);
const onMenuBlur = useDismiss(menuOpen, closeMenu, menuRef, menuButtonRef);

// The top-level strip: its items' hover boxes touch, so the highlight glides from one to the next.
const linksRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -186,14 +163,7 @@ export function SiteHeader({ onLanding = true }: { onLanding?: boolean }) {
</button>
<AnimatePresence>
{menuOpen && (
<motion.div
className="nav-menu-panel"
id={menuId}
initial={PANEL_HIDDEN}
animate={PANEL_SHOWN}
exit={{ ...PANEL_HIDDEN, transition: spring.moderate.exit }}
transition={spring.moderate}
>
<motion.div className="nav-menu-panel" id={menuId} {...panelMotion}>
<ExplainerGrid base={base} current={current} onChoose={() => setMenuOpen(false)} />
</motion.div>
)}
Expand All @@ -215,11 +185,14 @@ export function SiteHeader({ onLanding = true }: { onLanding?: boolean }) {
})}
<span className="nav-indicator" ref={indicatorRef} aria-hidden="true" />
</div>
<a className="button button-sm" href={REPO_URL}>
<Icon icon={GithubIcon} size={16} className="button-brand" />
View the Crate
<Icon icon={ArrowUpRight01Icon} size={16} className="button-arrow" />
</a>
<div className="nav-actions">
<HeaderAccount />
<a className="button button-sm nav-crate" href={REPO_URL}>
<Icon icon={GithubIcon} size={16} className="button-brand" />
<span className="nav-crate-label">View the Crate</span>
<Icon icon={ArrowUpRight01Icon} size={16} className="button-arrow" />
</a>
</div>
</nav>
</header>
);
Expand Down
12 changes: 12 additions & 0 deletions site/src/constants/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,18 @@ export const AUTH_COPY = {
checking: "Checking…",
stillWaiting: "Still waiting for approval. We'll email you when it's done.",
},
/** The account slot in the header of the landing page and /pricing. */
header: {
signIn: "Sign in",
/** The account button's name: it shows only the initials and, on wide screens, the address. */
menu: (email: string) => `Your account, signed in as ${email}`,
signedInAs: "Signed in as",
account: "Account",
console: "Console",
waitlist: "Waitlist",
signOut: "Sign out",
signingOut: "Signing out…",
},
/** On a signed-in page, when the service couldn't say who is signed in. */
unavailable: {
title: "We couldn't check your sign-in.",
Expand Down
5 changes: 5 additions & 0 deletions site/src/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export function safeNext(page: keyof typeof DEFAULT_NEXT, next: string | null) {
return allowed ? next! : DEFAULT_NEXT[page];
}

/** The letter on an account's avatar: its address's first letter or digit, capitalised. */
export function accountInitials(user: AuthUser) {
return (user.email.match(/[a-z0-9]/i)?.[0] ?? "?").toUpperCase();
}

/** The sign-in page, coming back to `next` afterwards. */
export function loginPath(next: string) {
return `${AUTH_PATHS.login}?${new URLSearchParams({ [NEXT_PARAM]: next })}`;
Expand Down
38 changes: 38 additions & 0 deletions site/src/hooks/useDismiss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useCallback, useEffect, type FocusEvent, type RefObject } from "react";

/**
* Closes an open disclosure (a button and the panel it shows, both inside `ref`) when the reader
* presses outside it or presses Escape, which hands focus back to `button`. Returns the onBlur
* handler for `ref`'s element, so tabbing out of it closes it too. `close` should be stable.
*/
export function useDismiss(
open: boolean,
close: () => void,
ref: RefObject<HTMLElement | null>,
button: RefObject<HTMLElement | null>,
) {
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) close();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
close();
button.current?.focus();
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open, close, ref, button]);

return useCallback(
(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close();
},
[close],
);
}
15 changes: 15 additions & 0 deletions site/src/lib/panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { spring } from "./springs";

/**
* A header panel's motion: it drops 4px into place and grows from 98% at its top edge. A spring,
* so a close that interrupts an open reverses from where it is; the close is the tier's quicker
* tween. Spread it onto a motion element inside AnimatePresence.
*/
const PANEL_HIDDEN = { opacity: 0, y: -4, scale: 0.98 };

export const panelMotion = {
initial: PANEL_HIDDEN,
animate: { opacity: 1, y: 0, scale: 1 },
exit: { ...PANEL_HIDDEN, transition: spring.moderate.exit },
transition: spring.moderate,
};
Loading
Loading