The universal header bar embedded across UCF web properties. Usage instructions
for site owners live at https://universityheader.ucf.edu (built from site/).
This file covers working on the header itself.
Node 20+. That is the whole list.
npm ci
cp .env.example .env # optional; sensible defaults apply without it
npm run dev # watch + serve on http://localhost:4321npm run dev builds to dist/ and serves it. The documentation page at
/index.html loads the header the same way a real site does, and forwards any
supported option passed to the page so you can preview it — e.g.
/index.html?use-full-width=1.
| Script | What it does |
|---|---|
npm run dev |
Watch build + local server |
npm run build |
Production bundle to dist/ |
npm run size |
Payload budget gate — fails over 9 KB gzip |
npm run bench |
v3-vs-v4 load benchmark, 20 uncached loads per variant |
npm run lint / lint:fix |
Biome (lint + format) |
npm run typecheck |
tsc --noEmit |
npm test |
Vitest units |
npm run test:e2e |
Playwright, Chromium + Firefox + WebKit + mobile WebKit |
npm run test:visual |
Visual regression (see caveat below) |
npm run test:visual:docker |
Visual regression in the pinned container |
npm run test:a11y |
axe-core against every host fixture |
npm run verify |
Everything CI runs, in order |
One TypeScript entry point bundled by esbuild into a single IIFE, with the
stylesheet and every SVG inlined as strings. That is deliberate: the previous
version fetched bar.css as a second request, so the bar could not paint until
a full extra round trip completed. Inlining trades a slightly larger file for
one fewer request and no unstyled gap.
The bar renders into a shadow root attached to #ucfhb. Host page CSS
cannot reach in and the bar's CSS cannot leak out, which is what removed the
!important arms race, the Foundation box-sizing reset, and the Bootstrap 2
override stylesheet that v3 needed.
Everything that is not "make the header appear" — analytics, and later the
signed-in view — is deferred behind requestIdleCallback after
DOMContentLoaded. The critical path is: read flags, mount, wire the search
toggle. Nothing else.
The search carries a UCF / Site switch. The hard part was not the logic. It was finding room for it. From 768px up the switch sits in the row, left of the field, and fits because opening search already spends the wordmark below 980px. On a phone it does not fit: next to the lock and the search button it would squeeze the field under 100px. So below 768px the same switch moves onto a shelf under the bar, positioned like the sign-in tray, and the field keeps the whole row. The shelf is absolutely positioned, so the bar keeps its height and the host page never reflows.
The form still works without JavaScript because it has a real action and one
input named q, and the scope must not cost us that. The options are
<button role="radio"> rather than real radios, because a radio needs a name
and any named control in the form is sent to search.ucf.edu as a stray
parameter. The scope is applied at submit time by rewriting the field to
site:<hostname> <query> and restoring it on the next tick. The browser builds
the form's entry list synchronously, so the scoped query is what gets sent,
while the visitor still sees what they typed.
The hostname is used verbatim, with no www. stripping. site: is a prefix
match, so trimming www.ucf.edu to ucf.edu would widen the search to every
subdomain. A query that already contains its own site: operator is left alone.
Analytics reports a scope change as click_scope with the new scope as
ucf_target, and adds ucf_scope to submit_search. A GTM container needs a
Data Layer Variable for ucf_scope before it can use that value.
A host page that pads its <body> insets the header, because the shadow host's
containing block is outside the shadow root. There is no fix that does not
involve 100vw (which breaks when a scrollbar is present). This is measured by
a test in tests/e2e/isolation.spec.ts so it stays a known quantity.
Build-time values are injected by esbuild define, read from the environment or
a local .env:
| Variable | Default | Purpose |
|---|---|---|
GTM |
(empty) | GTM container ID. Empty means analytics never loads. |
ROOT_URL |
universityheader.ucf.edu |
Serving origin. Reserved for the Phase 2 session endpoint. |
SEARCH_URL |
https://search.ucf.edu/ |
Where the search form submits. |
UCFHB_SESSION |
0 |
Set to 1 to compile in the Phase 2 signed-in seam. |
Runtime flags on the script tag are a separate contract, documented on
https://universityheader.ucf.edu. 4.0.0 adds one:
use-site-search-default=1, which opens the search already scoped to the host
site rather than to all of UCF.
SEARCH_URLreplacedSEARCH_SERVICE, which was defined in the deploy workflows but never used — and named something else entirely (search.cm.ucf.edu, the data API hub, not the search destination).
The most useful tests are the host-page fixtures in tests/fixtures/. Each is a
page carrying global CSS chosen to break a light-DOM header — a { color: red !important }, button { all: unset }, Foundation's content-box reset — and
the bar must render identically on all of them. That is asserted both by
computed geometry and by comparing every fixture against one shared screenshot.
npm run bench measures this branch against the live production v3 header
and reports the distribution across 20 uncached loads per variant.
This is a tool for answering a question, not a gate. It is deliberately not
part of CI: it takes minutes and it reports numbers to read rather than a
threshold to trip. npm run size is the CI performance gate.
npm run bench # 20 loads, mobile/none/4g/3g profiles
npm run bench -- --runs=50 --profiles=3g # one profile, more samples
npm run bench -- --gtm=GTM-XXXXXXX # build v4 with a real container
npm run bench -- --flags= --refresh # bare embed, re-fetch v3v3 is downloaded from universityheader.ucf.edu (cached under .bench/; use
--refresh to re-fetch), its hardcoded origin rewritten to the local server,
and served from the same in-memory gzip origin as v4. Three host pages —
control with no header, legacy, and v4 — are byte-identical apart from the
one script tag, and the page itself costs exactly one request.
The headline metric is header painted: the first animation frame in which
#ucfhb has non-zero layout height. It is version-neutral by construction. v3
cannot reach it until bar.css arrives, because the bar is inserted with
#ucfhb-inner set to display:none and only the stylesheet reveals it; v4
reaches it when the shadow root mounts. Neither version instruments itself.
Each profile also reports the Core Web Vitals a lab run can measure, read the way Google reads them: the 75th percentile of loads, rated good / needs work / poor against Google's thresholds.
- LCP — largest contentful paint (good ≤ 2.5 s).
- CLS — cumulative layout shift, using Google's session-window definition (good ≤ 0.1). This is where the two versions differ in kind: v3's bar has no height until its stylesheet arrives after the page has painted, so the page jumps; v4 reserves its height when it mounts.
- TBT — total blocking time, counted as Lighthouse counts it (good ≤ 200 ms). INP needs a real visitor's input and cannot be measured in a lab; TBT is the proxy Lighthouse uses in its place.
The column to quote is each header's cost — its p75 minus the no-header
control page's — shown with the share of the "good" threshold it spends. The
host page is deliberately minimal, so absolute ratings describe it; what a
header adds on top carries over to any page. The mobile profile is the one
to quote: it reproduces PageSpeed Insights' mobile run (Lighthouse's slow 4G, a
4x CPU slowdown, a 412px Moto G Power screen). All of this is lab data from one
Chromium. Google ranks on field data from real Chrome users (CrUX), so these
numbers indicate which way that data should move, not the score a site will get.
Things the harness does deliberately, because each one would otherwise turn into a wrong conclusion:
- Throttling is the point. On loopback a round trip is free, so v3 and v4
finish within a millisecond of each other. The
noneprofile is reported as a floor, not as the result. Onlymobilealso slows the CPU and changes the screen; the others are network throttling on the 1440px desktop viewport. - Analytics is not measured. v3's gtag injection is always stripped, and v4
builds with no container unless
--gtmsays otherwise. The header defers its own tags behindrequestIdleCallback, so they cannot affect anything a visitor waits for; leaving a third-party request in a 20-run comparison only imports network variance. - Measurement stops when the header's assets go quiet, not at network idle,
so a container loading in the background never holds up or distorts a sample.
Nor does it stop at
load: v3 discovers its spritesheet only afterbar.cssparses, and on a slow link that request is still in flight whenloadfires. - Variants are interleaved and rotated each iteration, so machine drift during the run cannot land on one version.
- Every load is a fresh context with the HTTP cache disabled and
no-storefrom the server. This is the first-visit case. In production the header is cached acrossucf.edusubdomains, so it is the worst case for both versions — and the case the extra round trips actually hit.
--gtm=GTM-XXXXXXX builds v4 with a real container, so a run can confirm what
GTM costs the initial page load. On current measurements the answer is
nothing: load fires at 168 ms without a container and 171 ms with one,
because initAnalytics runs inside requestIdleCallback and the container is
not fetched until after the load event has already gone. That is the flag's
purpose — to verify that claim rather than assume it. What the container does
afterwards is out of scope; the run has stopped measuring by then.
Raw per-run JSON lands in .bench/results/. Note that the run leaves dist/
holding the benchmark build; npm run build restores it.
Baselines are only authoritative from the pinned Playwright container. Font rasterization differs enough between macOS and Linux that locally generated baselines produce permanent false diffs against CI. Committed baselines are the Linux ones; macOS baselines are gitignored and exist only as a local smoke test.
npm run test:visual:docker # run against committed baselines
npm run test:visual:docker -- --update-snapshots # re-baseline after a design changeBaselines stay loose while the design iterates, and get tightened once the look is signed off.
Visual regression runs as six projects. Three are desktop engines driven by
explicit setViewportSize across six widths (tests/visual/bar.spec.ts). Three
are iOS device descriptors — WebKit plus a mobile user agent, isMobile, touch,
and a 3x device pixel ratio — which supply their own viewport, so
tests/visual/mobile.spec.ts never sets one. Their widths bracket the 390px
wordmark breakpoint:
| Project | Device | Width | Wordmark |
|---|---|---|---|
visual-ios-se |
iPhone SE (3rd gen) | 375px | clipped |
visual-ios-14 |
iPhone 14 | 390px | visible |
visual-ios-max |
iPhone 14 Pro Max | 430px | visible |
This is emulation, not a device. Playwright's WebKit is the same engine as
Safari but not the same product, so it will catch CSS and layout regressions
while missing anything that depends on real iOS: native form-control chrome,
momentum scrolling, the keyboard resizing the viewport, and focus zoom. Those
last ones are driven by values we can read directly, so they are asserted as
computed style and geometry in tests/e2e/ios.spec.ts (project e2e-ios)
rather than by pixel diff — the check then fails with a number instead of a
blurry image. Anything that genuinely needs real iOS Safari needs a device
cloud or a physical device; nothing in this repo covers it.
Two traps those assertions exist for, both found on real iPhones and neither reproducible in emulation:
Focus zoom. iOS Safari zooms the page when a text field under 16px receives
focus, and does not zoom back out on blur. .search-input is therefore 16px at
mobile widths. Do not "fix" a recurrence with maximum-scale=1 — the viewport
meta belongs to the host page, and disabling pinch-zoom across every UCF site
to tidy up one input is an accessibility regression.
Do not use :has() for state that script toggles. The mobile layout has to
shrink the wordmark to make room for the open field, which means styling an
ancestor of .search from .search's state. .inner:has(.search.is-open)
expresses that exactly and works in every engine the test suite can drive — but
iOS Safari does not reliably re-invalidate a :has() ancestor when script
mutates a descendant's class inside a shadow root. It matched on first paint,
went stale on toggle, and the wordmark kept its width and squeezed the field
down to a bare caret. initSearch therefore mirrors the state onto .inner as
.is-searching, and the rules are plain descendant selectors. If you find
yourself reaching for :has() against a scripted class again, mirror the class
instead.
Pushes to develop and test deploy to the corresponding Azure Static Web App.
Live is a manual workflow_dispatch against a tag. Each workflow runs
npm ci && npm run build explicitly and uploads dist/.
Repository variables must be renamed alongside the GTM swap. The workflows now read
GTM_ID_DEV,GTM_ID_TESTandGTM_ID_LIVEwhere they previously readGA_ID_*. An unset variable is not a build error — it compiles the analytics seam out entirely — so a deploy that ships with no analytics at all looks exactly like a successful one.
src/
index.ts entry: read flags → mount → schedule deferred work
config.ts script-tag and query-param parsing
render.ts shadow root, adoptedStyleSheets, mount
template.ts markup, state-driven right-hand zone
styles/bar.css the whole stylesheet, inlined at build
icons/ brand/ inlined SVG
features/ search (critical), analytics (deferred), session (Phase 2)
site/ the universityheader.ucf.edu documentation page
tests/ unit · e2e · visual · a11y · host-page fixtures
scripts/ build, size gate, static server, containerized visuals
scripts/bench/ v3-vs-v4 load benchmark
The experiments page has been retired for launch.
site/experiments/was an unlinked,noindexsandbox for previewing proposed changes against the live bar — each experiment adata-x-*attribute on the host element, a rule in a stylesheet appended after the bar's own, and a switch on the page. Its last experiment was the wordmark toggle. To bring it back, restore it from history withgit checkout 44e99d5 -- site/experiments.
See CONTRIBUTING.md.