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 (