vita: generate /about from the CV data instead of hand-copying it
The vita page carried a hand-typed duplicate of education, publications,
presentations, and experience, and had drifted from the PDFs in about
fifteen places — Shu Lab still one entry titled "Undergraduate
Researcher" three months after the degree, Weenix at ~7,000 lines, CHASE
still "accepted" after being presented, a preprint missing entirely, and
two different code URLs for the same paper.
build/Vita.hs reads yaml-source/data/*.yml — the same files the PDF
pipeline builds from — and renders education, experience (keeping the
CV's research/industry split), publications, presentations, and contact.
Drift is now structurally impossible rather than merely discouraged.
Notes on the implementation:
- The YAML is LaTeX-flavoured because xelatex is its first consumer, so
values pass through a small closed converter (\textbf, \textit,
\texttt, \href, $\times$, $\delta$, \#, {,}, ~, --, ---). Escaping
happens before conversion; unhandled commands pass through visibly
rather than silently dropping their argument.
- Scalars are parsed loosely: the same logical field is sometimes a YAML
number and sometimes a string (`year: 2026` vs `year: "2026--2027"`),
and the PDF pipeline does not care, so this does not force it to.
- personal.yml's phone number is deliberately not read. It is printed on
the CV, which is handed to a chosen reader; this page is crawled.
- Contact chips carry labels (ORCID, Forgejo, GitHub) rather than the
CV's full display values, which exist because paper cannot be clicked.
The page keeps only what nothing else on the site owns: the pointers and
the four research threads. In-progress work belongs to /current, the
engineering index to /cv/projects/, and the narrative to /me/ — whose
abstract already described this page as the formal record.
vita.css composes on item-card.css and mirrors now.css's headings and
chip geometry, so /about and /current read as one system. Link chips get
the only interaction polish on the page, being the one affordance a
printed CV cannot offer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SUGesXiMmACsLBTGG1xuEU
This commit is contained in:
parent
d4031a79f2
commit
29c4b80a2a
|
|
@ -29,6 +29,7 @@ import Compilers (essayCompiler, postCompiler, pageCompiler, poetryCompiler, fi
|
|||
import Catalog (musicCatalogCtx)
|
||||
import Commonplace (commonplaceCtx)
|
||||
import Now (nowCtx)
|
||||
import Vita (vitaCtx)
|
||||
import Contexts (siteCtx, essayCtx, postCtx, pageCtx, poetryCtx, fictionCtx, compositionCtx,
|
||||
contentKindField, recentFirstByDisplay,
|
||||
tagLinksFieldExcludingTopSegment, isProvedConfidence)
|
||||
|
|
@ -270,6 +271,13 @@ rules = do
|
|||
-- /current.html. Re-compiles current.html when the YAML changes.
|
||||
match "data/now.yaml" $ compile getResourceBody
|
||||
|
||||
-- CV/résumé YAML — the same files the PDF pipeline builds from
|
||||
-- (yaml-source/data/). Loaded by Vita.vitaCtx so /about.html renders
|
||||
-- education, publications, presentations, and experience from one
|
||||
-- source instead of a hand-typed copy. Matching them here is what
|
||||
-- makes about.html recompile when the CV data changes.
|
||||
match "yaml-source/data/*.yml" $ compile getResourceBody
|
||||
|
||||
-- Per-build stamp — written by Main.main before Hakyll starts, so it
|
||||
-- always exists and always differs from the previous run. Matched
|
||||
-- (not routed) purely so the telemetry pages can `load` it as a
|
||||
|
|
@ -352,8 +360,21 @@ rules = do
|
|||
>>= loadAndApplyTemplate "templates/default.html" essayCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
-- Vita — the academic-homepage view, driven by yaml-source/data/*.yml.
|
||||
-- Prose (research interests, pointers, contact) stays in the markdown
|
||||
-- body; education, publications, presentations, and experience are
|
||||
-- generated by Vita.vitaCtx and assembled by templates/vita.html.
|
||||
match "content/about.md" $ do
|
||||
route $ stripPrefixRoute "content/"
|
||||
`composeRoutes` setExtension "html"
|
||||
compile $ pageCompiler
|
||||
>>= loadAndApplyTemplate "templates/vita.html" vitaCtx
|
||||
>>= loadAndApplyTemplate "templates/default.html" vitaCtx
|
||||
>>= relativizeUrls
|
||||
|
||||
match ("content/*.md"
|
||||
.&&. complement "content/index.md"
|
||||
.&&. complement "content/about.md"
|
||||
.&&. complement "content/commonplace.md"
|
||||
.&&. complement "content/colophon.md"
|
||||
.&&. complement "content/current.md"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,543 @@
|
|||
{-# LANGUAGE GHC2021 #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
-- | Vita page: renders education, publications, presentations, and
|
||||
-- experience for @/about.html@ from @yaml-source/data/*.yml@ — the same
|
||||
-- files that drive the CV and résumé PDFs via the Jinja/xelatex pipeline.
|
||||
--
|
||||
-- The point is single-sourcing. Before this module the vita page carried a
|
||||
-- hand-typed copy of all four sections, and it had drifted from the PDFs in
|
||||
-- roughly fifteen places (stale roles, a superseded line count, a talk still
|
||||
-- listed as forthcoming after it was given). Anything rendered here is read
|
||||
-- from the same YAML the PDFs are built from, so the two cannot disagree.
|
||||
--
|
||||
-- Sections the site already owns are deliberately absent: in-progress work
|
||||
-- belongs to @/current@ (data\/now.yaml, see "Now"), the engineering index to
|
||||
-- @/cv/projects/@, and the personal narrative to @/me/@. This module renders
|
||||
-- only what nothing else did.
|
||||
--
|
||||
-- The YAML is LaTeX-flavoured, because its first consumer is xelatex. Values
|
||||
-- may contain @\\textbf{}@, @\\href{}{}@, @$\\times$@, @~@, @--@ and friends,
|
||||
-- so every value goes through 'latexToHtml' on the way out. See that function
|
||||
-- for the full supported set and why escaping happens before conversion.
|
||||
module Vita
|
||||
( vitaCtx
|
||||
) where
|
||||
|
||||
import Data.Aeson (FromJSON (..), Object, Value (..), withObject, (.:), (.:?), (.!=))
|
||||
import Data.Aeson.Types (Parser, typeMismatch)
|
||||
import Data.List (isPrefixOf, sortOn)
|
||||
import Data.Maybe (mapMaybe)
|
||||
import Data.Scientific (isInteger, toRealFloat)
|
||||
import qualified Data.Aeson.Key as K
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.Yaml as Y
|
||||
import Hakyll hiding (escapeHtml)
|
||||
import Contexts (siteCtx)
|
||||
import Utils (escapeHtml)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Loose scalars
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | A YAML scalar that may be written as either a string or a number for the
|
||||
-- same logical field. @publications.yml@ has both @year: 2026@ (parsed as a
|
||||
-- number) and @year: "2026--2027"@ (a string); @experience.yml@ has
|
||||
-- @start: "2025"@ next to @start: July 2026@. Accept either and normalise
|
||||
-- to 'String' rather than forcing the YAML to be quoted consistently — the
|
||||
-- PDF pipeline does not care, and this module should not make it care.
|
||||
newtype Loose = Loose { unLoose :: String }
|
||||
|
||||
instance FromJSON Loose where
|
||||
parseJSON (String t) = pure (Loose (T.unpack t))
|
||||
parseJSON (Number n)
|
||||
| isInteger n = pure (Loose (show (truncate (toRealFloat n :: Double) :: Integer)))
|
||||
| otherwise = pure (Loose (show (toRealFloat n :: Double)))
|
||||
parseJSON (Bool b) = pure (Loose (if b then "true" else "false"))
|
||||
parseJSON v = typeMismatch "string or number" v
|
||||
|
||||
reqLoose :: Object -> String -> Parser String
|
||||
reqLoose o k = unLoose <$> o .: K.fromString k
|
||||
|
||||
optLoose :: Object -> String -> Parser (Maybe String)
|
||||
optLoose o k = fmap unLoose <$> o .:? K.fromString k
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Entry types
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
data Link = Link
|
||||
{ lkLabel :: String
|
||||
, lkHref :: String
|
||||
}
|
||||
|
||||
instance FromJSON Link where
|
||||
parseJSON = withObject "Link" $ \o -> Link
|
||||
<$> o .: "label"
|
||||
<*> o .: "href"
|
||||
|
||||
data Edu = Edu
|
||||
{ edInstitution :: String
|
||||
, edLocation :: Maybe String
|
||||
, edDegree :: String
|
||||
, edStart :: String
|
||||
, edEnd :: Maybe String
|
||||
, edGpa :: Maybe String
|
||||
, edNotes :: Maybe String
|
||||
, edVisible :: Bool
|
||||
}
|
||||
|
||||
instance FromJSON Edu where
|
||||
parseJSON = withObject "Edu" $ \o -> Edu
|
||||
<$> o .: "institution"
|
||||
<*> optLoose o "location"
|
||||
<*> reqLoose o "degree"
|
||||
<*> reqLoose o "start"
|
||||
<*> optLoose o "end"
|
||||
<*> optLoose o "gpa"
|
||||
<*> optLoose o "notes_cv"
|
||||
<*> o .:? "cv_visible" .!= True
|
||||
|
||||
newtype EduDoc = EduDoc { unEduDoc :: [Edu] }
|
||||
|
||||
instance FromJSON EduDoc where
|
||||
parseJSON = withObject "EduDoc" $ \o -> EduDoc <$> o .: "education"
|
||||
|
||||
data Pub = Pub
|
||||
{ pbAuthors :: String
|
||||
, pbTitle :: Maybe String
|
||||
, pbVenue :: String
|
||||
, pbYear :: String
|
||||
, pbMonth :: Maybe String
|
||||
, pbTarget :: Maybe String
|
||||
, pbLinks :: [Link]
|
||||
, pbNote :: Maybe String
|
||||
, pbVisible :: Bool
|
||||
}
|
||||
|
||||
instance FromJSON Pub where
|
||||
parseJSON = withObject "Pub" $ \o -> Pub
|
||||
<$> reqLoose o "authors"
|
||||
<*> optLoose o "title"
|
||||
<*> reqLoose o "venue"
|
||||
<*> reqLoose o "year"
|
||||
<*> optLoose o "month"
|
||||
<*> optLoose o "target"
|
||||
<*> o .:? "links" .!= []
|
||||
<*> optLoose o "equal_contrib_note"
|
||||
<*> o .:? "cv_visible" .!= True
|
||||
|
||||
newtype PubDoc = PubDoc { unPubDoc :: [Pub] }
|
||||
|
||||
instance FromJSON PubDoc where
|
||||
parseJSON = withObject "PubDoc" $ \o -> PubDoc <$> o .: "publications"
|
||||
|
||||
data Pres = Pres
|
||||
{ prAuthors :: String
|
||||
, prTitle :: String
|
||||
, prVenue :: String
|
||||
, prKind :: Maybe String
|
||||
, prYear :: String
|
||||
, prMonth :: Maybe String
|
||||
, prStatus :: Maybe String
|
||||
, prVisible :: Bool
|
||||
}
|
||||
|
||||
instance FromJSON Pres where
|
||||
parseJSON = withObject "Pres" $ \o -> Pres
|
||||
<$> reqLoose o "authors"
|
||||
<*> reqLoose o "title"
|
||||
<*> reqLoose o "venue"
|
||||
<*> optLoose o "kind"
|
||||
<*> reqLoose o "year"
|
||||
<*> optLoose o "month"
|
||||
<*> optLoose o "status"
|
||||
<*> o .:? "cv_visible" .!= True
|
||||
|
||||
newtype PresDoc = PresDoc { unPresDoc :: [Pres] }
|
||||
|
||||
instance FromJSON PresDoc where
|
||||
parseJSON = withObject "PresDoc" $ \o -> PresDoc <$> o .: "presentations"
|
||||
|
||||
data Exp = Exp
|
||||
{ exOrg :: String
|
||||
, exRole :: Maybe String
|
||||
, exLocation :: Maybe String
|
||||
, exLocUrl :: Maybe String
|
||||
, exStart :: String
|
||||
, exEnd :: Maybe String
|
||||
, exSection :: Maybe String
|
||||
, exOrder :: Int
|
||||
, exPreamble :: Maybe String
|
||||
, exBullets :: [String]
|
||||
, exVisible :: Bool
|
||||
}
|
||||
|
||||
instance FromJSON Exp where
|
||||
parseJSON = withObject "Exp" $ \o -> Exp
|
||||
<$> reqLoose o "organization"
|
||||
<*> optLoose o "role"
|
||||
<*> optLoose o "location"
|
||||
<*> optLoose o "location_url"
|
||||
<*> reqLoose o "start"
|
||||
<*> optLoose o "end"
|
||||
<*> optLoose o "cv_section"
|
||||
<*> o .:? "cv_order" .!= 99
|
||||
<*> optLoose o "cv_preamble"
|
||||
<*> o .:? "bullets" .!= []
|
||||
<*> o .:? "cv_visible" .!= True
|
||||
|
||||
newtype ExpDoc = ExpDoc { unExpDoc :: [Exp] }
|
||||
|
||||
instance FromJSON ExpDoc where
|
||||
parseJSON = withObject "ExpDoc" $ \o -> ExpDoc <$> o .: "experience"
|
||||
|
||||
-- | @personal.yml@ also carries a @display@ string per link (the value the
|
||||
-- CV prints in full, since paper cannot be clicked). It is deliberately
|
||||
-- not read here — see 'renderContact'.
|
||||
data ProfileLink = ProfileLink
|
||||
{ plLabel :: String
|
||||
, plHref :: String
|
||||
, plVisible :: Bool
|
||||
}
|
||||
|
||||
instance FromJSON ProfileLink where
|
||||
parseJSON = withObject "ProfileLink" $ \o -> ProfileLink
|
||||
<$> reqLoose o "label"
|
||||
<*> reqLoose o "href"
|
||||
<*> o .:? "cv_visible" .!= True
|
||||
|
||||
-- | Contact details from @personal.yml@. The phone number is deliberately
|
||||
-- not parsed: it is printed on the CV PDF, which is a document handed to
|
||||
-- a chosen reader, whereas this page is crawled. Nothing here should hand
|
||||
-- a scraper a phone number it did not already have to go looking for.
|
||||
data Person = Person
|
||||
{ pnEmail :: String
|
||||
, pnLinks :: [ProfileLink]
|
||||
}
|
||||
|
||||
instance FromJSON Person where
|
||||
parseJSON = withObject "Person" $ \o -> Person
|
||||
<$> reqLoose o "email"
|
||||
<*> o .:? "links" .!= []
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- LaTeX → HTML
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Convert the LaTeX subset that actually appears in the CV YAML into HTML.
|
||||
--
|
||||
-- Callers must escape HTML /before/ calling this, never after: this
|
||||
-- function emits real tags, so escaping afterwards would turn them into
|
||||
-- visible @<strong>@. Escaping first is safe because none of the
|
||||
-- LaTeX constructs contain @<@, @>@ or @&@ — and an @&@ inside an
|
||||
-- @\\href@ URL becomes @&@, which is what an HTML attribute wants
|
||||
-- anyway.
|
||||
--
|
||||
-- The supported set is deliberately closed and matches what the YAML
|
||||
-- contains today (@\\textbf@, @\\textit@, @\\texttt@, @\\href@,
|
||||
-- @$\\times$@, @$\\delta$@, @\\#@, @{,}@, @~@, @--@, @---@). An unhandled
|
||||
-- command passes through verbatim and is therefore visible on the page —
|
||||
-- the intended failure mode, since a silently swallowed @\\emph@ would
|
||||
-- drop its argument's text.
|
||||
latexToHtml :: String -> String
|
||||
latexToHtml =
|
||||
substAll "---" "—"
|
||||
. substAll "--" "–"
|
||||
. substAll "$\\times$" "×"
|
||||
. substAll "$\\delta$" "δ"
|
||||
. substAll "\\#" "#"
|
||||
. substAll "{,}" ","
|
||||
. substAll "~" " "
|
||||
. rewriteCmd2 "href" (\u t -> "<a href=\"" ++ u ++ "\">" ++ t ++ "</a>")
|
||||
. rewriteCmd1 "textbf" (\x -> "<strong>" ++ x ++ "</strong>")
|
||||
. rewriteCmd1 "textit" (\x -> "<em>" ++ x ++ "</em>")
|
||||
. rewriteCmd1 "texttt" (\x -> "<code>" ++ x ++ "</code>")
|
||||
|
||||
-- | Escape, then convert. The one-step form every renderer should use.
|
||||
tex :: String -> String
|
||||
tex = latexToHtml . escapeHtml
|
||||
|
||||
substAll :: String -> String -> String -> String
|
||||
substAll _ _ [] = []
|
||||
substAll pat rep s@(c:cs)
|
||||
| pat `isPrefixOf` s = rep ++ substAll pat rep (drop (length pat) s)
|
||||
| otherwise = c : substAll pat rep cs
|
||||
|
||||
-- | Split a leading @{...}@ group, tracking brace depth so nested groups
|
||||
-- survive. Returns the group's contents and whatever follows it.
|
||||
takeGroup :: String -> Maybe (String, String)
|
||||
takeGroup ('{':rest) = go (0 :: Int) "" rest
|
||||
where
|
||||
go _ _ [] = Nothing
|
||||
go d acc ('}':cs)
|
||||
| d == 0 = Just (reverse acc, cs)
|
||||
| otherwise = go (d - 1) ('}':acc) cs
|
||||
go d acc ('{':cs) = go (d + 1) ('{':acc) cs
|
||||
go d acc (c:cs) = go d (c:acc) cs
|
||||
takeGroup _ = Nothing
|
||||
|
||||
-- | Rewrite every @\\cmd{arg}@ with a function of its argument.
|
||||
rewriteCmd1 :: String -> (String -> String) -> String -> String
|
||||
rewriteCmd1 name f = go
|
||||
where
|
||||
marker = '\\' : name
|
||||
go [] = []
|
||||
go s@(c:cs)
|
||||
| marker `isPrefixOf` s
|
||||
, Just (arg, rest) <- takeGroup (drop (length marker) s)
|
||||
= f (go arg) ++ go rest
|
||||
| otherwise = c : go cs
|
||||
|
||||
-- | Rewrite every @\\cmd{a}{b}@ with a function of both arguments.
|
||||
rewriteCmd2 :: String -> (String -> String -> String) -> String -> String
|
||||
rewriteCmd2 name f = go
|
||||
where
|
||||
marker = '\\' : name
|
||||
go [] = []
|
||||
go s@(c:cs)
|
||||
| marker `isPrefixOf` s
|
||||
, Just (a, rest1) <- takeGroup (drop (length marker) s)
|
||||
, Just (b, rest2) <- takeGroup rest1
|
||||
= f a (go b) ++ go rest2
|
||||
| otherwise = c : go cs
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared rendering pieces
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | @start – end@, or just @start@ when the entry has no end.
|
||||
dateRange :: String -> Maybe String -> String
|
||||
dateRange s me = tex s ++ maybe "" (\e -> " – " ++ 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\"> · </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>"
|
||||
]
|
||||
|
||||
-- | 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
|
||||
renderLinks [] = ""
|
||||
renderLinks ls = concat
|
||||
[ "<p class=\"vita-links\">"
|
||||
, concatMap one ls
|
||||
, "</p>"
|
||||
]
|
||||
where
|
||||
one l = concat
|
||||
[ "<a class=\"vita-chip\" href=\"", escapeHtml (lkHref l), "\">"
|
||||
, tex (lkLabel l)
|
||||
, "</a>"
|
||||
]
|
||||
|
||||
renderBullets :: [String] -> String
|
||||
renderBullets [] = ""
|
||||
renderBullets bs = concat
|
||||
[ "<ul class=\"vita-bullets\">"
|
||||
, concatMap (\b -> "<li>" ++ tex b ++ "</li>") bs
|
||||
, "</ul>"
|
||||
]
|
||||
|
||||
section :: String -> String -> String -> String
|
||||
section slug heading inner = concat
|
||||
[ "<section class=\"vita-section library-section\" id=\"", slug, "\">"
|
||||
, "<h2 class=\"vita-section-heading\">", heading, "</h2>"
|
||||
, inner
|
||||
, "</section>"
|
||||
]
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Section renderers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
renderEducation :: [Edu] -> String
|
||||
renderEducation es
|
||||
| null visible = ""
|
||||
| otherwise = section "education" "Education" $ concat
|
||||
[ "<ul class=\"item-card-list vita-list\">"
|
||||
, concatMap one visible
|
||||
, "</ul>"
|
||||
]
|
||||
where
|
||||
visible = filter edVisible 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\"> · GPA " ++ tex g ++ "</span>") (edGpa e)
|
||||
, "</p>"
|
||||
, metaLine (dateRange (edStart e) (edEnd e)) (edLocation e) Nothing
|
||||
, maybe "" (\n -> "<p class=\"vita-note\">" ++ tex n ++ "</p>") (edNotes e)
|
||||
, "</div>"
|
||||
, "</li>"
|
||||
]
|
||||
|
||||
renderPublications :: [Pub] -> String
|
||||
renderPublications ps
|
||||
| null visible = ""
|
||||
| otherwise = section "publications" "Publications and Preprints" $ concat
|
||||
[ "<ul class=\"item-card-list vita-list\">"
|
||||
, concatMap one visible
|
||||
, "</ul>"
|
||||
, footnote
|
||||
]
|
||||
where
|
||||
visible = filter pbVisible ps
|
||||
-- The dagger legend lives on whichever entry declares it, but reads as a
|
||||
-- section-level note, so it is rendered once at the foot of the list.
|
||||
footnote = case mapMaybe pbNote visible of
|
||||
(n:_) -> "<p class=\"vita-footnote\">" ++ tex n ++ "</p>"
|
||||
[] -> ""
|
||||
dateOf p = tex (pbYear p) ++ maybe "" (\m -> ", " ++ tex m) (pbMonth p)
|
||||
one p = concat
|
||||
[ "<li class=\"item-card vita-card\">"
|
||||
, "<div class=\"item-card-main\">"
|
||||
, case pbTitle p of
|
||||
-- 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>"
|
||||
, "<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>"
|
||||
, "<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>"
|
||||
]
|
||||
|
||||
renderPresentations :: [Pres] -> String
|
||||
renderPresentations ps
|
||||
| null visible = ""
|
||||
| otherwise = section "presentations" "Presentations" $ concat
|
||||
[ "<ul class=\"item-card-list vita-list\">"
|
||||
, concatMap one visible
|
||||
, "</ul>"
|
||||
]
|
||||
where
|
||||
visible = filter prVisible ps
|
||||
dateOf p = maybe "" (\m -> tex m ++ " ") (prMonth p) ++ tex (prYear p)
|
||||
one p = concat
|
||||
[ "<li class=\"item-card vita-card\">"
|
||||
, "<div class=\"item-card-main\">"
|
||||
, "<h3 class=\"vita-entry-title\">", tex (prTitle p), "</h3>"
|
||||
, "<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)
|
||||
, "</p>"
|
||||
, "</div>"
|
||||
, "</li>"
|
||||
]
|
||||
|
||||
-- | Experience keeps the CV's research/industry split — that division is
|
||||
-- editorial, lives in @cv_section@, and a homepage that merged the two
|
||||
-- would say something the PDF does not.
|
||||
renderExperience :: [Exp] -> String
|
||||
renderExperience xs = research ++ industry
|
||||
where
|
||||
visible = sortOn exOrder (filter exVisible xs)
|
||||
isRes e = exSection e == Just "research"
|
||||
research = group "experience-research" "Research Experience" (filter isRes visible)
|
||||
industry = group "experience-industry" "Industry Experience" (filter (not . isRes) visible)
|
||||
group slug heading es
|
||||
| null es = ""
|
||||
| otherwise = section slug heading $ concat
|
||||
[ "<ul class=\"item-card-list vita-list\">"
|
||||
, concatMap one es
|
||||
, "</ul>"
|
||||
]
|
||||
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)
|
||||
, maybe "" (\p -> "<p class=\"vita-note\">" ++ tex p ++ "</p>") (exPreamble e)
|
||||
, renderBullets (exBullets e)
|
||||
, "</div>"
|
||||
, "</li>"
|
||||
]
|
||||
|
||||
renderContact :: Person -> String
|
||||
renderContact p = section "contact" "Contact" $ concat
|
||||
[ "<p class=\"vita-links\">"
|
||||
, "<a class=\"vita-chip\" href=\"mailto:", escapeHtml (pnEmail p), "\">"
|
||||
, escapeHtml (pnEmail p)
|
||||
, "</a>"
|
||||
, concatMap one (filter plVisible (pnLinks p))
|
||||
, "</p>"
|
||||
]
|
||||
where
|
||||
-- Chips carry the label, not personal.yml's `display` value. The CV
|
||||
-- prints "ORCID: 0009-0002-0162-3587" because a printed page cannot be
|
||||
-- clicked; a chip reading "0009-0002-0162-3587" alone identifies
|
||||
-- nothing, and "github.com/levineuwirth" is a URL doing a label's job.
|
||||
one l = concat
|
||||
[ "<a class=\"vita-chip\" href=\"", escapeHtml (plHref l), "\">"
|
||||
, tex (plLabel l)
|
||||
, "</a>"
|
||||
]
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Load
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Same UTF-8 round-trip as "Now": Hakyll hands back a 'String' of Unicode
|
||||
-- codepoints and the yaml library wants a UTF-8 'ByteString'.
|
||||
-- 'Data.ByteString.Char8.pack' would truncate every 'Char' to 8 bits and
|
||||
-- silently mangle the em-dashes and daggers this data is full of.
|
||||
loadYaml :: FromJSON a => FilePath -> Compiler a
|
||||
loadYaml path = do
|
||||
raw <- load (fromFilePath path) :: Compiler (Item String)
|
||||
case Y.decodeEither' (TE.encodeUtf8 (T.pack (itemBody raw))) of
|
||||
Left err -> fail (path ++ ": " ++ show err)
|
||||
Right doc -> return doc
|
||||
|
||||
-- | Render a section, or drop the field entirely when it comes out empty so
|
||||
-- the template's @$if(...)$@ guards behave.
|
||||
sectionField :: String -> Compiler String -> Context String
|
||||
sectionField name gen = field name $ \_ -> do
|
||||
html <- gen
|
||||
if null html then noResult (name ++ ": empty") else return html
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Context
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
vitaCtx :: Context String
|
||||
vitaCtx =
|
||||
constField "vita" "true"
|
||||
<> sectionField "vita-education-html"
|
||||
(renderEducation . unEduDoc <$> loadYaml "yaml-source/data/education.yml")
|
||||
<> sectionField "vita-publications-html"
|
||||
(renderPublications . unPubDoc <$> loadYaml "yaml-source/data/publications.yml")
|
||||
<> sectionField "vita-presentations-html"
|
||||
(renderPresentations . unPresDoc <$> loadYaml "yaml-source/data/presentations.yml")
|
||||
<> sectionField "vita-experience-html"
|
||||
(renderExperience . unExpDoc <$> loadYaml "yaml-source/data/experience.yml")
|
||||
<> sectionField "vita-contact-html"
|
||||
(renderContact <$> loadYaml "yaml-source/data/personal.yml")
|
||||
<> siteCtx
|
||||
|
|
@ -3,72 +3,23 @@ title: Levi Neuwirth — Vita
|
|||
tags: meta
|
||||
---
|
||||
|
||||
For a less formal, more detailed introduction to who I am, see [[Me]].
|
||||
The formal record. For a less formal, more detailed introduction to who I am, see [[Me]]; for what I am actively working on this month, see [Current](/current.html).
|
||||
|
||||
## Documents
|
||||
These are probably what you're looking for. A summary of the key points follows below!
|
||||
|
||||
- **[Curriculum Vitae (PDF)](/cv.pdf)**
|
||||
- **[Resume (PDF)](/resume.pdf)**
|
||||
- **[Curriculum Vitae (PDF)](/cv.pdf)** — the complete record, including grants, affiliations, languages, and technical skills.
|
||||
- **[Resume (PDF)](/resume.pdf)** — one page, engineering-facing.
|
||||
- **[Project index](/cv/projects/)** — engineering artifacts in depth, with links to writeups and source.
|
||||
|
||||
## Education
|
||||
|
||||
- **Technical University of Denmark (DTU)** — MSc in Computer Science and Engineering. September 2026 – expected 2028. Expecting PhD studies after.
|
||||
- **Brown University** — Sc.B. in Computer Science and Mathematics. August 2022 – May 2026
|
||||
- **DIS Copenhagen / Københavns Universitet** — Semester abroad. Fall 2024
|
||||
The sections below are generated from the same data as the two PDFs, so they cannot fall out of step with them.
|
||||
|
||||
## Research Interests
|
||||
|
||||
My work clusters into four threads:
|
||||
|
||||
- **AI safety and applied AI** — zero-knowledge proofs for cryptographic verification of large language models, as a MARS V fellow with the [Cambridge AI Safety Hub](https://caish.org/mars) (mentored by James Petrie, Future of Life Institute); a Magic: The Gathering reinforcement-learning project on OSCAR; agentic-systems work at xAI on `grok-code-fast-1`; and reasoning, evaluation, and red-teaming research contracts.
|
||||
- **Mathematics** — graph theory, number theory, and theoretical computer science. Public results so far are graph-theory-centered: static coverage and persistence in tree-ball geometry ([preprint](/essays/branch-based-local-capture-in-tree-balls/)); more is in progress.
|
||||
- **AI safety and applied AI** — zero-knowledge proofs for cryptographic verification of large language models, as a MARS V fellow with the [Cambridge AI Safety Hub](https://caish.org/mars) (mentored by James Petrie, Future of Life Institute); a Magic: The Gathering reinforcement-learning project; and reasoning, evaluation, and red-teaming research contracts.
|
||||
- **Mathematics** — graph theory, number theory, and theoretical computer science. Public results so far are graph-theory-centered: static coverage and persistence in tree-ball geometry ([preprint](/essays/branch-based-local-capture-in-tree-balls/)), and the annealed critical window for growing-radius domination in random regular graphs ([preprint](/essays/near-critical-growing-radius-domination.html)).
|
||||
- **Computer systems and high-performance computing** — the Weenix kernel, a TCP/IP networking stack from scratch in Go, and micro-architectural performance work (SIMD, hardware counters via PAPI, RAPL energy, cross-ISA ports across AVX2 / ARM NEON-SVE / RISC-V V) on Brown's OSCAR HPC cluster.
|
||||
- **Machine learning** — order-invariant ICD-10-CM embeddings (under review at *JAMIA*, deployed calculator), the NeuroPose 3D-kinematics system in Liqi Shu's lab at Brown Neurology, and ongoing research engineering at [NeuroAI](https://neuroai.health). Undergraduate work has been clinically focused; graduate study broadens the scope.
|
||||
|
||||
Computer vision and security thread through all four but do not stand on their own.
|
||||
|
||||
## Research
|
||||
|
||||
### Published / In Submission
|
||||
|
||||
- **Neuwirth L.** *Branch-Tube Persistence and Static Coverage in Tree-Ball Geometry.* Preprint, July 2026. [Preprint](/essays/branch-based-local-capture-in-tree-balls/)
|
||||
- **Neuwirth L.** *Where Does SIMD Help Post-Quantum Cryptography? A Micro-Architectural Study of ML-KEM on x86 AVX2.* Technical report, Brown University Department of Computer Science, April 2026. [Report](/essays/where-does-simd-help-post-quantum-cryptography/) · [Artifact](https://git.levineuwirth.org/neuwirth/where-simd-helps)
|
||||
- **Shu L, Neuwirth L†, Wang X†, Zheng H†.** *Beyond Comorbidity Indices: An Order-Invariant ICD-10-CM Embedding for Readmission and Mortality Prediction.* Under review at the *Journal of the American Medical Informatics Association* (JAMIA), 2026. [Preprint](/essays/beyond-comorbidity-indices/) · [Calculator](https://levineuwirth.github.io/icd_embeddings/) · [Code](https://github.com/levineuwirth/icd_embeddings)
|
||||
|
||||
### In Preparation / In Progress
|
||||
|
||||
- **Zero-knowledge proofs for LLM verification.** MARS V fellowship, [Cambridge AI Safety Hub](https://caish.org/mars), July–October 2026; cryptographic verification of claims about model training, inference, and deployment. Public write-up expected October 2026.
|
||||
- **[NeuroPose](/essays/neuropose/) clinical-implications manuscript.** In preparation; target submission 2026–2027.
|
||||
- **SIMD / PQC Phase 2 & Phase 3.** Hardware performance counters (PAPI), RAPL energy, and cross-ISA ports (ARM NEON/SVE, RISC-V V).
|
||||
- **Semantic-embeddings citation project.** Early-stage work with [NeuroAI](https://neuroai.health), preprint expected summer 2026.
|
||||
- **Magic: The Gathering reinforcement learning project.** Early-stage work through Brown's HPC, expected late 2026.
|
||||
|
||||
### Presentations
|
||||
|
||||
- **Early Detection of Neurological Disorders through Video-Captured Kinematic Analysis.** Ma J, Arms S, Kaneira L, Lall M, Chen K, Cabral W, Man D, Neuwirth L, Shu L. Poster, Brown / Rhode Island Hospital Neurology Summer UTRA Symposium, August 2025.
|
||||
- **"Order-Invariant ICD-10-CM Embedding for Readmission and Mortality Prediction: Toward Multimodal Generative Patient Models"**Shu L, Neuwirth L†, Wang X†, Zheng H†. IEEE/ACM Conference on Connected Health: Applications, Systems and Engineering Technologies (CHASE), August 2026, accepted.
|
||||
- **Neuwirth L, Dasher AS.** "Proving What a Datacenter Did: Verified Inference Between Adversaries." Talk, MARS V, Cambridge AI Safety Hub, July 2026. Accepted.
|
||||
|
||||
†Equal-contribution undergraduate authors.
|
||||
|
||||
## Experience
|
||||
See [resume (PDF)](/resume.pdf).
|
||||
|
||||
- **Cambridge AI Safety Hub** — *MARS V Fellow.* July – October 2026, Cambridge, UK. Selected for [MARS V](https://caish.org/mars), a competitive part-time AI-safety research fellowship; working under James Petrie (Future of Life Institute) on zero-knowledge proofs for cryptographic verification of large language models.
|
||||
- **Shu Laboratory, Brown Department of Neurology** — *Undergraduate Researcher and Technical Lead.* October 2023 – Present. Technical lead on [NeuroPose](/essays/neuropose/); co-lead developer on the [ICD-10-CM embedding model](/essays/beyond-comorbidity-indices/).
|
||||
- **NeuroAI** Present. [neuroai.health](https://neuroai.health). Early-stage venture of academics and clinicians integrating deep learning, reinforcement learning, and generative AI into clinical and research workflows; leading research-engineering across model development, deployment infrastructure, and system design.
|
||||
- **xAI** Summer 2025, remote. Contributed to the training of `grok-code-fast-1`, xAI's agentic coding model; built LLM integrations into autonomous agent frameworks and resolved 50+ agentic tool-execution failures.
|
||||
- **Independent Research Contracting** — *Various AI laboratories.* 2025 – Present. Expert reasoning contributions in code and mathematics for agentic workflows, agentic task design and evaluation, AI safety, and red-teaming.
|
||||
|
||||
## Selected Projects
|
||||
|
||||
- **[Weenix](/essays/weenix/)** — Unix-like kernel in ~7,000 lines of C: virtual memory, VFS, system calls, threading, device drivers, interrupt handlers, and file systems. Custom linker support for running userspace x86-64 ELF binaries; extended with pipes and userspace preemption.
|
||||
- **[Networking Stack from Scratch](/essays/networking-stack/)** — TCP/IP, RIP, UDP, and DNS in Go, supporting file transmission of up to 1 GB across 8-node networks. Extended with a fully RFC-compliant SSH implementation (2,000+ additional lines) supporting sustained sessions of arbitrary length.
|
||||
- **[SIMD / PQC Performance Study](/essays/where-does-simd-help-post-quantum-cryptography/)** — Hand-written AVX2 assembly for ML-KEM / Kyber. 35×–56× speedup over compiler-optimized C for core NTT arithmetic; 5.4×–7.1× end-to-end KEM speedup, with a full statistical-analysis pipeline on Brown's OSCAR cluster.
|
||||
|
||||
For the complete index — additional artifacts, deployed ML, and smaller tools — see [/cv/projects/](/cv/projects/).
|
||||
|
||||
## Contact
|
||||
|
||||
[ln@levineuwirth.org](mailto:ln@levineuwirth.org) · [ORCID 0009-0002-0162-3587](https://orcid.org/0009-0002-0162-3587) · [GitHub](https://github.com/levineuwirth) · [Forgejo](https://git.levineuwirth.org/neuwirth)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ executable site
|
|||
Catalog
|
||||
Commonplace
|
||||
Now
|
||||
Vita
|
||||
Backlinks
|
||||
Dingbat
|
||||
SimilarLinks
|
||||
|
|
|
|||
|
|
@ -0,0 +1,304 @@
|
|||
/* vita.css — /about.html.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* ============================================================
|
||||
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.
|
||||
============================================================ */
|
||||
|
||||
.vita-intro {
|
||||
margin: 0 0 3rem;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.vita-intro h2 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.15rem;
|
||||
font-variant: all-small-caps;
|
||||
font-feature-settings: "smcp" 1;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
margin: 2rem 0 0.6rem;
|
||||
}
|
||||
|
||||
.vita-intro p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SECTIONS
|
||||
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;
|
||||
}
|
||||
|
||||
.vita-section-heading {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.15rem;
|
||||
font-variant: all-small-caps;
|
||||
font-feature-settings: "smcp" 1;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--text-muted);
|
||||
text-transform: none;
|
||||
font-weight: 400;
|
||||
margin: 0 0 0.85rem 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
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.
|
||||
============================================================ */
|
||||
|
||||
.vita-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vita-entry-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.vita-gpa {
|
||||
font-style: normal;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.92rem;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
margin: 0.2rem 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.vita-sep {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.vita-location {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
a.vita-location {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.2em;
|
||||
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
|
||||
}
|
||||
|
||||
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 {
|
||||
font-family: var(--font-sans);
|
||||
font-variant: all-small-caps;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vita-status::before {
|
||||
content: " · ";
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Preamble / note lines — the CV's connective prose. Same register
|
||||
as .item-card-abstract.is-full, unclamped. */
|
||||
.vita-note {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-size-small);
|
||||
color: var(--text-muted);
|
||||
margin: 0.4rem 0 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.vita-bullets {
|
||||
margin: 0.4rem 0 0;
|
||||
padding-left: 1.05rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
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 {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1.05rem;
|
||||
top: 0.72em;
|
||||
width: 0.5rem;
|
||||
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.
|
||||
============================================================ */
|
||||
|
||||
.vita-links {
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.vita-chip {
|
||||
display: inline-block;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.66rem;
|
||||
font-variant: all-small-caps;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
padding: 0.32em 0.55em 0.28em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.vita-chip:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-muted);
|
||||
background: var(--surface-raised, transparent);
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
color: var(--text-faint);
|
||||
margin: 0.9rem 0 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.
|
||||
============================================================ */
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.vita-entry-title {
|
||||
font-size: 0.95rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.vita-role,
|
||||
.vita-venue {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vita-meta {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.vita-chip {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.28em 0.45em 0.24em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vita-chip,
|
||||
a.vita-location {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,8 @@ $if(catalog)$<link rel="stylesheet" href="/css/catalog.css">$endif$
|
|||
$if(commonplace)$<link rel="stylesheet" href="/css/commonplace.css">$endif$
|
||||
$if(now)$<link rel="stylesheet" href="/css/item-card.css">$endif$
|
||||
$if(now)$<link rel="stylesheet" href="/css/now.css">$endif$
|
||||
$if(vita)$<link rel="stylesheet" href="/css/item-card.css">$endif$
|
||||
$if(vita)$<link rel="stylesheet" href="/css/vita.css">$endif$
|
||||
$if(build)$<link rel="stylesheet" href="/css/build.css">$endif$
|
||||
$if(reading)$<link rel="stylesheet" href="/css/reading.css">$endif$
|
||||
$if(composition)$<link rel="stylesheet" href="/css/score-reader.css">$endif$
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
<main id="markdownBody" data-pagefind-body>
|
||||
<h1 class="page-title">$title$</h1>
|
||||
|
||||
<div class="vita-intro">$body$</div>
|
||||
|
||||
$vita-education-html$
|
||||
$vita-experience-html$
|
||||
$vita-publications-html$
|
||||
$vita-presentations-html$
|
||||
$vita-contact-html$
|
||||
</main>
|
||||
Loading…
Reference in New Issue