diff --git a/Makefile b/Makefile index cb4ed68..3144573 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deploy sign download-model download-pdfjs download-leaflet compress-assets convert-images pdf-thumbs pdfs watch clean dev archive-gc archive-wayback archive-check +.PHONY: build deploy sign download-model download-pdfjs download-leaflet compress-assets convert-images pdf-thumbs pdfs watch clean dev audit-marks archive-gc archive-wayback archive-check # Source .env for deploy / GitHub config if it exists. # .env format: KEY=value (one per line, no `export` prefix, no quotes needed). @@ -163,6 +163,18 @@ watch: clean: cabal run site -- clean +# Report which content pieces are missing a monogram (mark.svg) and / or +# the epistemic figure (status: frontmatter). Exits 0 unconditionally; +# this is a coverage report, not a build gate. The pre-commit hook at +# tools/hooks/pre-commit-marks.sh runs the same script for newly-staged +# .md files. +audit-marks: + @if [ -d .venv ]; then \ + uv run python tools/audit-marks.py; \ + else \ + python3 tools/audit-marks.py; \ + fi + # Evict archived works: delete archive// directories whose slug is # recorded in archive/removed.yaml. Opt-in — NEVER run by `make build`. # Orphan directories (not in manifest.yaml, not in removed.yaml) are diff --git a/build/Contexts.hs b/build/Contexts.hs index b7197f7..b05c680 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -48,7 +48,7 @@ import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..)) import Hakyll hiding (trim) import Backlinks (backlinksField) import Dingbat (dingbatField) -import Marks (monogramSvgField, epistemicSvgField) +import Marks (monogramSvgField, hasMonogramField, epistemicSvgField) import SimilarLinks (similarLinksField) import Stability (stabilityField, lastReviewedField, lastReviewedIsoField, versionHistoryField, @@ -437,6 +437,7 @@ siteCtx = <> summaryField <> dingbatField <> monogramSvgField + <> hasMonogramField <> defaultContext -- --------------------------------------------------------------------------- diff --git a/build/Marks.hs b/build/Marks.hs index 09a7bcb..ed4dffd 100644 --- a/build/Marks.hs +++ b/build/Marks.hs @@ -16,7 +16,11 @@ -- byte-identical SVGs, so the GPG signing pipeline is undisturbed. module Marks ( monogramSvgField + , hasMonogramField + , monogramSvgFieldFor + , hasMonogramFieldFor , epistemicSvgField + , hasMonogram ) where import Control.Exception (IOException, try) @@ -52,6 +56,24 @@ monogramCandidates fp = then [dir "mark.svg"] else [dir takeBaseName fp ++ ".mark.svg"] +-- | Predicate form of 'resolveMonogramPath' — used by Stats.hs to +-- compute monogram coverage on @/build/@. Returns 'True' when at +-- least one of the dual-form candidate paths exists on disk. +hasMonogram :: Item a -> Compiler Bool +hasMonogram item = isJust <$> resolveMonogramPath item + +-- | @$has-monogram$@ — present (renders as @"true"@) only when the +-- item has an actual @mark.svg@ on disk; 'noResult' for the +-- placeholder-roundel case. Templates that don't want to display +-- placeholder roundels (e.g. item-card listings, popup previews) +-- gate on this flag instead of @$monogramSvg$@, which the +-- frontmatter header relies on always rendering for symmetric +-- column layout. +hasMonogramField :: Context String +hasMonogramField = field "has-monogram" $ \item -> do + has <- hasMonogram item + if has then return "true" else noResult "no real monogram" + -- | Return the first candidate path that exists on disk, or 'Nothing'. resolveMonogramPath :: Item a -> Compiler (Maybe FilePath) resolveMonogramPath item = @@ -72,13 +94,17 @@ resolveMonogramPath item = -- tools may produce hardcoded blacks; the contract still holds), strips -- the @width@/@height@ presentation attributes from the root @@, -- and wraps the result in @
@. Returns 'noResult' when no candidate --- exists; warns and returns 'noResult' on read failure. +-- frontmatter-mark--monogram">@. +-- +-- When no @mark.svg@ exists, returns the placeholder roundel — an +-- empty outer ring at lower opacity that visually balances the +-- epistemic-figure column and signals "monogram not yet authored". +-- Read failures fall back to the same placeholder. monogramSvgField :: Context String monogramSvgField = field "monogramSvg" $ \item -> do mPath <- resolveMonogramPath item case mPath of - Nothing -> noResult "no mark.svg" + Nothing -> return $ T.unpack monogramPlaceholder Just path -> do result <- unsafeCompiler $ try (TIO.readFile path) :: Compiler (Either IOException T.Text) @@ -87,9 +113,24 @@ monogramSvgField = field "monogramSvg" $ \item -> do unsafeCompiler $ hPutStrLn stderr $ "[Marks] " ++ toFilePath (itemIdentifier item) ++ ": failed to read " ++ path ++ ": " ++ show e - noResult "monogram read failed" + return $ T.unpack monogramPlaceholder Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg) +-- | Empty-roundel placeholder used while a piece's monogram is still +-- to be authored (Phase 2 of MARKS.md). The @--placeholder@ modifier +-- class lets CSS render it at reduced opacity so it reads as a +-- neutral frame rather than a finished glyph. +monogramPlaceholder :: T.Text +monogramPlaceholder = T.concat + [ "
" + , "" + , "" + , "" + , "
" + ] + -- | Wrap inlined monogram SVG in its outer figure element. wrapMonogram :: T.Text -> T.Text wrapMonogram svg = T.concat @@ -98,6 +139,33 @@ wrapMonogram svg = T.concat , "
" ] +-- | @$monogramSvg$@ override for synthesized pages whose item identifier +-- doesn't live under @content/@ (e.g. @/build/@, @/stats/@), so the +-- auto-resolver in 'monogramSvgField' can't find a co-located mark. +-- Reads from the supplied path; falls back to the placeholder roundel +-- when the file is absent or unreadable. +monogramSvgFieldFor :: FilePath -> Context a +monogramSvgFieldFor path = field "monogramSvg" $ \_ -> do + exists <- unsafeCompiler $ doesFileExist path + if not exists + then return $ T.unpack monogramPlaceholder + else do + result <- unsafeCompiler $ try (TIO.readFile path) + :: Compiler (Either IOException T.Text) + case result of + Left e -> do + unsafeCompiler $ hPutStrLn stderr $ + "[Marks] failed to read " ++ path ++ ": " ++ show e + return $ T.unpack monogramPlaceholder + Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg) + +-- | @$has-monogram$@ override paired with 'monogramSvgFieldFor'. Present +-- (as @"true"@) only when the path exists; 'noResult' otherwise. +hasMonogramFieldFor :: FilePath -> Context a +hasMonogramFieldFor path = field "has-monogram" $ \_ -> do + exists <- unsafeCompiler $ doesFileExist path + if exists then return "true" else noResult "no real monogram" + -- | Replace hardcoded black fills/strokes with @currentColor@ and strip -- the root @@'s @width@/@height@ attributes (presentation lives -- in CSS via the @.frontmatter-mark svg@ selector). Mirrors the color diff --git a/build/Stats.hs b/build/Stats.hs index 10fc2fb..d99a5e2 100644 --- a/build/Stats.hs +++ b/build/Stats.hs @@ -39,6 +39,8 @@ import qualified Text.Blaze.Internal as BI import Hakyll import Archive (archiveBuildStats) import Contexts (siteCtx, authorLinksField) +import Marks (hasMonogram, monogramSvgFieldFor, + hasMonogramFieldFor) import qualified Patterns as P import Utils (readingTime) @@ -676,6 +678,39 @@ renderEpistemic total ws wc wi we = , txt (pctStr n total) ] +-- | Per-content-type counts feeding 'renderMarks'. @mrCount@ is the +-- denominator (total pieces of that type), @mrMonogram@ is the count +-- with a co-located @mark.svg@, and @mrFigure@ is the count with +-- @status:@ frontmatter (which is what triggers the epistemic figure +-- per MARKS.md §3.1). +data MarkRow = MarkRow + { mrLabel :: String + , mrCount :: Int + , mrMonogram :: Int + , mrFigure :: Int + } + +renderMarks :: [MarkRow] -> H.Html +renderMarks rows = + section "marks" "Marks coverage" $ + table + ["Type", "Pieces", "Monogram", "Epistemic figure"] + (map row rows) + (Just [ "Total" + , txt (commaInt totalCount) + , txt (commaInt totalMono ++ " (" ++ pctStr totalMono totalCount ++ ")") + , txt (commaInt totalFig ++ " (" ++ pctStr totalFig totalCount ++ ")") + ]) + where + totalCount = sum (map mrCount rows) + totalMono = sum (map mrMonogram rows) + totalFig = sum (map mrFigure rows) + row r = [ txt (mrLabel r) + , txt (commaInt (mrCount r)) + , txt (commaInt (mrMonogram r) ++ " (" ++ pctStr (mrMonogram r) (mrCount r) ++ ")") + , txt (commaInt (mrFigure r) ++ " (" ++ pctStr (mrFigure r) (mrCount r) ++ ")") + ] + renderOutput :: Map.Map String (Int, Integer) -> Int -> Integer -> H.Html renderOutput grouped totalFiles totalSize = section "output" "Output" $ @@ -734,6 +769,7 @@ pageTOC = H.ol $ mapM_ item sections , ("tags", "Tags") , ("links", "Links") , ("epistemic", "Epistemic coverage") + , ("marks", "Marks coverage") , ("output", "Output") , ("archive", "Link archive") , ("repository", "Repository") @@ -844,8 +880,11 @@ statsRules tags = do -- ---------------------------------------------------------------- -- Epistemic coverage (essays + posts) -- ---------------------------------------------------------------- - essayMetas <- mapM (getMetadata . itemIdentifier) essays - postMetas <- mapM (getMetadata . itemIdentifier) posts + essayMetas <- mapM (getMetadata . itemIdentifier) essays + postMetas <- mapM (getMetadata . itemIdentifier) posts + poemMetas <- mapM (getMetadata . itemIdentifier) poems + fictionMetas <- mapM (getMetadata . itemIdentifier) fiction + compMetas <- mapM (getMetadata . itemIdentifier) comps let epMetas = essayMetas ++ postMetas epTotal = length epMetas ep f = length (filter (isJust . f) epMetas) @@ -854,6 +893,38 @@ statsRules tags = do withImp = ep (lookupString "importance") withEv = ep (lookupString "evidence") + -- ---------------------------------------------------------------- + -- Marks coverage (per-portal monogram + epistemic-figure counts) + -- + -- Monogram presence is a disk lookup via Marks.hasMonogram; + -- epistemic-figure presence is the same trigger as the figure + -- generator itself (status: set in frontmatter). + -- ---------------------------------------------------------------- + essayMonos <- mapM hasMonogram essays + postMonos <- mapM hasMonogram posts + poemMonos <- mapM hasMonogram poems + fictionMonos <- mapM hasMonogram fiction + compMonos <- mapM hasMonogram comps + let countTrue = length . filter id + countStat = length . filter (isJust . lookupString "status") + markRows = + [ MarkRow "Essays" (length essays) + (countTrue essayMonos) + (countStat essayMetas) + , MarkRow "Blog posts" (length posts) + (countTrue postMonos) + (countStat postMetas) + , MarkRow "Poems" (length poems) + (countTrue poemMonos) + (countStat poemMetas) + , MarkRow "Fiction" (length fiction) + (countTrue fictionMonos) + (countStat fictionMetas) + , MarkRow "Compositions" (length comps) + (countTrue compMonos) + (countStat compMetas) + ] + -- ---------------------------------------------------------------- -- Output directory stats -- ---------------------------------------------------------------- @@ -893,6 +964,7 @@ statsRules tags = do renderTagsSection topTags uniqueTags renderLinks mostLinkedInfo orphanCount (length allPIs) renderEpistemic epTotal withStatus withConf withImp withEv + renderMarks markRows renderOutput outputGrouped totalFiles totalSize renderArchive archiveMetrics renderRepository hf hl cf cl jf jl commits firstDate @@ -909,6 +981,8 @@ statsRules tags = do \link analysis, epistemic coverage, output metrics, \ \repository overview, and build timing." <> constField "build" "true" + <> monogramSvgFieldFor "content/build.mark.svg" + <> hasMonogramFieldFor "content/build.mark.svg" <> authorLinksField <> siteCtx @@ -985,6 +1059,8 @@ statsRules tags = do <> constField "abstract" "Writing activity, corpus breakdown, \ \and tag distribution — computed at build time." <> constField "build" "true" + <> monogramSvgFieldFor "content/stats.mark.svg" + <> hasMonogramFieldFor "content/stats.mark.svg" <> authorLinksField <> siteCtx diff --git a/static/css/marks.css b/static/css/marks.css index c42edb5..1a49837 100644 --- a/static/css/marks.css +++ b/static/css/marks.css @@ -9,40 +9,106 @@ suppress the slot div entirely when its SVG is empty). ============================================================ */ -/* Three-column grid: +/* Three-column grid spanning the full viewport width: [ monogram ] [ title block — 1fr ] [ epistemic figure ] - The 1fr column absorbs all extra width. Mark slots are - sized to their content via grid auto-placement. When the - template guard suppresses one or both slot divs, the column - simply does not exist for layout purposes. */ + The header lives outside #content so the grid stretches + edge-to-edge; the side columns are pinned to the same + `clamp()` width as the marks themselves so the middle + column stays symmetric in the viewport even when only one + slot has content (e.g. essays without status: render only + the monogram, but the right column still reserves its + width — without this, the title block would centre in an + off-axis 1fr cell and visibly slide right). The padding + mirrors #content's `2rem var(--page-padding)` so the + header aligns with the body below. */ .essay-frontmatter { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: + clamp(170px, 17vw, 280px) + minmax(0, 1fr) + clamp(170px, 17vw, 280px); column-gap: clamp(0.75rem, 2vw, 1.75rem); row-gap: 0.75rem; align-items: center; - margin-bottom: 1rem; + padding: 2rem var(--page-padding); + width: 100%; } /* Reading variant (poetry / fiction) and blog variant share the same grid; declared explicitly so future tweaks can diverge. */ .essay-frontmatter--reading, .essay-frontmatter--blog { - grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-columns: + clamp(170px, 17vw, 280px) + minmax(0, 1fr) + clamp(170px, 17vw, 280px); } -/* Title block stays in the centre; never shrinks below 0. */ +/* Title block: centred in its grid column, capped to roughly + the body column's measure so prose lines stay readable on + ultrawide viewports. `justify-self: center` is the explicit + override of grid items' default `stretch`; without it auto + margins do not centre because the item is already filling + the cell at max-width. */ .frontmatter-title { min-width: 0; + width: 100%; + max-width: var(--body-max-width); + justify-self: center; + text-align: center; } -/* Centre the title and metadata under the H1 — matches the - visual rhythm of the reference mockup, where the byline, - abstract, and compact strip sit in a stacked column. */ .frontmatter-title > .page-title { margin-bottom: 0.25rem; } +/* Abstract paragraph reads better left-aligned even inside a + centred title block — multi-line prose with ragged-right + on a centred axis becomes hard to track. */ +.frontmatter-title .meta-description { + text-align: left; +} + +/* Compact-strip chips sit comfortably wider than the abstract; + centre them so the row balances visually under the title. */ +.frontmatter-title .meta-epistemic-strip { + justify-content: center; +} + +/* Tailmatter: the body-level wrapper that hosts the metadata-tail + row (tags + keywords + affiliation + pagelinks). Constrained to + the body column's measure and centred so its contents read at the + same width they did before the layout split, while sitting + *above* #content so the TOC sidebar starts right under the + frontmatter divider rather than competing with it for the top + of the page. */ +.essay-tailmatter { + width: min(var(--body-max-width), 100%); + margin: 0 auto; + padding: 0 var(--page-padding); + box-sizing: border-box; +} + +/* The cursive-L frontmatter divider runs edge-to-edge of the + viewport — the dashed lines on either side of the L use the + existing `flex: 1` rule on `.content-divider::before` and + `::after` to fill whatever container they sit inside, so as a + body-level child the divider naturally spans the full page + width. The page-padding keeps the dashes off the literal edge + while still reading as a full-width separator. */ +.content-divider--frontmatter { + padding: 0 var(--page-padding); + box-sizing: border-box; +} + +/* Monogram placeholder (rendered when no mark.svg exists for the + piece — see Marks.monogramPlaceholder). Lower opacity so it reads + as a neutral frame, balancing the epistemic-figure column without + being mistaken for an authored glyph. */ +.frontmatter-mark--placeholder svg { + opacity: 0.35; +} + /* Subtitle: a short secondary line, lighter than the H1, never competing with it. Kept restrained so existing essays without a subtitle render unchanged. */ @@ -81,8 +147,6 @@ #markdownBody .frontmatter-mark { margin: 0; padding: 0; - width: 170px; - height: 170px; max-width: none; background: none; border: none; @@ -91,6 +155,30 @@ color: var(--text); } +/* Frontmatter header context: scale with viewport. 170 px floor on + narrow desktops, 280 px cap on ultrawide displays (matches the + monogram viewBox so the placeholder roundel reaches its native + edge). 17vw is the slope between the two — at ~1000 px it equals + the floor, at ~1650 px it equals the cap. */ +.essay-frontmatter .frontmatter-mark { + width: clamp(170px, 17vw, 280px); + height: clamp(170px, 17vw, 280px); +} + +/* Item-card context: small inline glyph beside the kind badge. + Sized to read as a marker, not a competing figure. */ +.item-card-monogram { + flex-shrink: 0; + line-height: 0; + color: var(--text-muted); + margin-top: 0.15em; +} + +.item-card-monogram .frontmatter-mark { + width: 72px; + height: 72px; +} + /* SVG fills its parent figure exactly. !important defeats the global `img, video, svg { max-width: 100%; height: auto }` in base.css for our specific case (which would otherwise leave the diff --git a/templates/blog-post.html b/templates/blog-post.html index 4edc53f..1eba026 100644 --- a/templates/blog-post.html +++ b/templates/blog-post.html @@ -1,21 +1,19 @@ +
+
$monogramSvg$
+
+

$title$

+ $if(subtitle)$

$subtitle$

$endif$ + $if(date)$ + + $endif$ +
+ $if(epistemicSvg)$ +
+ $epistemicSvg$ +
+ $endif$ +
-
- $if(monogramSvg)$ -
$monogramSvg$
- $endif$ -
-

$title$

- $if(subtitle)$

$subtitle$

$endif$ - $if(date)$ - - $endif$ -
- $if(epistemicSvg)$ - - $endif$ -
$body$ $if(backlinks)$