diff --git a/CONSUMING.md b/CONSUMING.md index 682a7c7..5fe5b26 100644 --- a/CONSUMING.md +++ b/CONSUMING.md @@ -266,6 +266,142 @@ bump deliberately. --- +## `@ampl/kit/ui` — `AmplHeader` + +`AmplHeader` is the single shared header component for every tool on `ampl.tools`. +It renders two bands: a white institutional band (the AMPL logo lockup and +lab-site nav, owned by the kit) and a deep-plum (#743A6A) WORKSHOP band (tool +switcher, contextual in-app nav, EN/ES, and the signed-in account chip or a +sign-in link). A hamburger-toggled mobile sheet covers narrow screens. All tool +variation comes through props — no per-tool fork of the component. + +```typescript +import { + AmplHeader, + DEFAULT_TOOLS, + type AmplHeaderProps, + type NavItem, + type ToolLink, + type ToolId, + type AccountInfo, + type HeaderSize, +} from "@ampl/kit/ui"; +``` + +### Compact case (all in-app pages) + +```tsx +// Signed-in compact header — standard in-app layout + + } + account={{ + name: user.name, + handle: user.handle, + avatarUrl: user.avatar_url, + }} + nav={[ + { label: t("nav.manuscripts"), href: "/palaeography/manuscripts", active: true }, + { label: t("nav.groups"), href: "/palaeography/groups" }, + ]} +/> +``` + +The `size` prop defaults to `"compact"` and can be omitted on every in-app +page. + +### Full case (signed-out front door only) + +```tsx +// Signed-out front door — size="full" masthead, no nav + + } + signInHref="/auth/login?return_to=/palaeography" +/> +``` + +Use `size="full"` **only** on the signed-out front-door page. Every authenticated +page and every intermediate state uses the default `"compact"`. Only the +institutional band scales between the two sizes; the WORKSHOP band is constant +height in both modes. + +### `account` vs `signInHref` — signed-in vs signed-out + +When `account` is provided the WORKSHOP band renders the signed-in account chip +(avatar or initials + name, with a menu). When `account` is `null` or omitted +the band renders a sign-in link pointing to `signInHref`. + +```tsx +// Signed-in + + +// Signed-out + +``` + +Override the sign-in link text with `signInLabel` when the default ("Sign in") +does not fit the tool's voice — for example, Scheduling passes +`signInLabel="Host sign in"`. + +### `AccountInfo` sign-out + +`AccountInfo.signOutHref` defaults to `"/auth/logout"`. Sign-out is a POST form. +Pass `signOutHref` only if you need to override the target (e.g. in a local dev +environment); standard deployments need no override. + +### Tool switcher + +The WORKSHOP band includes a cross-tool switcher populated from the `tools` prop. +It defaults to `DEFAULT_TOOLS` (Palaeography and Scheduling). Override it to +control which tools appear or to add future tools: + +```typescript +import { DEFAULT_TOOLS } from "@ampl/kit/ui"; + +// Use the default registry — no prop needed + + +// Override with a custom registry + +``` + +The `tool` prop (internal tool id) drives the switcher "current" highlight so +the active tool is visually distinguished. + +### LocaleSwitcher variant + +Pass `` to `AmplHeader`. The `"on-dark"` +variant is styled for the plum WORKSHOP band; the default light variant is +unsuitable on that background. + +### CSP: avatar host + +When `account` carries a non-null `avatarUrl`, the header renders the GitHub +avatar as a plain ``. The same CSP requirement applies as for +`AccountWidget` — add `avatars.githubusercontent.com` to `img-src`. See +[section 2, "CSP avatar host"](#2-csp-avatar-host) above. + +### Deprecation note + +`SiteHeader` is `@deprecated` as of v0.3.0. It is retained for backward +compatibility but new tools should use `AmplHeader`. Existing consumers (Calamus) +should migrate at the next milestone boundary. + +--- + ## `@ampl/kit/email` — bilingual shell + `.ics` builder The `@ampl/kit/email` subpath ships two pure TypeScript functions — @@ -517,16 +653,20 @@ The git tag is the contract for `@ampl/kit`. Consumers pin to an exact tag: "@ampl/kit": "github:UCSB-AMPLab/ampl-kit#v0.2.1" ``` -**This release:** `v0.2.2` fixes a `return_to` bug in the hosted `ampl-auth` -service: the login page dropped `return_to` at the "Continue with GitHub" -button, so deep-link-after-login always landed at `/auth` (see the note in -section 4). It is a service-side fix with no library API change — every -consuming tool benefits once the shared service is deployed at v0.2.2, and no -consumer code change is required. (`v0.2.1` exported the `send()` RPC contract — -`SendMessage`, `SendResult` — from `./email`; `v0.2.0` added the `./email` -subpath itself: `renderEmailShell`, `buildIcs`, `EmailShellInput`, `EmailBlock`, -`IcsEvent`.) Consumers on `v0.1.0` are unaffected at the library level — the -`./auth` and `./ui` subpaths are unchanged. +**This release:** `v0.3.0` adds `AmplHeader` — a new UI component exported from +`@ampl/kit/ui`, along with the `DEFAULT_TOOLS` registry and the types +`AmplHeaderProps`, `NavItem`, `ToolLink`, `ToolId`, `AccountInfo`, and +`HeaderSize`. It also adds the `kit.nav`, `kit.switcher`, and `kit.header` i18n +string groups and the `"on-dark"` `LocaleSwitcher` variant. This is purely +additive — a **minor** bump. `SiteHeader` is deprecated but retained for +back-compat; no consumer is required to migrate. (`v0.2.2` fixed a `return_to` +bug in the hosted `ampl-auth` service: the login page dropped `return_to` at +the "Continue with GitHub" button, so deep-link-after-login always landed at +`/auth` — a service-side fix with no library API change. `v0.2.1` exported the +`send()` RPC contract — `SendMessage`, `SendResult` — from `./email`; `v0.2.0` +added the `./email` subpath itself: `renderEmailShell`, `buildIcs`, +`EmailShellInput`, `EmailBlock`, `IcsEvent`.) Consumers on `v0.1.0` are +unaffected at the library level — the `./auth` subpath is unchanged. **Policy:** diff --git a/app/root.tsx b/app/root.tsx index bd9e8d9..481694e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -3,8 +3,9 @@ * * This file is the outermost wrapper around every page the app renders. It * owns the HTML document itself — the ``, ``, and `` — and - * paints the persistent chrome that surrounds each route: the AMPL site header - * with its "AMPL Auth" lockup, a language switcher, and the shared footer. Two + * paints the persistent chrome that surrounds each route: the shared + * `AmplHeader` (full masthead, `tool="auth"`, the signed-out front door) with + * its language switcher, and the shared footer. Two * middlewares run here for every request — `securityMiddleware`, which mints * the per-request CSP nonce, and `i18nextMiddleware`, which resolves the * active language — and the root loader hands both the chosen `locale` and the @@ -15,7 +16,7 @@ * — the friendly, translated fallback shown when a route throws or a page is * not found, with a developer stack trace surfaced only in development. * - * @version v0.1.0 + * @version v0.3.0 */ import { @@ -35,8 +36,7 @@ import { i18nextMiddleware, getLocale } from "~/middleware/i18next"; import { securityMiddleware, nonceContext } from "~/middleware/security"; import { withBase, stripBasename } from "~/lib/paths"; import { kitFontLinks } from "@ampl/kit/fonts"; -import { SiteHeader, SiteFooter, LocaleSwitcher } from "@ampl/kit/ui"; -import amplLogo from "@ampl/kit/assets/ampl-logo.svg"; +import { AmplHeader, SiteFooter, LocaleSwitcher } from "@ampl/kit/ui"; import "./app.css"; export const middleware = [securityMiddleware, i18nextMiddleware]; @@ -55,7 +55,6 @@ export function Layout({ children }: { children: React.ReactNode }) { const locale = data?.locale ?? "en"; const nonce = data?.nonce ?? ""; const location = useLocation(); - const { t } = useTranslation("common"); // Build the locale-switch href: strip basename from current path so the // locale route can reconstruct it correctly, then prefix with withBase. @@ -76,57 +75,18 @@ export function Layout({ children }: { children: React.ReactNode }) {
- } - nav={ - - } - > - {/* AMPL logo — links to lab home; img is decorative (aria-label on anchor) */} - - - - + />
{children}
diff --git a/kit/README.md b/kit/README.md index 355f298..b42a274 100644 --- a/kit/README.md +++ b/kit/README.md @@ -13,7 +13,7 @@ git dependency. | `@ampl/kit/theme.css` | Tailwind v4 `@theme` tokens + base layer | ✅ | | `@ampl/kit/fonts` | `kitFontLinks` for the RR root `links` export | ✅ | | `@ampl/kit/locales/{en,es}` | i18n key fragments | ✅ (es pending review) | -| `@ampl/kit/ui` | footer, header, account widget, no-access, auth-error, report-a-problem, primitives | ⏳ scaffold | +| `@ampl/kit/ui` | footer, `AmplHeader` (unified two-band header), account widget, locale switcher, no-access, auth-error, report-a-problem, primitives | ✅ | | `@ampl/kit/auth` | session-validation helper + read-only `AUTH_DB` contract | ✅ | `@ampl/kit/ui` is presentational only — no secrets, no DB access. The single secret @@ -104,3 +104,40 @@ export const links: Route.LinksFunction = () => [...kitFontLinks]; ``` Then merge `@ampl/kit/locales/{en,es}` and import surfaces from `@ampl/kit/ui`. + +## AmplHeader (`@ampl/kit/ui`) + +`AmplHeader` is the unified shared header for all AMPL tools. It renders a white +institutional band (AMPL logo lockup + lab-site nav) and a deep-plum WORKSHOP +band (tool switcher + contextual nav + EN/ES + account chip or sign-in link), plus +a hamburger-toggled mobile sheet. All tool variation comes through props. + +```tsx +import { AmplHeader, DEFAULT_TOOLS } from "@ampl/kit/ui"; + +// Compact signed-in header (standard in-app use) +} + account={{ name: user.name, avatarUrl: user.avatar_url }} + nav={[ + { label: "Manuscripts", href: "/palaeography/manuscripts", active: true }, + { label: "Groups", href: "/palaeography/groups" }, + ]} +/> + +// Full signed-out front door (size="full" only on the front door) +} + signInHref="/auth/login?return_to=/palaeography" +/> +``` + +The tool switcher defaults to `DEFAULT_TOOLS` (Palaeography + Scheduling); pass +a custom `tools` array to override. `SiteHeader` is deprecated as of v0.3.0 in +favour of `AmplHeader`. See `CONSUMING.md` for the full prop reference and CSP +requirements. diff --git a/kit/locales/en.ts b/kit/locales/en.ts index cecc570..238e210 100644 --- a/kit/locales/en.ts +++ b/kit/locales/en.ts @@ -37,6 +37,22 @@ export default { }, header: { localeLabel: "Switch language", + signIn: "Sign in", + menuLabel: "Menu", + }, + nav: { + ariaLabel: "Lab site navigation", + tools: "Tools", + projects: "Projects", + opportunities: "Opportunities", + people: "People", + }, + switcher: { + heading: "AMPL Workshop", + tagline: { + calamus: "Practice reading manuscripts", + scheduling: "booking & polls", + }, }, accountWidget: { signOut: "Sign out", diff --git a/kit/locales/es.ts b/kit/locales/es.ts index 42a6b79..eafd431 100644 --- a/kit/locales/es.ts +++ b/kit/locales/es.ts @@ -37,6 +37,22 @@ export default { }, header: { localeLabel: "Cambiar idioma", + signIn: "Iniciar sesión", + menuLabel: "Menú", + }, + nav: { + ariaLabel: "Navegación del sitio del laboratorio", + tools: "Herramientas", + projects: "Proyectos", + opportunities: "Oportunidades", + people: "Personas", + }, + switcher: { + heading: "Taller AMPL", + tagline: { + calamus: "Practica leer manuscritos", + scheduling: "reservas y encuestas", + }, }, accountWidget: { signOut: "Cerrar sesión", diff --git a/kit/theme.css b/kit/theme.css index ec6ccb4..fccfa3f 100644 --- a/kit/theme.css +++ b/kit/theme.css @@ -33,7 +33,8 @@ --color-accent: #A5469A; /* THE AMPL plum — the single UI accent */ --color-accent-ink: #8B467D; /* footer band plum */ - --color-accent-deep: #743A6A; /* footer-bottom plum */ + --color-accent-deep: #743A6A; /* footer-bottom plum + WORKSHOP band */ + --color-accent-pale: #efd6e9; /* WORKSHOP eyebrow + current-tool in switcher (on plum) */ --color-hue-orange: #D97743; /* per-project accent override ONLY */ --color-hue-blue: #3B6BA5; /* per-project accent override ONLY */ diff --git a/kit/ui/AccountWidget.tsx b/kit/ui/AccountWidget.tsx index 3dadd0c..4ca76b1 100644 --- a/kit/ui/AccountWidget.tsx +++ b/kit/ui/AccountWidget.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { buildSignOutAction } from "./lib/sign-out"; /** * Account widget @@ -33,7 +34,7 @@ import { useTranslation } from "react-i18next"; * preserves its action URL's query string, so this reaches the * route; a hidden input would land in the body and be ignored. * - * @version v0.1.2 + * @version v0.1.3 */ type AccountWidgetProps = { @@ -44,19 +45,6 @@ type AccountWidgetProps = { returnTo?: string; }; -// Append a guarded return_to to the logout endpoint without clobbering any -// query string signOutHref may already carry. Built with URLSearchParams rather -// than string concatenation so existing params survive and the value is encoded -// (split on "?" keeps this working for relative hrefs, which `new URL()` can't -// parse without a base). -function buildSignOutAction(signOutHref: string, returnTo?: string): string { - if (!returnTo) return signOutHref; - const [path, existingQuery = ""] = signOutHref.split("?"); - const params = new URLSearchParams(existingQuery); - params.set("return_to", returnTo); - return `${path}?${params.toString()}`; -} - export function AccountWidget({ name, handle, diff --git a/kit/ui/LocaleSwitcher.tsx b/kit/ui/LocaleSwitcher.tsx index 81f80ab..6226c85 100644 --- a/kit/ui/LocaleSwitcher.tsx +++ b/kit/ui/LocaleSwitcher.tsx @@ -15,8 +15,9 @@ import { useTranslation } from "react-i18next"; * const buildHref = (lng: "en" | "es") => * `${withBase("/locale")}?lng=${lng}&to=...`; * + * // plum band * - * @version v0.1.0 + * @version v0.1.1 */ type LocaleSwitcherProps = { @@ -24,41 +25,27 @@ type LocaleSwitcherProps = { buildHref: (lng: "en" | "es") => string; /** The currently active locale. */ current: "en" | "es"; + /** Visual theme. "on-dark" is for the plum WORKSHOP band; default is light. */ + variant?: "default" | "on-dark"; }; -export function LocaleSwitcher({ buildHref, current }: LocaleSwitcherProps) { +export function LocaleSwitcher({ buildHref, current, variant = "default" }: LocaleSwitcherProps) { const { t } = useTranslation("kit"); + const onDark = variant === "on-dark"; + const activeCls = onDark ? "font-semibold text-white" : "font-semibold text-fg-1"; + const idleCls = onDark ? "text-white/60 hover:text-white" : "text-fg-3 hover:text-accent"; + const slashCls = onDark ? "text-white/30" : "text-fg-3 opacity-60"; return (
- - EN - - - - ES - + EN + + ES
); } diff --git a/kit/ui/ampl-header/AccountMenu.tsx b/kit/ui/ampl-header/AccountMenu.tsx new file mode 100644 index 0000000..f9cbd05 --- /dev/null +++ b/kit/ui/ampl-header/AccountMenu.tsx @@ -0,0 +1,64 @@ +/** + * Account menu — the signed-in account chip (avatar or initials + name) on the + * WORKSHOP band, a
menu with the @handle, optional links, and a POST sign-out. + * + * @version v0.3.0 + */ +import { useTranslation } from "react-i18next"; +import { buildSignOutAction } from "../lib/sign-out"; +import type { AccountInfo } from "./types"; + +/** Two-letter initials for the avatar fallback (first + last word). */ +function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? parts[parts.length - 1][0] : ""; + return (first + last).toUpperCase() || "?"; +} + +export function AccountMenu({ account }: { account: AccountInfo }) { + const { t } = useTranslation("kit"); + const signOutAction = buildSignOutAction(account.signOutHref ?? "/auth/logout", account.returnTo); + + return ( +
+ + {account.avatarUrl ? ( + + ) : ( + + {initials(account.name)} + + )} + {account.name} + + +
+
+
{account.name}
+ {account.handle &&
@{account.handle}
} +
+ {account.menu?.map((item, i) => ( + + {item.label} + + ))} +
+ +
+
+
+ ); +} diff --git a/kit/ui/ampl-header/AmplHeader.tsx b/kit/ui/ampl-header/AmplHeader.tsx new file mode 100644 index 0000000..e0c5e83 --- /dev/null +++ b/kit/ui/ampl-header/AmplHeader.tsx @@ -0,0 +1,69 @@ +/** + * AmplHeader — the unified two-band header for every ampl.tools tool. + * + * Composes the institutional band, the deep-plum WORKSHOP band, and the mobile + * sheet; holds the single mobile-sheet open/close state. All tool variation + * comes through props. The public entry point for the component. + * + * @version v0.3.0 + */ +import { useState } from "react"; +import { InstitutionalBand } from "./InstitutionalBand"; +import { WorkshopBand } from "./WorkshopBand"; +import { MobileSheet } from "./MobileSheet"; +import { DEFAULT_TOOLS } from "./tools"; +import type { AmplHeaderProps } from "./types"; + +/** + * The unified AMPL header. Two bands: the institutional AMPL identity (logo + + * lab nav, owned by the kit, scales full/compact) and the deep-plum WORKSHOP + * band (tool switcher + contextual nav + EN/ES + account/sign-in). Anchored + * dropdowns use native
; the full-width mobile sheet uses one state + * toggle here. All tool variation comes through props. + */ +export function AmplHeader({ + tool, + toolName, + size = "compact", + nav, + context, + localeSwitcher, + account = null, + signInHref, + signInLabel, + tools = DEFAULT_TOOLS, + labHome = "https://ampl.clair.ucsb.edu/", +}: AmplHeaderProps) { + const [menuOpen, setMenuOpen] = useState(false); + + return ( +
+ setMenuOpen((v) => !v)} + /> + + +
+ ); +} diff --git a/kit/ui/ampl-header/InstitutionalBand.tsx b/kit/ui/ampl-header/InstitutionalBand.tsx new file mode 100644 index 0000000..e0cc1e1 --- /dev/null +++ b/kit/ui/ampl-header/InstitutionalBand.tsx @@ -0,0 +1,76 @@ +/** + * Institutional band — the white top band: AMPL logo lockup (links to the lab + * home) + lab-site nav, scaling between full and compact; plus the mobile + * hamburger that toggles the sheet. Owned by the kit (single AMPL identity). + * + * @version v0.3.0 + */ +import { useTranslation } from "react-i18next"; +import amplLogo from "../../assets/ampl-logo.svg"; +import { LAB_NAV } from "./lab-nav"; +import type { HeaderSize } from "./types"; + +export function InstitutionalBand({ + size, + labHome, + menuOpen, + onMenuToggle, +}: { + size: HeaderSize; + labHome: string; + menuOpen: boolean; + onMenuToggle: () => void; +}) { + const { t } = useTranslation("kit"); + const full = size === "full"; + + return ( +
+
+ + + + + {/* Lab nav — desktop only; mobile uses the sheet. */} + + + {/* Mobile hamburger — toggles the AmplHeader sheet. */} + +
+
+ ); +} diff --git a/kit/ui/ampl-header/MobileSheet.tsx b/kit/ui/ampl-header/MobileSheet.tsx new file mode 100644 index 0000000..e940075 --- /dev/null +++ b/kit/ui/ampl-header/MobileSheet.tsx @@ -0,0 +1,88 @@ +/** + * Mobile sheet — the full-width hamburger-toggled panel for narrow screens: + * lab nav + tool nav + EN/ES + account/sign-in. Always in the DOM; visibility + * via the `hidden`/`aria-hidden` toggle so it is SSR-present and tab-order-correct. + * + * @version v0.3.0 + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { buildSignOutAction } from "../lib/sign-out"; +import { LAB_NAV } from "./lab-nav"; +import type { AccountInfo, NavItem } from "./types"; + +export function MobileSheet({ + open, + nav, + context, + localeSwitcher, + account, + signInHref, + signInLabel, +}: { + open: boolean; + nav?: NavItem[]; + context?: ReactNode; + localeSwitcher: ReactNode; + account?: AccountInfo | null; + signInHref?: string; + signInLabel?: ReactNode; +}) { + const { t } = useTranslation("kit"); + const signOutAction = account + ? buildSignOutAction(account.signOutHref ?? "/auth/logout", account.returnTo) + : ""; + + return ( + + ); +} diff --git a/kit/ui/ampl-header/ToolSwitcher.tsx b/kit/ui/ampl-header/ToolSwitcher.tsx new file mode 100644 index 0000000..fc5433e --- /dev/null +++ b/kit/ui/ampl-header/ToolSwitcher.tsx @@ -0,0 +1,57 @@ +/** + * Tool switcher — the `Workshop · {tool} ▾` cluster on the WORKSHOP band, a + * native
disclosure listing the AMPL tool registry; marks the current tool. + * + * @version v0.3.0 + */ +import { useTranslation } from "react-i18next"; +import type { ToolId, ToolLink } from "./types"; + +export function ToolSwitcher({ + tool, + toolName, + tools, +}: { + tool: ToolId; + toolName: string; + tools: ToolLink[]; +}) { + const { t } = useTranslation("kit"); + + return ( +
+ + Workshop + · + {toolName} + + +
+
+ {t("switcher.heading")} +
+ {tools.map((tl) => { + const current = tl.id === tool; + return ( + + + {tl.name} + + + — {t(`switcher.tagline.${tl.id}`, tl.descriptor)} + + + ); + })} +
+
+ ); +} diff --git a/kit/ui/ampl-header/WorkshopBand.tsx b/kit/ui/ampl-header/WorkshopBand.tsx new file mode 100644 index 0000000..dabef38 --- /dev/null +++ b/kit/ui/ampl-header/WorkshopBand.tsx @@ -0,0 +1,90 @@ +/** + * WORKSHOP band — the deep-plum band: tool switcher + contextual in-app nav + * (active/disabled states) + the EN/ES switcher and account-chip-or-sign-in cluster. + * + * @version v0.3.0 + */ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { ToolSwitcher } from "./ToolSwitcher"; +import { AccountMenu } from "./AccountMenu"; +import type { AccountInfo, NavItem, ToolId, ToolLink } from "./types"; + +export function WorkshopBand({ + tool, + toolName, + nav, + context, + localeSwitcher, + account, + signInHref, + signInLabel, + tools, +}: { + tool: ToolId; + toolName: string; + nav?: NavItem[]; + context?: ReactNode; + localeSwitcher: ReactNode; + account?: AccountInfo | null; + signInHref?: string; + signInLabel?: ReactNode; + tools: ToolLink[]; +}) { + const { t } = useTranslation("kit"); + const hasNav = (nav && nav.length > 0) || context; + + return ( +
+
+ + + {/* Contextual tool nav — desktop only; mobile uses the sheet. */} + {hasNav && ( + + )} + + {/* Right cluster — desktop only. */} +
+ {localeSwitcher} + {account ? ( + + ) : signInHref ? ( + + {signInLabel ?? t("header.signIn")} + + ) : null} +
+
+
+ ); +} diff --git a/kit/ui/ampl-header/index.ts b/kit/ui/ampl-header/index.ts new file mode 100644 index 0000000..78cc44a --- /dev/null +++ b/kit/ui/ampl-header/index.ts @@ -0,0 +1,15 @@ +/** + * AmplHeader barrel — the component, the default tool registry, and the public types. + * + * @version v0.3.0 + */ +export { AmplHeader } from "./AmplHeader"; +export { DEFAULT_TOOLS } from "./tools"; +export type { + AmplHeaderProps, + NavItem, + ToolLink, + ToolId, + AccountInfo, + HeaderSize, +} from "./types"; diff --git a/kit/ui/ampl-header/lab-nav.ts b/kit/ui/ampl-header/lab-nav.ts new file mode 100644 index 0000000..eb12518 --- /dev/null +++ b/kit/ui/ampl-header/lab-nav.ts @@ -0,0 +1,15 @@ +/** + * Lab-site navigation links + * + * The institutional band's four lab-site nav entries, shared by the desktop + * InstitutionalBand and the mobile sheet so the hrefs cannot silently diverge. + * Labels resolve at render via the `kit` i18n `nav.` strings. + * + * @version v0.3.0 + */ +export const LAB_NAV = [ + { key: "tools", href: "https://ampl.clair.ucsb.edu/#tools" }, + { key: "projects", href: "https://ampl.clair.ucsb.edu/#projects" }, + { key: "opportunities", href: "https://ampl.clair.ucsb.edu/#opportunities" }, + { key: "people", href: "https://ampl.clair.ucsb.edu/people" }, +] as const; diff --git a/kit/ui/ampl-header/tools.ts b/kit/ui/ampl-header/tools.ts new file mode 100644 index 0000000..5cc6692 --- /dev/null +++ b/kit/ui/ampl-header/tools.ts @@ -0,0 +1,13 @@ +import type { ToolLink } from "./types"; + +/** + * The live AMPL Workshop tool registry. Display names are public ("Palaeography" + * for the calamus codebase); descriptors are English fallbacks localised at + * render via `switcher.tagline.`. Data-driven so future tools slot in. + * + * @version v0.3.0 + */ +export const DEFAULT_TOOLS: ToolLink[] = [ + { id: "calamus", name: "Palaeography", descriptor: "Practice reading manuscripts", href: "https://ampl.tools/palaeography" }, + { id: "scheduling", name: "Scheduling", descriptor: "booking & polls", href: "https://ampl.tools/scheduling" }, +]; diff --git a/kit/ui/ampl-header/types.ts b/kit/ui/ampl-header/types.ts new file mode 100644 index 0000000..c982669 --- /dev/null +++ b/kit/ui/ampl-header/types.ts @@ -0,0 +1,67 @@ +/** + * AmplHeader prop and data contracts (NavItem, ToolLink, AccountInfo, HeaderSize, + * AmplHeaderProps). The shared type surface consumed across the header components. + * + * @version v0.3.0 + */ +import type { ReactNode } from "react"; + +/** Internal tool id (codename), e.g. "calamus". Drives switcher "current". */ +export type ToolId = string; + +export interface NavItem { + label: ReactNode; + href: string; + /** Marks the active route — white text + 2px underline. */ + active?: boolean; + /** Renders muted + non-interactive (for not-yet-built routes). */ + disabled?: boolean; +} + +export interface ToolLink { + id: ToolId; + /** Public display name (proper noun — not translated). */ + name: string; + /** English descriptor; localised by id via `switcher.tagline.` when present. */ + descriptor: string; + href: string; +} + +export interface AccountInfo { + name: string; + handle?: string; + avatarUrl: string | null; + /** POST logout endpoint; defaults to "/auth/logout". */ + signOutHref?: string; + /** Post-logout destination, guarded + appended as ?return_to=. */ + returnTo?: string; + /** Optional extra menu links (e.g. Settings, Report a problem). */ + menu?: { label: ReactNode; href: string }[]; +} + +export type HeaderSize = "full" | "compact"; + +export interface AmplHeaderProps { + /** Internal tool id — drives the switcher "current" highlight. */ + tool: ToolId; + /** Public display name shown in the WORKSHOP band (e.g. "Palaeography"). */ + toolName: string; + /** Institutional-band scale. Default "compact"; "full" only on the signed-out front door. */ + size?: HeaderSize; + /** Contextual in-app nav. Omit for public/front-door states. */ + nav?: NavItem[]; + /** Optional context chip (e.g. the current group), rendered after the switcher. */ + context?: ReactNode; + /** Pre-wired ``. */ + localeSwitcher: ReactNode; + /** Signed-in account. null/undefined → public/signed-out (sign-in link shown). */ + account?: AccountInfo | null; + /** Sign-in href, shown when `account` is null. */ + signInHref?: string; + /** Sign-in label override (default: kit `header.signIn`). e.g. "Host sign in". */ + signInLabel?: ReactNode; + /** Cross-tool switcher registry. Default: DEFAULT_TOOLS. */ + tools?: ToolLink[]; + /** Lab-home href for the lockup. Default: the live lab site. */ + labHome?: string; +} diff --git a/kit/ui/index.ts b/kit/ui/index.ts index fbf525e..c09f7ad 100644 --- a/kit/ui/index.ts +++ b/kit/ui/index.ts @@ -11,11 +11,14 @@ * app-specific logic. The session-validation code deliberately lives in its own * separate module and is not exposed through this barrel. * - * @version v0.1.0 + * @version v0.3.0 */ export { SiteFooter } from "./SiteFooter"; +/** @deprecated Superseded by AmplHeader (v0.3.0). Kept for back-compat. */ export { SiteHeader } from "./SiteHeader"; +export { AmplHeader, DEFAULT_TOOLS } from "./ampl-header"; +export type { AmplHeaderProps, NavItem, ToolLink, ToolId, AccountInfo, HeaderSize } from "./ampl-header"; export { LocaleSwitcher } from "./LocaleSwitcher"; export { Button } from "./Button"; export { AccountWidget } from "./AccountWidget"; diff --git a/kit/ui/lib/sign-out.ts b/kit/ui/lib/sign-out.ts new file mode 100644 index 0000000..b3b87de --- /dev/null +++ b/kit/ui/lib/sign-out.ts @@ -0,0 +1,18 @@ +/** + * Sign-out action URL builder + * + * Shared by AccountWidget and the AmplHeader AccountMenu. Appends a guarded + * `return_to` to the logout endpoint without clobbering any query string the + * href already carries. Built with URLSearchParams (not string concat) so + * existing params survive and the value is encoded; the split on "?" keeps it + * working for relative hrefs, which `new URL()` can't parse without a base. + * + * @version v0.3.0 + */ +export function buildSignOutAction(signOutHref: string, returnTo?: string): string { + if (!returnTo) return signOutHref; + const [path, existingQuery = ""] = signOutHref.split("?"); + const params = new URLSearchParams(existingQuery); + params.set("return_to", returnTo); + return `${path}?${params.toString()}`; +} diff --git a/package-lock.json b/package-lock.json index f2f631b..9f8b74a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ampl/kit", - "version": "0.2.2", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ampl/kit", - "version": "0.2.2", + "version": "0.3.0", "hasInstallScript": true, "dependencies": { "@oslojs/crypto": "^1.0.1", diff --git a/package.json b/package.json index dc3e2e1..7e3b3b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ampl/kit", - "version": "0.2.2", + "version": "0.3.0", "private": true, "type": "module", "description": "Shared foundation for the AMPL tools suite: the ampl-auth Worker (ampl.tools/auth) + the @ampl/kit design system, surfaces, and session-validation helper consumed by every tool.", diff --git a/tests/kit/AmplHeader.test.ts b/tests/kit/AmplHeader.test.ts new file mode 100644 index 0000000..6059891 --- /dev/null +++ b/tests/kit/AmplHeader.test.ts @@ -0,0 +1,95 @@ +/** + * AmplHeader rendering tests + * + * Server-rendered (renderToString, no jsdom, no i18n provider → t() returns the + * key). Assertions stick to structure/HTML: hrefs, classes, aria-current, the + * account-vs-sign-in branch, switcher registry, and the size variant. Tool names + * and nav labels are literal props/registry values, so they assert directly. + */ +import { describe, it, expect } from "vitest"; +import { renderToString } from "react-dom/server"; +import React from "react"; + +async function render(props: Record) { + const { AmplHeader } = await import("kit/ui/ampl-header/AmplHeader"); + return renderToString(React.createElement(AmplHeader, props as never)); +} + +const SWITCHER = React.createElement("span", { "data-testid": "ls" }, "EN/ES"); + +describe("AmplHeader", () => { + it("signed-in: renders the account menu (POST sign-out form), no sign-in link", async () => { + const html = await render({ + tool: "calamus", toolName: "Palaeography", localeSwitcher: SWITCHER, + account: { name: "Juan Cobo", handle: "juan", avatarUrl: null, signOutHref: "/auth/logout", returnTo: "/palaeography" }, + }); + expect(html).toContain("Palaeography"); + expect(html).toContain('method="post"'); + expect(html).toContain('action="/auth/logout?return_to=%2Fpalaeography"'); + expect(html).not.toContain("header.signIn"); // no sign-in link rendered + }); + + it("public/signed-out: renders the sign-in link, no account form", async () => { + const html = await render({ + tool: "scheduling", toolName: "Scheduling", localeSwitcher: SWITCHER, + account: null, signInHref: "/auth/login", + }); + expect(html).toContain('href="/auth/login"'); + expect(html).not.toContain('method="post"'); + }); + + it("sign-in label is overridable", async () => { + const html = await render({ + tool: "scheduling", toolName: "Scheduling", localeSwitcher: SWITCHER, + account: null, signInHref: "/auth/login", signInLabel: "Host sign in", + }); + expect(html).toContain("Host sign in"); + }); + + it("switcher lists the default registry and marks the current tool", async () => { + const html = await render({ tool: "calamus", toolName: "Palaeography", localeSwitcher: SWITCHER, account: null }); + expect(html).toContain("Palaeography"); + expect(html).toContain("Scheduling"); + expect(html).toContain('aria-current="true"'); + }); + + it("active nav item gets the white underline (after: + text-white)", async () => { + const html = await render({ + tool: "calamus", toolName: "Palaeography", localeSwitcher: SWITCHER, account: null, + nav: [ + { label: "Dashboard", href: "/d", active: false }, + { label: "Library", href: "/l", active: true }, + { label: "Review queue", href: "/r", disabled: true }, + ], + }); + expect(html).toContain('href="/l"'); + expect(html).toContain('aria-current="page"'); + expect(html).toContain("after:bg-white"); + expect(html).toContain('aria-disabled="true"'); + }); + + it("full size uses the 220px logo; compact uses 132px", async () => { + const full = await render({ tool: "calamus", toolName: "Palaeography", localeSwitcher: SWITCHER, account: null, size: "full" }); + expect(full).toContain("w-[220px]"); + const compact = await render({ tool: "calamus", toolName: "Palaeography", localeSwitcher: SWITCHER, account: null }); + expect(compact).toContain("w-[132px]"); + }); + + it("renders the deep-plum WORKSHOP band and the mobile sheet (hidden at rest)", async () => { + const html = await render({ tool: "calamus", toolName: "Palaeography", localeSwitcher: SWITCHER, account: null }); + expect(html).toContain("bg-accent-deep"); + // The mobile sheet is always in the DOM but hidden until the hamburger + // toggles it; SSR (menuOpen=false) must emit the `hidden` attribute. + expect(html).toMatch(/id="ampl-mobile-sheet"[^>]*\shidden/); + }); + + it("account takes precedence over signInHref (no sign-in link when signed in)", async () => { + const html = await render({ + tool: "calamus", toolName: "Palaeography", localeSwitcher: SWITCHER, + account: { name: "Juan Cobo", handle: "juan", avatarUrl: null }, + signInHref: "/auth/login", + }); + expect(html).toContain('method="post"'); // account menu present + expect(html).not.toContain('href="/auth/login"'); // sign-in link suppressed + }); +}); diff --git a/tests/kit/LocaleSwitcher.test.ts b/tests/kit/LocaleSwitcher.test.ts new file mode 100644 index 0000000..e083d2a --- /dev/null +++ b/tests/kit/LocaleSwitcher.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { renderToString } from "react-dom/server"; +import React from "react"; + +describe("LocaleSwitcher", () => { + it("default variant uses light-surface classes", async () => { + const { LocaleSwitcher } = await import("kit/ui/LocaleSwitcher"); + const html = renderToString( + React.createElement(LocaleSwitcher, { buildHref: (l: "en" | "es") => `/l?lng=${l}`, current: "en" }), + ); + expect(html).toContain("text-fg-1"); + expect(html).not.toContain("text-white"); + }); + + it("on-dark variant uses white classes for the plum band", async () => { + const { LocaleSwitcher } = await import("kit/ui/LocaleSwitcher"); + const html = renderToString( + React.createElement(LocaleSwitcher, { buildHref: (l: "en" | "es") => `/l?lng=${l}`, current: "en", variant: "on-dark" }), + ); + expect(html).toContain("text-white"); + expect(html).toContain("text-white/60"); // idle link stays readable on the plum band + expect(html).toContain('aria-current="true"'); + }); +}); diff --git a/tests/routes/root-header.test.ts b/tests/routes/root-header.test.ts index f00930a..7831b17 100644 --- a/tests/routes/root-header.test.ts +++ b/tests/routes/root-header.test.ts @@ -1,107 +1,67 @@ /** * Root layout header tests (SSR) * - * These tests cover the header wiring in `app/root.tsx` — the AMPL logo lockup - * and the four-link lab-site nav. Because `Layout` uses - * `useRouteLoaderData` which requires a data router context, tests render the - * header subcomponents (SiteHeader + the logo/nav nodes) directly using - * `renderToString`, mirroring the shape that root.tsx wires up. This is the - * same SSR-inspection strategy as `tests/routes/auth.login.test.ts`, adapted - * for a component under a data-router boundary. + * These cover the header wiring in `app/root.tsx`, which renders the shared + * `AmplHeader` (full masthead, signed-out front door). Because `Layout` uses + * `useRouteLoaderData` (needs a data-router context), the test renders + * `AmplHeader` directly with the same props root.tsx passes, using + * `renderToString`. No i18n provider in the Workers pool → `t()` returns keys, + * so assertions target hrefs and structural markers, not translated copy. * - * @version v0.1.0 + * @version v0.3.0 */ import { describe, it, expect } from "vitest"; import { renderToString } from "react-dom/server"; import React from "react"; -describe("root layout header", () => { - it("logo img is present, wrapped in a link to the lab home", async () => { - const { SiteHeader } = await import("@ampl/kit/ui"); - const amplLogo = await import("@ampl/kit/assets/ampl-logo.svg"); - - const logoNode = React.createElement( - "a", - { href: "https://ampl.clair.ucsb.edu/", "aria-label": "AMPL — Archives, Memory, and Preservation Lab" }, - React.createElement("img", { src: amplLogo.default, alt: "", className: "h-auto w-[220px]" }), - ); - - const html = renderToString( - React.createElement(SiteHeader, { children: logoNode }), - ); +async function renderHeader() { + const { AmplHeader, LocaleSwitcher } = await import("@ampl/kit/ui"); + const switcher = React.createElement(LocaleSwitcher, { + buildHref: (lng: "en" | "es") => `/auth/locale?lng=${lng}&to=%2F`, + current: "en" as "en" | "es", + variant: "on-dark" as const, + }); + return renderToString( + React.createElement(AmplHeader, { + tool: "auth", + toolName: "Account", + size: "full", + localeSwitcher: switcher, + }), + ); +} - // The lab-home link is present +describe("root layout header", () => { + it("renders the AMPL logo lockup linking to the lab home", async () => { + const html = await renderHeader(); expect(html).toContain('href="https://ampl.clair.ucsb.edu/"'); - // An img element is inside the link expect(html).toContain(" { - const { SiteHeader } = await import("@ampl/kit/ui"); - const amplLogo = await import("@ampl/kit/assets/ampl-logo.svg"); - - const logoNode = React.createElement( - "a", - { href: "https://ampl.clair.ucsb.edu/", "aria-label": "AMPL — Archives, Memory, and Preservation Lab" }, - React.createElement("img", { src: amplLogo.default, alt: "", className: "h-auto w-[220px]" }), - ); - - const html = renderToString( - React.createElement(SiteHeader, { children: logoNode }), - ); - - // Old text lockup must not appear - expect(html).not.toContain(">AMPL Auth<"); - }); - - it("nav slot renders all four lab-site links", async () => { - const { SiteHeader } = await import("@ampl/kit/ui"); - - const navNode = React.createElement( - "nav", - { "aria-label": "Lab site navigation" }, - React.createElement("a", { href: "https://ampl.clair.ucsb.edu/#tools" }, "Tools"), - React.createElement("a", { href: "https://ampl.clair.ucsb.edu/#projects" }, "Projects"), - React.createElement("a", { href: "https://ampl.clair.ucsb.edu/#opportunities" }, "Opportunities"), - React.createElement("a", { href: "https://ampl.clair.ucsb.edu/people" }, "People"), - ); - - const html = renderToString( - React.createElement(SiteHeader, { nav: navNode }), - ); - - // All four lab-site hrefs must appear + it("renders all four lab-site nav links", async () => { + const html = await renderHeader(); expect(html).toContain('href="https://ampl.clair.ucsb.edu/#tools"'); expect(html).toContain('href="https://ampl.clair.ucsb.edu/#projects"'); expect(html).toContain('href="https://ampl.clair.ucsb.edu/#opportunities"'); expect(html).toContain('href="https://ampl.clair.ucsb.edu/people"'); }); - it("nav and localeSwitcher are both rendered when both slots are filled", async () => { - const { SiteHeader, LocaleSwitcher } = await import("@ampl/kit/ui"); - - const navNode = React.createElement( - "nav", - { "aria-label": "Lab site navigation" }, - React.createElement("a", { href: "https://ampl.clair.ucsb.edu/#tools" }, "Tools"), - React.createElement("a", { href: "https://ampl.clair.ucsb.edu/people" }, "People"), - ); - - const switcherNode = React.createElement(LocaleSwitcher, { - buildHref: (lng: "en" | "es") => `/auth/locale?lng=${lng}&to=%2F`, - current: "en" as "en" | "es", - }); + it("renders the WORKSHOP band with the tool name and locale switcher", async () => { + const html = await renderHeader(); + expect(html).toContain("bg-accent-deep"); + expect(html).toContain("Account"); // toolName + expect(html).toContain("/auth/locale"); // locale switcher wired + }); - const html = renderToString( - React.createElement(SiteHeader, { nav: navNode, localeSwitcher: switcherNode }), - ); + it("does NOT contain the old 'AMPL Auth' text lockup", async () => { + const html = await renderHeader(); + expect(html).not.toContain(">AMPL Auth<"); + }); - // Both nav links and the locale switcher are in the output - expect(html).toContain('href="https://ampl.clair.ucsb.edu/#tools"'); - // LocaleSwitcher renders locale-switch links containing /locale - expect(html).toContain("/locale"); + it("uses the full masthead logo size (220px)", async () => { + const html = await renderHeader(); + expect(html).toContain("w-[220px]"); }); });