vita: fix the entry layout, which was losing to typography.css

Two specificity collisions, both invisible in the markup and both my fault
for styling with bare class selectors on a surface that typography.css owns
through #markdownBody:

  #markdownBody :is(h1..h6)  (1,0,1)  beat  .vita-entry-title  (0,1,0)
  #markdownBody p + p        (1,0,2)  beat  .vita-role etc.    (0,1,0)

So entry titles rendered at 1.45rem sans with 2.475rem heading margins
instead of 1rem serif — the "too much space" and the odd text sizes — and
every second paragraph in a card picked up the 1.5em essay prose indent,
which is the strange horizontal offset on the date lines.

now.css never hits either, because Now uses no heading elements inside its
cards and emits one paragraph per card. The vita keeps its headings, since a
record page wants a real document outline, and pays with specificity: every
rule is now #markdownBody-scoped, and card paragraphs reset text-indent
outright.

Also restructured onto the layout item-card.css already establishes: title
and date share a baseline with the date flush right, and role, location and
GPA collapse onto one line beneath it, instead of stacking title / role /
lone indented date. Dates moved from <time> to <span> — half of them are
"Fall 2024", "expected 2028" or "Present", which have no valid datetime
value, and a <time> without one is worse than none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUGesXiMmACsLBTGG1xuEU
This commit is contained in:
Levi Neuwirth 2026-08-11 09:39:22 +02:00
parent 464e613fe9
commit 4a5332b7c6
No known key found for this signature in database
2 changed files with 202 additions and 165 deletions

View File

