The Media Comprehension Lab website — mediacomplab.com — built with Astro.
The site began as a like-for-like port of the lab's old Hexo theme, and was then redesigned (2026)
into its current form: a quiet, editorial academic look — Inter for everything, white surfaces, GSU
Blue (#0039A6) for the masthead, the footer and things you can click, one shape (the rectangle),
hairlines instead of boxes, and motion that never lifts, bounces or pops.
WEB_LAB/
├── hexo/ ← the original Hexo site, kept as an archive
└── mediacomplab.github.io/ ← this project
npm install
npm run dev # http://localhost:4321
npm run build # → dist/ (runs the content check first)
npm run preview # serve dist/ locally
npm run check # type-check the .astro and .ts sources
npm run verify # compare dist/ against the reference build
npm run snapshot # make the current build the referenceOn Windows you can double-click update-site.bat, which builds, verifies, commits and pushes.
Visual styles live in src/styles/site.css — one stylesheet, organised in numbered sections:
| Palette | GSU Blue #0039A6 (plus --blue-700/500/300/100), near-black ink, cool greys, white surfaces. No second deep colour: the masthead and the footer are both GSU Blue, so the page is bookended by one brand colour rather than by two navies that never quite match |
| Shape | one — --radius: 0. Rectangles only: no pills, no circles, no rounded panels |
| Type | Two faces, no more. Inter 400–700 for every piece of text on the site; Fredoka for the lab wordmark alone. A third face — Source Serif 4 on the display headings — was removed because it read as a different site. Fluid display sizes via clamp() |
| Surfaces | 1px --line borders and hairline rules; boxes and shadows are rare (search panel, floating tools) |
| Width | one container, 1140px, on every page. A container--narrow (780px) once wrapped publications, post pages and the Virginia profile, which made those pages visibly narrower than people/research/news. It has been removed rather than left unused |
| Motion | One easing curve (--ease) and three durations; opacity-led scroll reveals, a sliding nav indicator, a slow photographic drift and crossfades. Hover is quiet everywhere except two deliberate signatures: the hero letters hop on hover, and People cards lift while the bio panel slides over them |
Three rules keep it coherent:
- Colour is blue or grey. The only other colour in the UI is the GSU lockup itself.
- One shape. Everything is a rectangle; hairline rules and spacing do the organising. If you
ever want softness back,
--radiusin the token block is the only value to change — everyborder-radiusin the stylesheet points at it. - Motion collapses under
prefers-reduced-motion— see the last block of the stylesheet.
Two CSS traps worth knowing about, both of which cost real debugging time:
- An
animation-fill-mode: forwardsanimation keeps ownership of the properties it animates and silently beats a later:hovertransform on the same element. Entrance animations here usebackwardsfor that reason: the entrance plays once, then the animated properties are handed back to normal cascade. visibilityis a discrete property; putting it in atransitionmakes it depend on interpolation. Flip it instantly and delay it withvisibility 0s linear <duration>instead (see.search-overlay).
There is no Bootstrap, no jQuery and no icon font. Site chrome is inline SVG (src/components/Icon.astro);
Font Awesome is loaded only for brand marks (GitHub, LinkedIn, ResearchGate, X, Facebook).
astro.config.mjs site url, markdown pipeline, integrations
integrations/
hexo-external-links.mjs adds target/rel to outbound links, as the old site did
doi-link-text.mjs relabels bare-DOI anchors in bibliographies as "Full text"
src/
styles/site.css the whole design system
scripts/site.js shared browser behaviour and hero motion controller
consts.ts site + theme config (a port of the old _config.yml files)
content.config.ts collections and front matter schema
content/
posts/ news items
pages/ home, people, publications, research, tags, virginia
layouts/BaseLayout.astro the shell
components/
SiteHeader.astro sticky header: GSU lockup, lab name, nav with sliding indicator
SiteFooter.astro footer, on the same GSU Blue as the masthead
Hero.astro photo-first home carousel and a separate lab identity band
PostList.astro news / archive list (optionally grouped by year)
EntryRow.astro one row of that list
SearchOverlay.astro search dialog, fed by /search.xml
Tools.astro floating search / share / back-to-top
Icon.astro inline SVG icons
Comments.astro Valine, from the old theme
pages/ routes
scripts/
check-content.mjs guards the Markdown/HTML indentation trap (see below)
postbuild.mjs flattens source pages to <name>.html like Hexo did
verify-against-hexo.mjs compares dist/ against the reference build
snapshot.mjs writes .baseline/ for that comparison
visual-diff.mjs renders two builds in headless Chrome and compares them
hover-preview.mjs screenshots a card page with the hover panel open
touch-preview.mjs renders the @media (hover: none) layout
public/ css, js, plugins, images, CNAME, robots.txt
| URL | File |
|---|---|
/ |
src/pages/index.astro — hero + src/content/pages/index.md |
/people/, /publications/, /research/ |
src/pages/[...slug].astro |
/people/virginia |
src/pages/[...slug].astro + scripts/postbuild.mjs |
/2023/05/19/<slug>/ |
src/pages/[year]/[month]/[day]/[...slug].astro |
/tags/ (the NEWS tab), /tags/<Tag>/ |
src/pages/tags/** |
/default-index/ |
kept for parity with the Hexo build, not linked |
/search.xml, /sitemap.xml, /sitemap.txt, /baidusitemap.xml |
src/pages/*.js |
/archives/ and its year/month sub-pages were removed. The Hexo theme generated thirteen of
them, but nothing on the site ever linked to them and none was in the sitemap — they were orphaned
in the original too, reachable only by typing the URL. The news index lists every update and the tag
pages filter them, so the set earned nothing but build time. Those URLs now 404; verify --hexo
counts them as dropped on purpose rather than missing, and the theme.navbar entry that pointed at
the route went with them so nobody can re-enable a link to a page that no longer exists.
A news item — add a file to src/content/posts/. The file name becomes the URL slug:
---
title: Gal Kaldes won the X from the American Educational Research Association
date: 2023-05-19 21:17:40
tags: Recent awards and accolades
---A title of the form #Headline@https://example.com renders as an external link and is shown without
the #; that is how the lab has always entered awards and media appearances. The home page, the
live index and the tag pages all understand it.
A lab member — src/content/pages/people/index.md holds two card grids: current members, then a
divider and the Alumni section. Copy a <article class="person"> block and change the photo, name,
role, blurb and links.
- Put the headshot in
public/images/and reference it as/images/<name>.jpg. The card crops it to a square from the top, so a portrait or square photo works best. - Hovering (or keyboard-focusing) a card lifts it and slides the biography panel over the bleached portrait. On touch devices the panel rests open below the name, so nothing is ever hidden.
A research area — src/content/pages/research/index.md is a stack of <details class="accordion">
blocks. Copy one, and use <h3> for a sub-topic and <ul class="pub-list"> for its publications.
Publications — src/content/pages/publications/index.md is plain Markdown grouped under
<h2 class="pub-year"> headings. ***Name*** renders a lab member in bold italic; the bullet is
drawn by CSS, so do not type a leading *.
These pages are Markdown files whose bodies are mostly raw HTML. In CommonMark:
- a line indented by four or more spaces after a blank line starts an indented code block, and
- a blank line ends an HTML block, after which the rest is re-parsed as Markdown.
So raw HTML in src/content/pages/*.md must sit flush at column 0, and a block must not contain a
blank line. Get this wrong and a whole section renders as a syntax-highlighted code block.
scripts/check-content.mjs runs before every build and fails with the exact file and line.
npm run build && npm run verify # against .baseline/ if it exists, else the Hexo archive
npm run visual-diff # renders both builds and compares them region by region
npm run snapshot # accept the current build as the new referenceverifycomparesdist/against a reference build.<style>,<script>and<title>contents are compared byte for byte; other markup is compared as a normalised token stream so that framework formatting does not drown out real differences. Static assets are byte compared. It defaults to.baseline/(a snapshot you bless withnpm run snapshot) and falls back to the archived Hexo output, where page markup is reported as redesigned rather than compared.visual-diffis the belt-and-braces check: the pages animate, so it takes several screenshots of each build and reports the smallest difference per region. Static regions must come out at exactly 0 pixels.hover-previewopens the hover panel on the People cards so a long blurb can be checked.
If you change anything cosmetic, run build, verify and visual-diff. Byte-level comparison alone
once missed a UTF-8 BOM pasted into a <style> block, which made the browser drop a whole CSS rule;
only rendering both builds caught it.
Pushing to main is the whole procedure. .github/workflows/deploy.yml runs npm ci and
npm run build on a GitHub runner, uploads dist/ as a Pages artifact, and publishes it. Nothing is
built locally for a deploy, and update-site.bat is just a convenience wrapper around
git add -A && git commit && git push.
The repository has already been set up:
- Pages Source is GitHub Actions (
build_type: workflow). It used to be Deploy from a branch with the Hexo output committed at the repo root; that output is now the previous commit rather than the working tree, so this branch holds source. mediacomplab.comis the Pages custom domain, andpublic/CNAMEcarries the same name so the artifact keeps it. Both are needed — the file alone is not enough with Actions-based Pages.- DNS for
mediacomplab.comsits behind Cloudflare, which proxies to GitHub Pages. Cloudflare terminates TLS at the edge, so the site is served over HTTPS even while GitHub is issuing its own certificate for the domain.
If the custom domain ever goes missing, the symptom is
mediacomplab.comreturning 404 whilemediacomplab.github.iostill serves fine — the artifact is deployed but the domain is not attached to it. Re-add it and re-run the workflow:gh api -X PUT repos/MediaCompLab/MediaCompLab.github.io/pages -f cname=mediacomplab.com gh workflow run "Deploy to GitHub Pages"Changing
build_typeclears the custom domain, andhttps_enforcedwith it; the latter can only be turned back on once GitHub has issued the certificate, which takes a few minutes.
The old hexo deploy flow is retired — hexo/ is no longer built, and pushing its output to this
branch would now be published as source, not as a site.
- Dates are rendered in UTC. Front matter is written the way Hexo wrote it
(
2023-05-19 21:17:40, no timezone), which the YAML parser reads as UTC; using the UTC components reproduces the original output and makes the build independent of the machine's timezone. public/css/*andpublic/js/*are leftovers from the Hexo theme. Nothing links them any more; they are kept only so the Hexo archive comparison still has something to check. They can be deleted once you stop runningnpm run verify -- --hexo.- MathJax was dropped. The theme loaded a 1 MB MathJax bundle on every post page and no content
uses maths.
public/plugins/mathjax/is still there if you want it back. - Comments are Valine, carried over unchanged; they appear on post pages when
THEME.comments.valine.enableis true insrc/consts.ts. src/templates/holds the original nunjucks sitemap and search templates, still used bysrc/utils/templates.ts, so the feed formats stay recognisable to anyone who edited them before.- The home hero photos are listed in
src/components/Hero.astro; the fifth one is the only place/images/genre_study.jpgis used.
Four things are deliberately kept as the Hexo theme had them, rather than redesigned.
hexo/themes/oranges/layout/home.ejs and .../_partial/navigation_avatar.ejs are the references.
The hero. The mark and the wordmark sit on one row, which is exactly what the theme's
#lab-name was — a flex row of icon_white.png and the <h1>. The block is laid over the photos
rather than parked beneath them (position: absolute, white Fredoka, the theme's own
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.2)), and a gradient — transparent through to
rgba(5, 12, 26, 0.82) — fades the picture into the text so the two do not meet at a hard edge.
The carousel controls stay out of the picture. At rest the photographs carry nothing at all.
Each arrow appears only when the pointer is inside that arrow's edge, and the counter and pause
switch only when the pointer is in the top-right corner they occupy. site.js tracks this from
mousemove on the hero rather than with CSS edge strips, because an overlay wide enough to catch the
pointer would also swallow the per-letter hover on the wordmark underneath it. :focus-visible
reveals an arrow for keyboard users regardless.
The arrow zones are measured, not assumed. The arrows sit at the container's edge —
max(gutter, (100% - 1140px) / 2)— while the hero spans the full viewport. Past roughly 1780px the container edge is further in than any fixed fraction of the width, so an 18%-of-width zone no longer contains the arrow: moving the pointer onto it left the zone and the arrow vanished. It only reproduced on a wide monitor. The boundary is therefore derived from the arrow's own rectangle, with the fraction as a floor — andscripts-style checks need to run at 1900px and 2560px, not just at a laptop width.
On touch there is no pointer to place, so the arrows are hidden at every width below 620px and a swipe steps the carousel instead: a horizontal drag of 40px or more, and at least 1.4× the vertical travel, so a slightly diagonal flick scrolls the page rather than turning the photos.
touch-action: pan-y pinch-zoomon.herois load-bearing. Without it, a rightward swipe is claimed by the browser as back-navigation and the reader leaves the site — which is exactly what happened when this was first wired up, and it is invisible in a desktop browser. Vertical panning and pinch-zoom are deliberately left enabled.
The masthead. One deep-blue bar carrying both the college lockup and the nav, which is the Hexo
header folded into a single row: the theme stacked a #0039A6 school banner (white lockup at
height: 100px) above a beige navbar. The background is that same GSU Blue, and the lockup is the
white cehd-white2.png the theme put on it.
It condenses as you scroll. At the top of a page it is 100px with an 88px lockup, which is what keeps a 1900px-wide window from looking like it has a thin strip of a header; it reaches 68px with a 56px lockup after about 180px of scrolling.
The collapse is interpolated, not switched. site.js writes a --condense value from 0 to 1 as
the page scrolls, and the height is calc()-ed from it. The first attempt toggled a class at
scrollY > 8, which fired the whole collapse the instant the page moved and read as the header
snapping shut; tying it to the scroll position instead makes it move with the reader, and needs no
transition because the value already tracks the scroll frame by frame. CONDENSE_DISTANCE in
site.js is the one number to change for a slower or quicker collapse. The shadow still arrives
immediately (is-stuck past 8px) — that is what separates the bar from the page, and it should be
there the moment they overlap.
Because the row is bottom-aligned, the panel collapses from the top and the lockup and nav stay pinned to its base.
Only above 960px. On a phone the bar keeps its compact 68–80px height, because the open nav drawer
is positioned from --header-h and a header that resized underneath it would leave the drawer
hanging in the wrong place. That is also why --header-h is the compact value: it is the one
scroll-margin and the drawer need, with --header-h-large for the resting desktop height.
No invented copy. The redesign had added four lines the theme never had — a wordmark beside the
masthead logo, a line of small caps over the wordmark ("Understanding how we understand."), and a
college affiliation line under the hero and again in the footer. All four are gone; the footer
carries the copyright line alone, as theme.footer.more did. If you add straplines back, put them
somewhere they earn their space — the page is deliberately quiet.
The wordmark. "Media Comprehension Lab" is set in Fredoka, not Inter, with the leading capital
of every word — and all of "Lab" — at weight 600. Letters hop on hover:
translateY(-15px) scale(1.3) rotate(5deg) on all 0.2s ease, the theme's own values. The entrance
stagger is an addition on top; it runs once per photo change via the is-animating class.
The lockup beside it animates too, and on different values — the theme gave the two separate
rules: the image lifts translateY(-20px) scale(1.1) on a much bouncier
transform 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55), so the mark springs while the letters
snap. pointer-events: auto is re-enabled on the image because the Hexo #lab-name wrapper had it
off. The theme also set cursor: pointer on a mark that is not a link and does nothing on click;
the cursor is left alone rather than promising an action that does not exist.
The mark is sized against the bar, not on a curve of its own:
calc(var(--bar-h) - var(--bar-pad)) — the panel's live height less its 12px of breathing room — so
it is as large as the panel allows rather than leaving a band of empty blue above it. It reads
larger than the theme's ratio (a 100px mark beside a 4.6rem headline, of which 20px was padding)
because a near-square mark looks smaller than a line of type of the same height: the type fills its
box and the mark does not.
The nav rule. The current item is marked by a 3px GSU red (#cc0000) segment hung below the
blue panel — bottom: -3px, the theme's own value — sliding between items on
cubic-bezier(0.25, 0.8, 0.25, 1) over 0.3s. site.js computes only its horizontal position,
exactly as activeNav.js did. The nav sits on the floor of the bar (align-items: flex-end) so its
label shares a bottom edge with the college lockup.
Two details are easy to "fix" by accident, so they are spelled out:
- No rule on the home page. The theme's
activeNav.jsonly marked items carryingdata-path, and HOME was hard-coded outside the loop that generated them — sogetActiveItem()returned null on/and set the line's width to 0.site.jsreproduces that: every link except Home counts as an active item. Hovering still brings the rule out, and leaving the nav hides it again. - Separated by a shadow, not a stripe. The header carries the theme's
.navbar-box-shadow(0 2px 3px rgba(0,0,0,.1), 0 2px 2px rgba(0,0,0,.4)), deepened once the bar is stuck. A full-width bright base rule was tried first to give the indicator a light track; it worked, but it added a second stripe of colour to the panel for no reason. Hanging the rule outside the bar solves the contrast instead: red against the white page below is 5.9:1, where red on the blue panel was only 1.67:1.
Centring the nav in the bar instead strands the rule in the middle of the panel, far from the text.
The People card. Geometry and interaction are the theme's:
| Card | 392px tall, holding one full-bleed portrait with the type on top of it |
| Portrait | fills the card (position: absolute; inset: 0), object-position: top; on hover it dims to 0.5 opacity and scales to 1.06 |
| Name | white, over a dark scrim across the foot of the picture. On hover the scrim grows to the full card height and carries the name up to 60px from the top |
| Biography | parked at bottom: -100%; on hover it slides to bottom: 0, rising over the darkened picture in white |
| Social icons | the theme's brand colours — ResearchGate #34ebcf, LinkedIn #069 — popping to scale(1.2) |
Three notes, all of them things that look like they could be simplified and cannot:
- The plate's
padding-topis doing the positioning, nottop. It is anchored to the card's bottom edge (inset: auto 0 0 0), so as its height animates from 150px to 100% the plate grows upward and carries the name with it — from the foot of the picture to 60px below the top. Ajustify-contentswitch could not interpolate that, and a shorthandpaddingin a media query would drop the name upward out of place. - Both backgrounds are gradients, at the same angle and stop count. Two gradients interpolate; a gradient and a flat colour do not, and the change would snap.
- The hover sheet is dark, not white. The Hexo card opened a white sheet over the portrait and put the name and biography in ink on it. That cannot survive white type on a full-bleed picture: the sheet has to stay dark, and the biography is white instead. The mechanic — the plate growing to fill the card while the biography rises from below — is the theme's.
.accordion__body does not use a * + * rhythm rule, and should not go back to one. Universal
selectors carry no specificity, so .accordion__body > * + * scores only (0,1,0) — while a reset
like .accordion__body p { margin: 0 } scores (0,1,1) and silently wins, flattening the spacing
between every sub-heading and the paragraph under it. The panel's contents run
h3 → p → p.body-label → ul, repeated, so each of those four transitions is stated explicitly with
enough specificity to survive the resets.
Shapes stay square here too, so the brand-coloured icon buttons are rectangles rather than the
theme's circles. Keyboard focus (:focus-within) opens the same state as hover.
Everything else — the carousel, the reveals, the search — follows the design system above. The
controller in src/scripts/site.js pauses photo motion and automatic rotation when the hero is
offscreen, the tab is hidden, or keyboard focus is inside the hero, and visitors can pause it
explicitly. prefers-reduced-motion switches off the drift, the letter entrance and automatic
rotation; manual photo navigation remains available.
The usual boilerplate — * { transition-duration: 0.01ms !important } — makes every hover, focus,
menu and card expansion snap instantly, which reads as "the site is broken" rather than "the site is
calm". The Hexo theme had no reduced-motion handling at all, so anyone with the preference set saw
the old site animate and this one appear dead.
So the block is split by intent: animations (the drift, the letter entrance, the reveals, the
progress bar) are switched off outright, because those are the looping and sweeping movements the
preference exists to stop; transitions are capped at 0.12s instead, because they are short,
bounded, and answer something the reader just did. If someone wants the full experience back, that
is a one-value change in the last block of src/styles/site.css.
On devices without hover the card lays out flat — square portrait, then name, role, the full biography and the links — so touch users never lose the research interests. Phones show one complete profile per row.
npm run hover-preview -- people # every card forced into its hovered end state
npm run touch-preview -- people # the @media (hover: none) layout, at 420pxBoth shoot a throwaway copy of dist/, so the real build is never touched. touch-preview exists
because Chrome will not emulate hover/pointer in headless mode — not through
Emulation.setEmulatedMedia, not through --blink-settings — so it rewrites that one media query
to an always-true one and renders the real declarations inside it.
Validate with npm run check and npm run build, then inspect desktop and mobile layouts, manual
photo navigation, keyboard links and reduced-motion rendering.