@ -27,7 +27,7 @@ module Vita
import Data.Aeson (FromJSON (..), Object, Value (..), withObject, (.:), (.:?), (.!=))
import Data.Aeson.Types (Parser, typeMismatch)
import Data.Char (toLower)
import Data.List (isPrefixOf, sortOn)
import Data.List (intercalate, isPrefixOf, sortOn)
import Data.Maybe (mapMaybe)
import Data.Scientific (isInteger, toRealFloat)
import qualified Data.Aeson.Key as K
@ -360,22 +360,48 @@ rewriteCmd2 name f = go
dateRange :: String -> Maybe String -> String
dateRange s me = tex s ++ maybe "" (\e -> " &ndash; " ++ tex e) me
-- | The grey line under an entry heading: dates, then location if present.
metaLine :: String -> Maybe String -> Maybe String -> String
metaLine dates mloc murl = concat
[ "<p class=\"vita-meta\">"
, "<span class=\"vita-dates\">", dates, "</span>"
, case mloc of
Nothing -> ""
Just loc -> concat
[ "<span class=\"vita-sep\" aria-hidden=\"true\"> &middot; </span>"
, case murl of
Just u -> "<a class=\"vita-location\" href=\"" ++ escapeHtml u ++ "\">" ++ tex loc ++ "</a>"
Nothing -> "<span class=\"vita-location\">" ++ tex loc ++ "</span>"
]
, "</p>"
-- | Title and date on one baseline, title left and date flush right — the
-- layout item-card.css already establishes for every other list on the
-- site. An earlier version stacked the date on its own line below the
-- title, which read as a stray indented fragment: @#markdownBody p + p@
-- applies the essay prose indent of 1.5em to consecutive paragraphs, and a
-- date is not prose.
--
-- Dates stay in a @span@ rather than a @time@ element on purpose. Half of
-- them are "Fall 2024", "expected 2028", "Present" — no valid @datetime@
-- value exists for those, and a @time@ without one is worse than no @time@.
headerRow :: String -> String -> String
headerRow titleHtml dates = concat
[ "<div class=\"item-card-header\">"
, "<h3 class=\"vita-entry-title\">", titleHtml, "</h3>"
, "<span class=\"item-card-date\">", dates, "</span>"
, "</div>"
]
-- | The line under the header: role or degree, then whatever secondary facts
-- the entry carries (location, GPA), middot-separated in a quieter ink so
-- the role still leads.
subLine :: Maybe String -> [String] -> String
subLine mrole extras
| null parts = ""
| otherwise = "<p class=\"vita-role\">" ++ lead ++ trailing ++ "</p>"
where
parts = maybe [] (pure . tex) mrole ++ extras
lead = head parts
rest = tail parts
trailing
| null rest = ""
| otherwise = "<span class=\"vita-quiet\"> &middot; "
++ intercalate " &middot; " rest
++ "</span>"
-- | A location, linked when the entry gives a URL for it.
locationPart :: Maybe String -> Maybe String -> [String]
locationPart Nothing _ = []
locationPart (Just loc) murl = pure $ case murl of
Just u -> "<a class=\"vita-location\" href=\"" ++ escapeHtml u ++ "\">" ++ tex loc ++ "</a>"
Nothing -> tex loc
-- | Link chips. The affordance the print CV cannot offer: every artifact one
-- click away, rather than a bracketed label the reader has to retype.
renderLinks :: [Link] -> String
@ -425,11 +451,11 @@ renderEducation es
one e = concat
[ "<li class=\"item-card vita-card\">"
, "<div class=\"item-card-main\">"
, "<h3 class=\"vita-entry-title\">", tex (edInstitution e), "</h3>"
, "<p class=\"vita-role\">", tex (edDegree e)
, maybe "" (\g -> "<span class=\"vita-gpa\"> &middot; GPA " ++ tex g ++ "</span>") (edGpa e)
, "</p>"
, metaLine (dateRange (edStart e) (edEnd e)) (edLocation e) Nothing
, headerRow (tex (edInstitution e)) (dateRange (edStart e) (edEnd e))
, subLine (Just (edDegree e))
( maybe [] (\g -> ["GPA " ++ tex g]) (edGpa e)
++ locationPart (edLocation e) Nothing
)
, maybe "" (\n -> "<p class=\"vita-note\">" ++ tex n ++ "</p>") (edNotes e)
, "</div>"
, "</li>"
@ -459,17 +485,16 @@ renderPublications ps
-- Entries without a title (work in preparation) put the label in
-- the authors field; the CV template makes the same distinction.
Nothing -> concat
[ "<h3 class=\"vita-entry-title\">", tex (pbAuthors p), "</h3>"
[ headerRow (tex (pbAuthors p)) (dateOf p)
, "<p class=\"vita-venue\">", tex (pbVenue p)
, maybe "" (\t -> " " ++ tex t) (pbTarget p)
, "</p>"
]
Just t -> concat
[ "<h3 class=\"vita-entry-title\">", tex t, "</h3>"
[ headerRow (tex t) (dateOf p)
, "<p class=\"vita-authors\">", tex (pbAuthors p), "</p>"
, "<p class=\"vita-venue\">", tex (pbVenue p), "</p>"
]
, "<p class=\"vita-meta\"><span class=\"vita-dates\">", dateOf p, "</span></p>"
, renderLinks (pbLinks p)
, "</div>"
, "</li>"
@ -489,14 +514,12 @@ renderPresentations ps
one p = concat
[ "<li class=\"item-card vita-card\">"
, "<div class=\"item-card-main\">"
, "<h3 class=\"vita-entry-title\">", tex (prTitle p), "</h3>"
, headerRow (tex (prTitle p)) (dateOf p)
, "<p class=\"vita-authors\">", tex (prAuthors p), "</p>"
, "<p class=\"vita-venue\">"
, maybe "" (\k -> tex k ++ ", ") (prKind p)
, tex (prVenue p)
, "</p>"
, "<p class=\"vita-meta\"><span class=\"vita-dates\">", dateOf p, "</span>"
, maybe "" (\s -> "<span class=\"vita-status\">" ++ tex s ++ "</span>") (prStatus p)
, maybe "" (\st -> "<span class=\"vita-status\">" ++ tex st ++ "</span>") (prStatus p)
, "</p>"
, "</div>"
, "</li>"
@ -522,9 +545,8 @@ renderExperience xs = research ++ industry
one e = concat
[ "<li class=\"item-card vita-card\">"
, "<div class=\"item-card-main\">"
, "<h3 class=\"vita-entry-title\">", tex (exOrg e), "</h3>"
, maybe "" (\r -> "<p class=\"vita-role\">" ++ tex r ++ "</p>") (exRole e)
, metaLine (dateRange (exStart e) (exEnd e)) (exLocation e) (exLocUrl e)
, headerRow (tex (exOrg e)) (dateRange (exStart e) (exEnd e))
, subLine (exRole e) (locationPart (exLocation e) (exLocUrl e))
, maybe "" (\p -> "<p class=\"vita-note\">" ++ tex p ++ "</p>") (exPreamble e)
, renderBullets (exBullets e)
, "</div>"
@ -563,12 +585,11 @@ renderProjects ps = concatMap one (groupOrder visible)
entry p = concat
[ "<li class=\"item-card vita-card\">"
, "<div class=\"item-card-main\">"
, "<h3 class=\"vita-entry-title\">"
, case pjEssay p of
Just u -> "<a href=\"" ++ escapeHtml u ++ "\">" ++ tex (pjName p) ++ "</a>"
Nothing -> tex (pjName p)
, "</h3>"
, metaLine (dateRange (pjStart p) (pjEnd p)) Nothing Nothing
, headerRow
(case pjEssay p of
Just u -> "<a href=\"" ++ escapeHtml u ++ "\">" ++ tex (pjName p) ++ "</a>"
Nothing -> tex (pjName p))
(dateRange (pjStart p) (pjEnd p))
, "<p class=\"vita-note\">", tex (pjDescription p), "</p>"
, renderLinks (pjLinks p)
, "</div>"

View File

@ -1,33 +1,41 @@
/* vita.css /about.html.
/* vita.css /about.html and /cv/projects/.
*
* The vita is a record surface. Where Now answers "what is moving right
* now" with a status chip per item, the vita answers "what is on file"
* and carries no temporality beyond the dates themselves so it borrows
* item-card.css's card rhythm and now.css's section headings, but drops
* the badge column entirely. Entries here have no state to report.
* now" with a status chip per item, the vita answers "what is on file" and
* carries no temporality beyond the dates themselves so it borrows
* item-card.css's card rhythm and now.css's section headings, but drops the
* badge column entirely. Entries here have no state to report.
*
* The one affordance this page has that the CV PDF cannot: every artifact
* is one click away. Link chips are therefore the only element given any
* interaction polish; everything else stays deliberately quiet so the
* chips read as the page's active surface.
* SPECIFICITY NOTE. Almost every selector below is prefixed with
* #markdownBody, and that is load-bearing rather than habit. typography.css
* styles the prose surface through #markdownBody (1,0,1) including
* `:is(h1..h6)` at display sizes with 2.475rem margins, and a 1.5em
* text-indent on `p + p` for essay prose. A bare `.vita-entry-title` (0,1,0)
* loses to those no matter what order the stylesheets load in, which is
* exactly what went wrong the first time: entry titles rendered at 1.45rem
* sans with heading margins, and every second paragraph in a card sat
* indented like a continuation of running prose.
*
* Now sidesteps this by never using heading elements inside its cards. The
* vita keeps them a record page wants a real document outline for anyone
* navigating by heading and pays for them with specificity instead.
*/
/* ============================================================
INTRO PROSE
Body content from about.md the pointers and the four research
threads set above the generated sections. Mirrors .now-intro
so the two data-driven pages open in the same register.
Body content from the markdown pointers and the research
threads on /about, the framing note on the project index.
Mirrors .now-intro so the data-driven pages open alike.
============================================================ */
.vita-intro {
margin: 0 0 3rem;
#markdownBody .vita-intro {
margin: 0 0 2.75rem;
font-family: var(--font-serif);
font-size: 1rem;
color: var(--text-muted);
line-height: 1.6;
}
.vita-intro h2 {
#markdownBody .vita-intro h2 {
font-family: var(--font-serif);
font-size: 1.15rem;
font-variant: all-small-caps;
@ -35,25 +43,32 @@
letter-spacing: 0.09em;
color: var(--text-muted);
font-weight: 400;
margin: 2rem 0 0.6rem;
margin: 1.9rem 0 0.5rem;
}
.vita-intro p:last-child {
margin-bottom: 0;
#markdownBody .vita-intro > p:first-of-type {
margin-top: 0;
}
/* The intro is a framing note, not running prose, so it opts out of the
essay indent on consecutive paragraphs too. Two short paragraphs where
the second starts 1.5em in reads as a mistake rather than a convention. */
#markdownBody .vita-intro p {
text-indent: 0;
}
/* ============================================================
SECTIONS
Identical treatment to .now-section same size, tracking, and
Identical treatment to .now-section same size, tracking and
ink because the two pages are siblings and a reader moving
between them should not have to re-learn the hierarchy.
============================================================ */
.vita-section {
margin-bottom: 2.75rem;
#markdownBody .vita-section {
margin: 0 0 2.5rem;
}
.vita-section-heading {
#markdownBody .vita-section-heading {
font-family: var(--font-serif);
font-size: 1.15rem;
font-variant: all-small-caps;
@ -62,89 +77,75 @@
color: var(--text-muted);
text-transform: none;
font-weight: 400;
margin: 0 0 0.85rem 0;
margin: 0 0 0.7rem 0;
line-height: 1.3;
}
/* ============================================================
ENTRIES
.vita-card composes on .item-card, which is a flex row sized
for a badge column. The vita emits no badge, so the single
.item-card-main child simply takes the full width.
.item-card-main child takes the full width.
============================================================ */
.vita-list {
#markdownBody .vita-card {
padding: 0.8rem 0;
}
/* Kill the prose indent inside cards outright. Card paragraphs are
labelled data a role, a venue, an author list not successive
paragraphs of an essay, and the 1.5em indent reads as damage. */
#markdownBody .vita-card p {
text-indent: 0;
margin: 0;
}
.vita-entry-title {
/* The title. Serif at body size, matching .item-card-title rather than any
heading step: these are list entries that happen to be headings for
outline purposes, not section openers. */
#markdownBody .vita-entry-title {
font-family: var(--font-serif);
font-size: 1rem;
font-weight: 600;
color: var(--text);
line-height: 1.35;
color: var(--text);
margin: 0;
font-feature-settings: normal;
}
/* Degree, role, position. Serif italic the scholarly convention
for a title held, and it separates the role from the institution
above it without another weight or size step. */
.vita-role {
font-family: var(--font-serif);
font-size: 0.95rem;
font-style: italic;
color: var(--text-muted);
margin: 0.15rem 0 0;
line-height: 1.4;
#markdownBody .vita-entry-title a {
color: inherit;
text-decoration: none;
}
.vita-gpa {
font-style: normal;
color: var(--text-faint);
#markdownBody .vita-entry-title a:hover {
text-decoration: underline;
text-underline-offset: 0.15em;
}
/* Author lists run long and are reference material rather than
reading material sans, small, muted, tight. */
.vita-authors {
font-family: var(--font-sans);
font-size: 0.82rem;
color: var(--text-muted);
margin: 0.2rem 0 0;
line-height: 1.45;
}
/* Venue: journal, conference, or publication state. Italic serif is
the citation convention and distinguishes it from the author line
directly above without adding a color step. */
.vita-venue {
/* Role, degree, position and any secondary facts hung off it. Serif
italic is the scholarly convention for a title held, and it separates
the role from the institution above without another size step. */
#markdownBody .vita-role {
font-family: var(--font-serif);
font-size: 0.92rem;
font-style: italic;
color: var(--text-muted);
margin: 0.2rem 0 0;
margin: 0.1rem 0 0;
line-height: 1.45;
}
/* Dates and location. Matches .item-card-date's register exactly
tabular numerals so date columns stay optically aligned down the
list even though they are inline here. */
.vita-meta {
/* Location and GPA trail the role in a fainter ink so the role still
leads the line. */
#markdownBody .vita-quiet {
color: var(--text-faint);
font-style: normal;
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--text-faint);
margin: 0.25rem 0 0;
font-variant-numeric: tabular-nums;
line-height: 1.5;
font-size: 0.78rem;
}
.vita-sep {
user-select: none;
}
.vita-location {
color: var(--text-faint);
}
a.vita-location {
#markdownBody .vita-quiet a.vita-location {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--border);
text-decoration-thickness: 0.08em;
@ -152,30 +153,52 @@ a.vita-location {
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
}
a.vita-location:hover {
#markdownBody .vita-quiet a.vita-location:hover {
color: var(--text-muted);
text-decoration-color: var(--border-muted);
}
/* Presentation state ("Presented", "Accepted"). Sits on the meta
line rather than in a badge column, so it is set as small-caps
text with a leading separator instead of a bordered chip a
frame here would compete with the link chips below it. */
.vita-status {
/* Author lists run long and are reference material rather than reading
material sans, small, muted, tight. */
#markdownBody .vita-authors {
font-family: var(--font-sans);
font-variant: all-small-caps;
letter-spacing: 0.07em;
font-size: 0.78rem;
color: var(--text-muted);
margin: 0.25rem 0 0;
line-height: 1.45;
}
.vita-status::before {
content: " · ";
/* Venue: journal, conference, or publication state. Italic serif is the
citation convention and distinguishes it from the author line above
without adding a colour step. */
#markdownBody .vita-venue {
font-family: var(--font-serif);
font-size: 0.9rem;
font-style: italic;
color: var(--text-muted);
margin: 0.1rem 0 0;
line-height: 1.45;
}
/* Presentation state ("Presented", "Accepted"). Sits at the end of the
venue line as small-caps text rather than a bordered chip a frame
here would compete with the link chips below it. */
#markdownBody .vita-status {
font-family: var(--font-sans);
font-size: 0.72rem;
font-style: normal;
font-variant: all-small-caps;
letter-spacing: 0.07em;
color: var(--text-faint);
}
/* Preamble / note lines the CV's connective prose. Same register
as .item-card-abstract.is-full, unclamped. */
.vita-note {
#markdownBody .vita-status::before {
content: " · ";
}
/* Preamble, notes, project descriptions the connective prose. Same
register as .item-card-abstract.is-full, unclamped. */
#markdownBody .vita-note {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
@ -183,50 +206,50 @@ a.vita-location:hover {
line-height: 1.55;
}
.vita-bullets {
#markdownBody .vita-bullets {
margin: 0.4rem 0 0;
padding-left: 1.05rem;
padding-left: 0.85rem;
list-style: none;
}
.vita-bullets li {
#markdownBody .vita-bullets li {
font-family: var(--font-sans);
font-size: var(--text-size-small);
color: var(--text-muted);
line-height: 1.55;
margin: 0.3rem 0 0;
margin: 0.25rem 0 0;
position: relative;
}
/* Hairline dash rather than a bullet glyph: the entries are claims,
not an enumerated sequence, and a disc would assert more structure
than the content has. */
.vita-bullets li::before {
/* Hairline dash rather than a bullet glyph: these are claims, not an
enumerated sequence, and a disc would assert more structure than the
content has. */
#markdownBody .vita-bullets li::before {
content: "";
position: absolute;
left: -1.05rem;
top: 0.72em;
width: 0.5rem;
left: -0.85rem;
top: 0.7em;
width: 0.4rem;
height: 1px;
background: var(--border-muted);
}
/* ============================================================
LINK CHIPS
The page's one interactive surface, and the reason a vita on the
web beats a vita on paper. Geometry is lifted from .now-status so
the chip shape is already familiar from /current; the difference
is that these are links and therefore respond.
The page's one interactive surface, and the reason a vita on
the web beats a vita on paper. Geometry is lifted from
.now-status so the shape is already familiar from /current;
the difference is that these are links, and respond.
============================================================ */
.vita-links {
#markdownBody .vita-links {
margin: 0.5rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
gap: 0.3rem;
}
.vita-chip {
#markdownBody .vita-chip {
display: inline-block;
font-family: var(--font-sans);
font-size: 0.66rem;
@ -242,63 +265,56 @@ a.vita-location:hover {
text-decoration: none;
transition:
color var(--transition-fast),
border-color var(--transition-fast),
background-color var(--transition-fast);
border-color var(--transition-fast);
}
.vita-chip:hover {
#markdownBody .vita-chip:hover {
color: var(--text);
border-color: var(--text-muted);
background: var(--surface-raised, transparent);
}
.vita-chip:focus-visible {
#markdownBody .vita-chip:focus-visible {
outline: 2px solid var(--text-muted);
outline-offset: 2px;
}
/* The equal-contribution legend. Belongs to the section, not to any
one entry, so it is set below the list in the faintest register
the page uses. */
.vita-footnote {
/* The equal-contribution legend. Belongs to the section rather than to
any one entry, so it sits below the list in the faintest register. */
#markdownBody .vita-footnote {
font-family: var(--font-serif);
font-size: 0.85rem;
font-style: italic;
color: var(--text-faint);
margin: 0.9rem 0 0;
margin: 0.8rem 0 0;
text-indent: 0;
}
/* ============================================================
MOBILE (540px)
item-card.css already stacks its header at this breakpoint. The
vita's entries are single-column by construction, so only the
type steps down.
item-card.css already stacks .item-card-header and drops the
date below the title at this width, so only the type steps.
============================================================ */
@media (max-width: 540px) {
.vita-entry-title {
#markdownBody .vita-entry-title {
font-size: 0.95rem;
overflow-wrap: anywhere;
}
.vita-role,
.vita-venue {
font-size: 0.9rem;
#markdownBody .vita-role,
#markdownBody .vita-venue {
font-size: 0.88rem;
}
.vita-meta {
font-size: 0.68rem;
}
.vita-chip {
#markdownBody .vita-chip {
font-size: 0.62rem;
padding: 0.28em 0.45em 0.24em;
}
}
@media (prefers-reduced-motion: reduce) {
.vita-chip,
a.vita-location {
#markdownBody .vita-chip,
#markdownBody .vita-quiet a.vita-location {
transition: none;
}
}