This commit is contained in:
Levi Neuwirth 2026-05-07 23:51:14 -04:00
parent 1274b36d42
commit 7f7c029601
15 changed files with 2109 additions and 48 deletions

959
MARKS.md Normal file
View File

@ -0,0 +1,959 @@
# Frontmatter Marks: Specification
A two-part visual identity for essay and research frontmatter, designed
to extend (not replace) the existing epistemic profile system.
The mark system has two pieces:
1. **Monogram** — a hand-authored SVG glyph per piece, abstracted from
the work's central concept. The author's statement of *what* the
piece is about.
2. **Epistemic figure** — a build-time SVG generated deterministically
from existing frontmatter fields. The site's statement of *where
the piece stands*.
The two are paired in the frontmatter: monogram on the left, title and
abstract in the middle, epistemic figure on the right. Either can be
present alone; both can be absent. When a field that drives the
epistemic figure is missing, the figure is omitted entirely rather
than rendered with empty axes.
This document specifies the authoring interface, the field schema
(extending the existing one in `WRITING.md`), the visual contract,
the build-time rendering pipeline, and the migration plan.
It is written to slot in alongside `WRITING.md` as a sibling reference,
and to live as a section in the colophon once shipped.
---
## 1. Scope and non-goals
### In scope
- A monogram convention (location, dimensions, line-weight, palette)
that any author or generator can produce SVGs for.
- Two new optional frontmatter fields (`peer-status`, `result-shape`)
that surface useful information for both essays and formal research,
with a colophon-glossed interpretation that adapts to genre.
- One narrow exception (`confidence: proved`) that lets formal proofs
honestly opt out of a numeric credence without forcing false precision.
- A new Pandoc filter (`Filters/Mark.hs`) that emits the epistemic
figure SVG inline in the essay header at build time.
- Template changes in `templates/essay.html` and `templates/blog-post.html`
to provide three-column frontmatter slots (monogram | title | figure).
- A `make audit-marks` build target and an addition to `/build/`
surfacing which essays are missing one or both marks.
### Out of scope
- A separate "research badge" figure type. The single radial figure
handles both essays and formal research; see §3.4.
- A unified mode-switched figure with axes that change meaning based on
a `claim-mode` flag. Visual grammar should be unambiguous; one figure
type, stable axis semantics. See §11 for the rejected alternatives.
- Per-portal iconographic systems (the Approach 5 idea from earlier
exploration). Not ruled out for the future, but not specified here.
- Author UI for generating monograms. Authors may use any tool —
hand-drawn, prompt-driven, traced from references — provided the
output meets §2.
---
## 2. The monogram
### 2.1 Authoring contract
A monogram is a single SVG file at:
content/essays/{slug}/mark.svg ← directory-form essays
content/essays/{slug}.mark.svg ← flat-file essays
content/blog/{slug}.mark.svg
content/poetry/{slug}.mark.svg
content/fiction/{slug}.mark.svg
content/music/{slug}/mark.svg
content/{slug}.mark.svg ← standalone pages
content/drafts/essays/{slug}.mark.svg ← drafts
The build picks up the file by the same slug-resolution rules already
used for score fragments and page-local JS. No frontmatter key is
required to opt in; the file's presence is the opt-in. To opt out
(suppress an inherited or stale mark), delete the file.
### 2.2 Visual contract
Monograms must satisfy the following constraints. These are enforced
by `tools/audit-marks.py` and by `make audit-marks` (§9), not at
build-fail level — violations warn but do not break the build.
| # | Constraint | Rationale |
|---|---|---|
| M1 | `viewBox="0 0 280 280"` (or proportional, square) | Renders at 130280 px equally. |
| M2 | All strokes use `stroke="currentColor"`; all fills use `fill="none"` except small filled point-marks which use `fill="currentColor"` | Inverts cleanly under Light, Dark, Cappuccino without per-theme assets. The score-fragment filter already does this substitution; monograms must be authored this way from the start. |
| M3 | Outer roundel: `<circle cx="140" cy="140" r="128" stroke-width="0.6"/>` | The unifying frame. Without it, marks read as illustrations, not as a system. |
| M4 | Stroke widths within {0.3, 0.5, 0.6, 0.8, 1.0, 1.2, 1.4} | Limits visual rhythm to a small palette. |
| M5 | `stroke-linecap="round"` and `stroke-linejoin="round"` everywhere | Spectral-compatible terminals. |
| M6 | No `<text>`, no `<image>`, no gradients, no filters, no embedded fonts, no rasters | Letterforms and color belong to the page, not the mark. Note: `<title>` and `<desc>` for accessibility are required (§2.3), not forbidden. |
| M7 | No XML prologue, no `<?xml` declaration, no DOCTYPE | The file is inlined; a prologue would land mid-body. |
| M8 | File size ≤ 8 KiB | A working corpus of 200 marks at this ceiling is 1.6 MiB total; larger is overkill for a 280-px frontispiece. |
| M9 | Validates as well-formed XML (round-trips through `xmllint --noout`) | Inlining a malformed SVG breaks the surrounding page. |
A reference monogram template lives at `static/templates/mark-template.svg`
and is the recommended starting point for hand-authoring.
### 2.3 Accessibility
Each monogram must include a `<title>` element as the first child of
`<svg>` and may include a `<desc>`:
<svg viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mark-title">
<title id="mark-title">Half-buried column on a low desert horizon</title>
<desc>A frontispiece mark for the essay "Ozymandias: A Static Site Framework".</desc>
...
</svg>
The author writes the visible-content description in `<title>`. The
`role="img"` and `aria-labelledby` attributes are required by M9.
Screen readers announce the title; the desc is supplementary.
### 2.4 Inlining and theming
Monograms are inlined into the page HTML at build time by the same
mechanism `Filters/Score.hs` uses for score fragments. The build step:
1. Reads the SVG file.
2. Strips any `width=` and `height=` attributes from the `<svg>` root
(presentation is controlled by CSS).
3. Replaces any `fill="#000000"`, `fill="black"`, `stroke="#000000"`,
`stroke="black"` with `currentColor` (defensive: lets authors
produce SVGs from generators that hardcode black without breaking
the contract).
4. Wraps the SVG in `<figure class="frontmatter-mark frontmatter-mark--monogram">…</figure>`.
The CSS rule `.frontmatter-mark svg { color: var(--text); }` propagates
the page color to the SVG strokes. No theme-switching JS is required.
### 2.5 Print and reduce-motion
Monograms render in print (they are inert SVG; there is nothing to
suppress). They are unaffected by `prefers-reduced-motion` because
they do not animate. The Display panel's "Focus Mode" hides them via
`.focus-mode .frontmatter-mark { display: none; }`.
---
## 3. The epistemic figure
### 3.1 Visibility rule
The epistemic figure is rendered for a piece if, and only if, the
existing visibility rule for the epistemic block is met:
> The epistemic figure renders when `status:` is set in frontmatter.
This matches the existing rule in `WRITING.md` ("The epistemic footer
section appears when `status` is set"). No new gating field is
introduced. A piece without `status:` gets no figure — the same as it
gets no epistemic block today.
This is deliberate. A figure showing five missing axes and one filled
axis would look like a build bug, not a deliberate position. Either
the piece has taken a position (and therefore renders the figure), or
it has not (and therefore renders only the monogram). The audit job
in §9 lists pieces in `research/` or with `peer-status:` that lack
`status:`, so the absence is visible without being silently broken.
### 3.2 Inputs
The figure consumes only fields already in the schema (per `WRITING.md`),
plus the two new optional fields specified in §4:
| Field | Existing? | Maps to |
|---|---|---|
| `confidence` | yes (0100) | confidence axis length |
| `importance` | yes (15) | importance axis length |
| `evidence` | yes (15) | evidence axis length |
| `scope` | yes (5-step ordinal) | scope axis length |
| `novelty` | yes (4-step ordinal) | novelty axis length |
| `practicality` | yes (5-step ordinal) | practicality axis length |
| *(stability)* | auto from git | outer-ring tick count |
| *(trust score)* | auto from formula | center number |
| `peer-status` | **new** (§4.1) | outer-ring tick *style* |
| `result-shape` | **new** (§4.2) | center glyph beside trust |
| `confidence: proved` | **new exception** (§4.3) | confidence axis renders as proof-cap |
Ordinal-to-numeric mapping is exactly what `Contexts.hs` already does
for the dot-rendering of these fields:
| `scope` value | Numeric |
|---|---|
| `personal` | 1 |
| `local` | 2 |
| `average` | 3 |
| `broad` | 4 |
| `civilizational` | 5 |
| `novelty` value | Numeric |
|---|---|
| `conventional` | 1 |
| `moderate` | 2 |
| `idiosyncratic` | 3 |
| `innovative` | 4 |
(Note: `novelty` is a 4-step scale in the existing schema, not 5. The
figure normalizes to a 01 axis length the same way the dots do —
`(value - 1) / (max - 1)` — so a 4-step scale renders one step shorter
at maximum than a 5-step scale. This is honest and matches existing
behavior; do not silently widen the scale to 5.)
| `practicality` value | Numeric |
|---|---|
| `abstract` | 1 |
| `low` | 2 |
| `moderate` | 3 |
| `high` | 4 |
| `exceptional` | 5 |
The `confidence` axis is 0100; it normalizes as `confidence / 100`.
### 3.3 Geometry
The figure is a 200×200 SVG rendered at frontmatter scale (170 px on
desktop, 130 px on mobile). It consists of:
- An outer roundel (two thin concentric circles, `r=88` and `r=90`,
both `stroke-width="0.5"`).
- Six radial axes at 60° spacing, in this fixed clockwise order
starting from 12 o'clock:
1. confidence (top, 0°)
2. novelty (60°)
3. practicality (120°)
4. scope (180°, bottom)
5. evidence (240°)
6. importance (300°)
Axis stroke `0.3`, opacity `0.55`. Visible at all times whether or
not the corresponding field is set; a missing field renders the
axis without a polygon vertex (see below).
- Four inner concentric guide circles at `0.2, 0.4, 0.6, 0.8` of the
axis radius. Stroke `0.25`, opacity `0.4`.
- A polygon connecting the field values along their axes.
Stroke `1.1`, fill `currentColor` at `fill-opacity="0.08"`. The
polygon is closed only if all six fields are present; otherwise it
is rendered as an open polyline through the present vertices in
axis order, and missing axes contribute no vertex (the line jumps
the missing axis). This is the only mode where partial fields are
rendered; in practice §3.1 ensures all six are present whenever the
figure renders at all.
- Vertex point marks at each present field's position
(`r=2`, `fill="currentColor"`).
- The center number: trust score, in Spectral 500 weight, font-size 16,
centered on the geometric center.
- Below the trust number, in 5 pt Fira Sans with letter-spacing 0.18em,
the literal text `TRUST`.
- Stability ticks on the outer ring at 12 o'clock (see §3.5).
- A result-shape glyph immediately to the right of the trust number
(rendered only when `result-shape:` is set; see §4.2).
The figure deliberately omits the confidence-trend arrow; the trend is
rendered inline in the compact epistemic strip instead (see §3.4). The
figure carries only the geometry, the trust score, and the result-shape
glyph.
A reference renderer in pure SVG, with annotated coordinates, is
checked in at `static/templates/epistemic-figure-reference.svg` for
visual regression testing.
### 3.4 Confidence trend
The trend arrow is rendered inline in the compact epistemic strip,
immediately after the confidence percentage (e.g. `conf 80%↑`). It
indicates the direction of the *last* step in `confidence-history`:
| Last step | Glyph |
|---|---|
| Increase (∆ > 5) | ↑ |
| Decrease (∆ < 5) | ↓ |
| Equal (within ±5) | → |
When `confidence-history` is absent or has fewer than two entries, the
arrow is not drawn. The arrow uses the same parsing and ±5 threshold
the existing epistemic block uses; no new heuristic is introduced.
The arrow lives in the strip rather than on the figure for two reasons:
the figure stays visually clean, and the arrow sits next to the value
it modifies. This is a deliberate departure from earlier drafts that
placed the arrow at the confidence vertex.
### 3.5 Stability ticks
The existing `Stability.hs` heuristic produces one of five labels:
`volatile`, `revising`, `fairly stable`, `stable`, `established`.
These map to outer-ring ticks at 12 o'clock:
| Stability | Tick count | Tick positions (visible / dim) |
|---|---|---|
| volatile | 1 | center only |
| revising | 2 | center + left |
| fairly stable | 3 | center + left + right |
| stable | 4 | center + left + right + far-left |
| established | 5 | all five |
Ticks are short radial line segments just outside the outer roundel,
11.5 px in length, `stroke-width="1"`. Inactive ticks are drawn at
`opacity="0.4"` so the full set is always visible; this gives the
reader a constant five-step scale to anchor against.
The manual override mechanism (`IGNORE.txt`) documented in WRITING.md
applies unchanged: a path listed there pins stability for one build
and is cleared by `make build`. The figure honors the override.
### 3.6 Visibility under reduce-motion and print
The figure does not animate, so reduce-motion has no effect.
In print, the figure renders at fixed size and inverts to black-on-white
via the existing `@media print` rules in `static/css/print.css`. No
new print rules are required.
### 3.7 Theming
The figure uses `currentColor` exclusively. The `<text>` elements
explicitly set `fill="currentColor" stroke="none"` to prevent text
nodes from inheriting the strokes used for geometry.
### 3.8 Tooltip and link
The figure is wrapped in `<a href="#epistemic">…</a>`. Hovering it
triggers the existing epistemic-jump-link popup (per WRITING.md:
"Epistemic jump link (`#epistemic`) — Clone of the full epistemic
profile"). Clicking jumps to the epistemic block at the page footer.
This means the figure does double duty: it is a glance-readable
summary at the top, and a clickable handle for the full block at the
bottom. The popup logic is already implemented; this is a free pickup.
---
## 4. New optional frontmatter fields
Two new fields and one new value of an existing field. All optional;
all backward-compatible; existing essays continue to render
identically until the author opts in.
### 4.1 `peer-status`
Captures the *external* review state of a piece, distinct from
`status` (which captures the author's internal position).
| Value | Meaning |
|---|---|
| `unreviewed` | No external review has taken place. Default if omitted. |
| `under-review` | Currently in submission or peer review. |
| `peer-reviewed` | Has been peer-reviewed (e.g. preprint with referee reports addressed) but not yet formally published. |
| `published` | Appeared in a peer-reviewed venue. Treat as canonical. |
| `retracted` | Formally retracted. Renders with a strikethrough on the field name in the epistemic block. |
This is genre-agnostic. An essay can be `under-review` at a magazine;
a paper can be `peer-reviewed` at a journal. The vocabulary doesn't
change.
#### Visual encoding
`peer-status` modulates the *style* of the stability ticks (§3.5),
not their count. Stability and peer-status are factored: stability
remains git-derived and counts ticks; peer-status changes how those
ticks are drawn:
| `peer-status` | Tick style |
|---|---|
| `unreviewed` (default) | Plain solid ticks. |
| `under-review` | Solid ticks with a small unfilled circle (`r=1`) just outside the outermost tick — "in flight" mark. |
| `peer-reviewed` | Solid ticks with a single horizontal bar above the outer roundel arc. |
| `published` | Solid ticks bracketed by two short vertical marks at ±15° on the outer roundel — a printer's bracket. |
| `retracted` | Solid ticks struck through with a horizontal line `stroke-width="1.5"` across the tick group. |
The reading order on the figure thus becomes: outer ring (stability +
peer-status) communicates *external* standing; inner shape
(polygon + trust + result-shape) communicates *internal* claim.
Cleanly factored.
#### Compact-row rendering
In addition to modulating the figure, `peer-status` adds a compact
chip to the existing epistemic-block primary row, alongside `status`
and the trust chip:
88% trust · Durable · under review · 80% confidence · ●●●○○ importance · …
Rendered for any non-`unreviewed` value. The label uses the
hyphen-stripped form (`under review`, `peer-reviewed`, `published`,
`retracted`).
### 4.2 `result-shape`
Captures the *shape* of the piece's central claim. This is missing
from the current vocabulary and surfaces information that's currently
buried in the abstract.
| Value | Meaning | Center glyph |
|---|---|---|
| `positive` | Argues for or proves something works. | `+` |
| `negative` | Argues against or proves a barrier. | `` |
| `mixed` | Both positive and negative results coexist (e.g. *Branch-Based Local Capture*'s "double pincer"). | `±` |
| `comparative` | Compares two or more approaches. | `` |
| `descriptive` | Describes a system, observation, or position without arguing for or against. | `□` |
The glyph appears immediately to the right of the trust score, in
Spectral, font-size 16, vertically centered on the trust number. When
omitted, no glyph is drawn (the trust number sits alone).
#### Compact-row rendering
`result-shape` adds nothing to the compact row. The character is small
enough that the figure carries it without competing with the chip
sequence. If omitted, the figure simply renders the trust number alone.
### 4.3 `confidence: proved` (and `proven`)
Formal mathematical results don't have credences in the same sense
that essays do. A theorem with a complete proof has confidence
~100 modulo soundness, but writing `confidence: 95` invites a
false-precision reading. The colophon's commitment to honest
epistemic accounting requires a way to opt out.
The exception:
confidence: proved
(or the equivalent `proven` — both forms accepted) does three things:
1. The trust score is computed as `100 × 0.6 + ((evidence-1)/4) × 100 × 0.4`,
i.e. as if `confidence` were 100. Evidence still varies; trust is
not pinned to 100.
2. The confidence axis on the figure is drawn full-length, with a
small distinct cap at the vertex: a 3×3-px filled square (instead
of the usual 2-px circle). The square is the visual marker that
reads "this is not a credence, it is a proof-completeness flag."
3. The compact row renders `proved confidence` instead of the
`XX% confidence` form.
`confidence-history` is incompatible with `confidence: proved`. If
both are set, the build emits a warning and `confidence-history` is
ignored (a proof either is or is not; tracking history of a
binary-after-the-fact value is incoherent).
This is the *only* genre-specific carve-out in the schema. All other
fields read across genres without modification, with the colophon
gloss in §6 explaining the cross-genre interpretation.
### 4.4 `subtitle`
Captures a short secondary title shown below the main `title` in the
center column of the new three-column header (§7.2). The field is
optional and free-form.
subtitle: "A Static Site Framework"
When omitted, no subtitle line is rendered and the byline collapses
upward against the title. The subtitle is *not* an abstract; abstracts
remain in the existing `abstract:` field and render below the byline.
Subtitles do not feed the epistemic figure or any audit metric; they
are purely a presentation field. They render in print and do not
participate in any focus-mode hiding.
---
## 5. Frontmatter layout
The combined frontmatter for an essay using all features:
---
title: "The Title"
subtitle: "An Optional Secondary Line"
date: 2026-05-07
abstract: >
One-paragraph description.
tags:
- research/mathematics
# existing epistemic
status: "Durable"
confidence: 80
importance: 3
evidence: 5
scope: average
novelty: moderate
practicality: moderate
confidence-history: [60, 70, 80]
# new
peer-status: under-review
result-shape: mixed
---
For a formal-mathematics piece using `confidence: proved`:
---
title: "Branch-Based Local Capture in Tree-Ball Geometry"
status: "Durable"
confidence: proved
importance: 3
evidence: 5
scope: average
novelty: idiosyncratic
practicality: low
peer-status: under-review
result-shape: mixed
---
The monogram lives outside frontmatter, in
`content/essays/branch-based-local-capture-in-tree-balls/mark.svg`.
---
## 6. Colophon gloss
The colophon's *Living Documents* section is updated to add a
paragraph documenting genre-specific reading of the existing
fields. Proposed text (to be inserted before the field list):
> The epistemic vocabulary above is genre-general but reads
> differently across genres. For a personal essay, `confidence`
> reflects credence in a thesis — "I might change my mind." For an
> empirical research paper, it reflects expected generalization —
> "this would replicate." For formal mathematics, it reflects
> credence in proof correctness, with a special value `proved`
> available for theorems with complete proofs (where any numeric
> value would be false precision). `evidence` reads analogously: the
> strength of arguments and supporting writing in essays, the
> empirical base in research, the structure of the proof in
> mathematics. The fields are the same; the interpretive frame
> shifts with the work.
Two new field rows are appended to the existing field list:
> **Peer status** — the external review state, distinct from
> `status` (which is the author's internal position). Values:
> *unreviewed* (default), *under review*, *peer reviewed*,
> *published*, *retracted*. This information modulates the outer
> ring of the epistemic figure; a *retracted* piece is also rendered
> with the field name struck through.
>
> **Result shape** — the shape of the central claim: *positive*
> (argues something works), *negative* (argues something does not),
> *mixed* (both, as in a double-pincer barrier paper), *comparative*
> (compares approaches), or *descriptive* (describes without arguing
> for or against). Encoded as a small glyph beside the trust score
> on the epistemic figure.
---
## 7. Pandoc filter and template integration
### 7.1 New Haskell module
A new module `build/Filters/Mark.hs` exports two functions:
-- | Render the monogram inline. Reads from disk; substitutes
-- black/#000000 fills and strokes with currentColor; strips
-- width/height attributes from <svg>; wraps in <figure>.
-- Returns an empty document fragment if the file is absent.
renderMonogram :: FilePath -> Compiler Html
-- | Build the epistemic figure SVG from a Context.
-- Reads exactly the fields listed in §3.2.
-- Returns Nothing when `status` is absent.
renderEpistemicFigure :: EpistemicFields -> Maybe Html
`EpistemicFields` is a small record type that reuses the parsing
logic already in `Contexts.hs` for the existing block. The figure
generator is a pure function from this record to SVG markup.
The filter is wired into `build/Compilers.hs` as the last step of
the AST transformation, so it runs after the existing image,
sidenote, and citation passes. It produces no AST mutation; instead,
the rendered SVG is added to the page Context as two new fields:
monogramSvg -- inline SVG or empty
epistemicSvg -- inline SVG or empty
These are referenced in templates as `$monogramSvg$` and
`$epistemicSvg$`.
### 7.2 Template change
The current `templates/partials/metadata.html` carries everything
between the title and the cursive-L divider: tags, keywords, abstract,
byline, affiliation, the compact epistemic strip, and the page-nav
links. To make room for the three-column header without losing the
ordering or the divider, the partial is split in two:
- `templates/partials/metadata-header.html` — byline, abstract, and
the compact epistemic strip. Renders inside the center column of
the new header.
- `templates/partials/metadata-tail.html` — tags, keywords,
affiliation, page-nav, in that exact order (matching the current
`metadata.html` rendering order). Renders as a row beneath the
three-column header, above the cursive-L divider.
`templates/essay.html` and `templates/blog-post.html` then become:
<header class="essay-frontmatter">
<div class="frontmatter-mark frontmatter-mark--monogram">$monogramSvg$</div>
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$partial("templates/partials/metadata-header.html")$
</div>
<div class="frontmatter-mark frontmatter-mark--epistemic">
<a href="#epistemic" aria-label="Jump to epistemic profile">$epistemicSvg$</a>
</div>
</header>
$partial("templates/partials/metadata-tail.html")$
<div class="content-divider" aria-hidden="true">
<a href="/new.html" class="content-divider-logo" aria-label="New"></a>
</div>
The cursive-L `content-divider-logo` is preserved exactly as it is
today; nothing about the frontmatter↔body separator changes. Only the
material *above* the divider is reorganized.
When either SVG is empty, the corresponding column collapses (CSS
grid, `auto` sizing). When both are absent, the header degrades to a
single-column layout that visually matches the existing one
(title + subtitle + metadata-header), so existing pages render
identically until they opt in.
`reading.html` (poetry/fiction) does NOT receive the figure column,
since these content types omit the epistemic block by design. They
do receive the monogram column when a `mark.svg` is present, plus the
`subtitle` field if set.
`pageCtx` (standalone pages) receives neither column but does honor
`subtitle` if set.
### 7.3 CSS
A new file `static/css/marks.css` defines the grid layout, the
collapse behavior, the print rules, the focus-mode hiding, and the
two `.frontmatter-mark` modifiers. It loads with the rest of the
stylesheet bundle; no new HTTP request.
The breakpoint at which the figure column drops below the title
(rather than sitting beside it) is the existing narrow-screen
breakpoint where sidenotes collapse to footnotes. A reader on
mobile sees: monogram → title → figure, stacked.
---
## 8. Build behavior
### 8.1 Determinism
Both monograms and epistemic figures must be deterministic at build
time. The monogram is just a file read; the epistemic figure is a
pure function of frontmatter and `git log --follow`. Two consecutive
builds of the same content tree must produce byte-identical SVGs.
This is enforced by:
- No timestamps in generated SVGs.
- No floating-point coordinates beyond two decimal places.
- Stable ordering of attributes (alphabetical) and elements
(declaration order).
- No build-time UUIDs or random IDs (use deterministic IDs derived
from slug, e.g. `id="mark-title-{slug}"`).
This matters for the GPG signing pipeline (`make sign`): a
non-deterministic SVG would invalidate page signatures across
otherwise-identical builds.
### 8.2 Performance
Reading 200 small SVG files at build is negligible. Rendering 200
epistemic figures is a few hundred lines of string concatenation each
and well within the existing build budget. No new build step is
needed; the work happens inside `Compilers.hs` alongside existing
filter passes.
The Hakyll dependency tracking already keys on frontmatter changes
via the existing essay context. Adding `monogramSvg` and
`epistemicSvg` to the same context propagates dependency tracking
for free: editing a frontmatter field invalidates the page; replacing
a `mark.svg` invalidates only that page's dependencies (Hakyll's
file-watch already tracks `content/**`).
### 8.3 Failure modes
| Condition | Build behavior |
|---|---|
| `mark.svg` absent | Monogram column collapses; no warning. |
| `mark.svg` malformed XML | Warn; render the slot empty; do not fail the build. |
| `mark.svg` exceeds 8 KiB | Warn; render anyway. |
| `mark.svg` violates §2.2 contract | Warn (with specific violation); render anyway. |
| `status:` absent | Epistemic column collapses; no warning. |
| `status:` set, `confidence` missing | Render figure; confidence axis has no vertex point. |
| `peer-status:` invalid value | Warn; treat as `unreviewed`. |
| `result-shape:` invalid value | Warn; render figure without center glyph. |
| `confidence: proved` and `confidence-history:` both set | Warn; ignore `confidence-history`. |
Warnings go to stderr during `make build`. They are captured and
surfaced on `/build/` (§9).
### 8.4 Backwards compatibility
Every existing essay must render identically after this change is
deployed, until and unless the author edits the file to add a
`mark.svg` or new frontmatter fields. The new template grid must
collapse to the existing single-column layout when both
`$monogramSvg$` and `$epistemicSvg$` are empty; CSS feature-tests
for grid fallback are not needed because the existing template uses
flexbox/block already.
A pre-merge regression test runs `make build` on a snapshot of
`content/` from before the change and diffs `_site/` against a
known-good snapshot. The only allowed diffs are template-driven
whitespace.
---
## 9. Audit and telemetry
### 9.1 `make audit-marks`
A new build target lists pieces missing one or both marks. Output
columns: path, has-monogram?, has-epistemic-figure?, suggested
action.
$ make audit-marks
content/essays/ozymandias.md ✓ ✓
content/essays/branch-based-...md ✗ ✗ add mark.svg, set status:
content/essays/beyond-comorbidity-... ✗ ✓ add mark.svg
...
Implementation: `tools/audit-marks.py` walks `content/**/*.md`,
parses YAML frontmatter, checks for the corresponding `mark.svg`,
checks whether `status:` is set, and emits the table.
The script also emits two summary metrics: corpus monogram coverage
percentage and corpus epistemic-figure coverage percentage.
### 9.2 `/build/` integration
The existing build telemetry page already includes "epistemic
coverage" per the WRITING.md auto-generated-pages list. Two new
sub-sections are added to that page:
- **Monogram coverage**: count and percentage of essays/blog/poetry/
fiction/music with `mark.svg` present, broken down by portal.
- **Epistemic-figure coverage**: count and percentage of pieces
with `status:` set and a renderable figure, broken down by portal.
The same Stats.hs module that produces existing coverage figures
extends to compute these. No new external dependencies.
### 9.3 Linting hook
A pre-commit hook (`tools/hooks/pre-commit-marks.sh`) runs
`make audit-marks` and warns on any new `.md` file under
`content/essays/` or `content/research/` (effectively, anything
tagged `research/*` or in those directories) added without a
`mark.svg` or with `status:` unset. Warning only; does not block
the commit. Authors who genuinely want to publish without marks
can ignore the warning.
---
## 10. Migration
### Phase 1 — Wire the system, no content (1 build)
- Land `Filters/Mark.hs`, the template changes, and `static/css/marks.css`.
- Land `tools/audit-marks.py`.
- Land the two new schema fields (`peer-status`, `result-shape`)
and the `confidence: proved` exception in `Contexts.hs` and
`Stability.hs`.
- Update `WRITING.md` with the new fields and the `mark.svg`
convention.
- Update the colophon with the §6 gloss.
- Build. Every existing page renders identically (§8.4).
### Phase 2 — Reference monograms (1 week of evenings)
- Author monograms for the 810 most-trafficked pieces (likely:
*Colophon*, *Memento Mori*, *Ozymandias*, *Beyond Comorbidity Indices*,
*Branch-Based Local Capture*, the *Music* index, the *Library* portal
landing, and a poetry collection landing).
- Author the reference monogram template at
`static/templates/mark-template.svg`.
- Validate them against §2.2 with `make audit-marks`.
### Phase 3 — Backfill epistemic fields (incremental)
- For each piece in `research/` and `nonfiction/`, decide whether to
add `status:`, `peer-status:`, `result-shape:`. The audit script
surfaces the candidates.
- Specifically: *Branch-Based Local Capture* gets `status: Durable`,
`confidence: proved`, `evidence: 5`, `peer-status: unreviewed`,
`result-shape: mixed`. *Beyond Comorbidity Indices* gets
`peer-status: under-review` and `result-shape: comparative`
added.
### Phase 4 — Iterate
- Once 30+ marks exist, review the corpus as a system. Tighten
§2.2 constraints if cross-mark consistency is weaker than expected.
Loosen if the constraints are pinching authorship.
- Decide whether portal-level base monograms (the
Approach 5 idea) are worth adding as a third tier.
---
## 11. Rejected alternatives
These were considered and not adopted; recording them so future
revisions don't relitigate.
- **Two figure types (essay vs. research badge).** The existing
fields handle both genres when read with appropriate gloss
(§6). Two figures would create a visual fork that costs more
than it pays. *Beyond Comorbidity Indices* is the proof point:
formal research already renders cleanly with the existing
vocabulary.
- **Mode-switched figure with axes that change meaning per genre.**
Visual grammar should be unambiguous. A single radial figure
where the axes mean different things depending on a frontmatter
flag would require footnotes to read. Genre-gloss in the colophon
handles the same need without ambiguity.
- **Auto-derived monograms from semantic-search embeddings.** Tried
in spec drafting. The result is generic and lacks the editorial
statement that a hand-authored monogram makes. Authors may use
AI-assist tools to generate monograms (against §2.2 contract),
but the system does not derive them automatically.
- **Ghosted axes for missing fields.** Tested visually. Reads as a
bug, not a deliberate position. Better to suppress the figure
entirely (§3.1) and surface the absence in `/build/` (§9).
- **A separate `claim-mode: formal | empirical | essay` field.**
Solved the wrong problem. The fields don't need a mode flag; they
need a gloss. The two new fields (`peer-status`, `result-shape`)
plus the `confidence: proved` exception cover the genre-specific
needs surfaced in audit.
- **Folding `peer-status` into `status`.** Tempting but wrong.
`status` is the author's position ("I expect this to hold up").
`peer-status` is the world's position ("the field has confirmed
it"). A piece can be `Durable` and `unreviewed` simultaneously
(the author believes it; the world hasn't checked yet). Keeping
them factored preserves that distinction.
---
## 12. Open questions for review
1. **Monogram filename convention.** Spec proposes `mark.svg` (in
directory-form) and `{slug}.mark.svg` (flat-form). Alternative:
always require directory-form for any piece that wants a
monogram, simplifying the resolver. Cost: forces directory-form
migration on currently-flat essays. Recommend keeping both
forms; the resolver is small and the migration cost is real.
2. **Should `peer-status: retracted` survive `make build`?**
Currently spec'd as a normal field with a strikethrough.
Alternative: `make build` refuses to publish pieces marked
`retracted` and instead generates a tombstone page at the
original URL. Probably overkill for the personal-site context;
leaving as a normal field with visual indicator. Worth flagging.
3. **Should the figure's confidence trend arrow distinguish
"stable" (∆ ≤ 2) from "unchanged" (∆ = 0)?** Currently treats
them the same as `→`. The existing trend arrow in the epistemic
block does too. No need to diverge.
4. **Per-portal monogram defaults.** Should an absent `mark.svg`
fall back to a portal-level base monogram (e.g. all `research/`
pieces show a default research mark)? Spec says no — absence
is meaningful and surfaces in `/build/`. The visual specimen
sheets in the earlier exploration suggested portal-level
iconography is interesting; defer to a future spec.
5. **Naming.** The pair of glyphs is currently called "monogram"
and "epistemic figure." Considered alternatives: "device"
(printer's-mark lineage) and "figure" (Tufte lineage), or
"mark" and "badge" (more colloquial). Spec uses
"monogram + epistemic figure" because it most accurately
describes what each thing *is*. Open to naming bikeshed.
---
## 13. Files touched
A complete list of files this spec creates or modifies, for tracking
PR scope:
**New:**
- `build/Filters/Mark.hs`
- `tools/audit-marks.py`
- `tools/hooks/pre-commit-marks.sh`
- `static/css/marks.css`
- `static/templates/mark-template.svg`
- `static/templates/epistemic-figure-reference.svg`
- `MARKS.md` (this file, after merge)
**Modified:**
- `build/Compilers.hs` (expose new context fields where essay /
blog / reading / page contexts are assembled)
- `build/Contexts.hs` (parse `subtitle`, `peer-status`,
`result-shape`, `confidence: proved`; produce `monogramSvg` and
`epistemicSvg` context fields; render the inline trend arrow
inside the compact-row `confidence` chip)
- `build/Stability.hs` (consume `peer-status` for tick styling
if rendering moves out of pure SVG generator)
- `build/Stats.hs` (monogram + epistemic-figure coverage on
`/build/`)
- `templates/essay.html`
- `templates/blog-post.html`
- `templates/reading.html` (monogram column + `subtitle`; no figure)
- `templates/partials/metadata.html` (split into the two new
partials below; this file becomes a thin shim or is removed)
- `templates/partials/metadata-header.html` (new — center-column
metadata: byline, abstract, compact epistemic strip)
- `templates/partials/metadata-tail.html` (new — row beneath the
three-column header: tags, keywords, affiliation, page-nav, in
that order)
- `Makefile` (`audit-marks` target)
- `WRITING.md` (new fields including `subtitle`; monogram convention)
- `content/colophon.md` (genre gloss)
**Per-essay (Phase 2+):**
- `content/essays/{slug}/mark.svg` × N (hand-authored monograms)
- frontmatter edits to add `peer-status:`, `result-shape:`,
`confidence: proved` where applicable.
---
## 14. Future work (out of scope for the initial rollout)
These extensions are explicitly deferred. They are recorded here so
that the structural decisions in §§29 do not foreclose them.
- **Monogram in hyperlink popup previews.** The existing on-hover
page-preview popup (which already renders title and abstract for
internal links) should display the monogram alongside the title
when one exists. The popup is the smallest place a reader meets a
page; the monogram earns its keep there.
- **Monogram in `/library/` and `/new/` feed listings.** Both the
library portal and the recent-changes feed render lists of pages.
Once monogram coverage is non-trivial (Phase 2 ships ≥10), each
list item should render the monogram as a small inline glyph
beside the title.
- **Portal-level base monograms.** Deferred per §12.4, but the
correct natural place to introduce them is once the popup and
feed-listing wiring is in place — base monograms compensate for
list rows where the per-page monogram is absent.
These items are scoped as a follow-up PR (informally "PR 4") after
the audit-tool PR ships and after at least 10 hand-authored monograms
exist to test the popup/listing rendering against real content.

View File

@ -61,6 +61,7 @@ custom template variables.
```yaml
---
title: "The Title of the Essay"
subtitle: "An Optional Secondary Line" # optional; rendered below the title in the frontmatter header
date: 2026-03-15 # required; used for ordering, feed, and display
abstract: > # optional; shown in the metadata block and link previews
A one-paragraph description of the piece.
@ -87,7 +88,7 @@ js: scripts/my-widget.js # optional; per-page JS file (see Page scripts)
# Epistemic profile — all optional; the entire section is hidden unless `status` is set
status: "Working model" # Draft | Working model | Durable | Refined | Superseded | Deprecated
confidence: 72 # 0100 integer (%)
confidence: 72 # 0100 integer (%); also accepts the sentinel `proved` / `proven` for formal mathematical results
importance: 3 # 15 integer (rendered as filled/empty dots ●●●○○)
evidence: 2 # 15 integer (same)
scope: average # personal | local | average | broad | civilizational
@ -97,6 +98,8 @@ confidence-history: # list of integers; trend arrow derived from last two
- 55
- 63
- 72
peer-status: under-review # optional; unreviewed (default) | under-review | peer-reviewed | published | retracted
result-shape: mixed # optional; positive | negative | mixed | comparative | descriptive
# Version history — optional; falls back to git log, then to date frontmatter
history:
@ -809,6 +812,84 @@ commits or age <730 days → *stable*; otherwise → *established*.
To pin a manual stability label for one build, add the file path to `IGNORE.txt`
(one path per line). The file is cleared automatically by `make build`.
### `peer-status`, `result-shape`, and `confidence: proved`
Three optional extensions to the epistemic vocabulary, introduced
alongside the epistemic figure (`MARKS.md` for the full spec).
**`peer-status`** — the *external* review state, factored out of `status`
(which captures the author's internal position). Values:
| Value | Meaning |
|-------|---------|
| `unreviewed` | Default. No external review has taken place. |
| `under-review` | In submission or peer review. |
| `peer-reviewed` | Reviewed but not yet formally published. |
| `published` | Appeared in a peer-reviewed venue. |
| `retracted` | Formally retracted; renders with a strikethrough. |
A non-`unreviewed` value adds a chip (`under review`, `peer-reviewed`, …)
to the compact epistemic strip and modulates the outer-ring tick *style*
on the epistemic figure.
**`result-shape`** — the shape of the central claim:
| Value | Meaning |
|-------|---------|
| `positive` | Argues for / proves something works. |
| `negative` | Argues against / proves a barrier. |
| `mixed` | Both, e.g. a double-pincer barrier paper. |
| `comparative` | Compares two or more approaches. |
| `descriptive` | Describes without arguing for or against. |
Renders as a small glyph (`+`, ``, `±`, ``, `□`) beside the trust score
on the epistemic figure. Adds nothing to the compact row.
**`confidence: proved`** (or `proven`) — the formal-proof carve-out. A
theorem with a complete proof has confidence ≈ 100 modulo soundness, and
writing `confidence: 95` invites a false-precision reading. The sentinel:
* Substitutes 100 in the trust-score formula (evidence still varies, so
a complete proof with thin supporting apparatus does not pin trust to
100).
* Renders the compact-row chip as `proved confidence` instead of
`XX% confidence`.
* Draws the confidence axis on the epistemic figure full-length, with a
3×3 px filled square at the vertex (the "proof cap" marker) instead
of the usual 2 px circle.
* Suppresses the inline trend arrow. Setting both
`confidence: proved` and `confidence-history` emits a build warning
and the history is ignored.
This is the only genre-specific carve-out in the schema. All other
fields read across genres without modification.
### Frontmatter marks (monogram + epistemic figure)
Each piece may carry a hand-authored monogram — a small SVG glyph
abstracted from its central concept — that renders in the left column of
the frontmatter header. Drop a `mark.svg` next to the source file; the
build picks it up automatically. No frontmatter key is required to opt
in.
| Source path | Mark path |
|-------------|-----------|
| `content/essays/foo.md` | `content/essays/foo.mark.svg` |
| `content/essays/foo/index.md` | `content/essays/foo/mark.svg` |
| `content/blog/post.md` | `content/blog/post.mark.svg` |
| `content/{slug}.md` | `content/{slug}.mark.svg` |
The right column of the header carries the **epistemic figure** — a
build-time SVG generated deterministically from the existing epistemic
fields. It renders only when `status:` is set, matching the existing
visibility rule for the epistemic block. See `MARKS.md` for the full
specification (visual contract, accessibility, geometry).
A reference monogram template lives at
`static/templates/mark-template.svg`. Authors hand-rolling a monogram
should copy and adapt it; the file documents the §2.2 visual contract
(currentColor strokes, no embedded text, ≤ 8 KiB, etc.).
---
## Version history

View File

@ -29,6 +29,7 @@ import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as AK
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Vector as V
import Data.Char (toLower)
import Data.List (intercalate, isPrefixOf, sortBy)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Ord (comparing)
@ -38,6 +39,7 @@ import Data.Time.Clock (UTCTime, getCurrentTime, utctDay)
import Data.Time.Format (formatTime, defaultTimeLocale, parseTimeM)
import System.Directory (doesFileExist)
import System.FilePath (takeDirectory, takeFileName, (</>))
import System.IO (hPutStrLn, stderr)
import Text.Read (readMaybe)
import qualified Data.Text as T
import qualified Data.Yaml as Y
@ -46,6 +48,7 @@ import Text.Pandoc.Options (WriterOptions(..), HTMLMathMethod(..))
import Hakyll hiding (trim)
import Backlinks (backlinksField)
import Dingbat (dingbatField)
import Marks (monogramSvgField, epistemicSvgField)
import SimilarLinks (similarLinksField)
import Stability (stabilityField, lastReviewedField, lastReviewedIsoField,
versionHistoryField,
@ -54,6 +57,13 @@ import Stability (stabilityField, lastReviewedField, lastReviewedIsoField,
versionHistoryRangeEndField, versionHistoryCommitsField)
import Utils (authorSlugify, authorNameOf, trim)
-- | Returns 'True' when the @confidence:@ frontmatter value is the
-- "proved" / "proven" sentinel — the §4.3 carve-out for formal proofs
-- that opt out of a numeric credence. Case-insensitive.
isProvedConfidence :: Maybe String -> Bool
isProvedConfidence (Just s) = map toLower (trim s) `elem` ["proved", "proven"]
isProvedConfidence _ = False
-- ---------------------------------------------------------------------------
-- Affiliation field
-- ---------------------------------------------------------------------------
@ -426,6 +436,7 @@ siteCtx =
<> descriptionField
<> summaryField
<> dingbatField
<> monogramSvgField
<> defaultContext
-- ---------------------------------------------------------------------------
@ -485,22 +496,36 @@ dotsField ctxKey metaKey = field ctxKey $ \item -> do
--
-- The arrow flips when the absolute change crosses 'trendThreshold'
-- (currently 5 percentage points). Smaller swings count as flat.
--
-- When @confidence: proved@ (or @proven@) is in effect, the arrow is
-- suppressed: a proof either holds or it does not, so tracking trend on
-- a binary-after-the-fact value is incoherent (MARKS.md §4.3). If the
-- frontmatter sets both @confidence: proved@ and @confidence-history:@
-- the build emits a warning and the history is ignored.
confidenceTrendField :: Context String
confidenceTrendField = field "confidence-trend" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupStringList "confidence-history" meta of
Nothing -> fail "no confidence history"
Just xs -> case lastTwo xs of
Nothing -> fail "no confidence history"
Just (prevS, curS) ->
let prev = readMaybe prevS :: Maybe Int
cur = readMaybe curS :: Maybe Int
in case (prev, cur) of
(Just p, Just c)
| c - p > trendThreshold -> return "\x2191" -- ↑
| p - c > trendThreshold -> return "\x2193" -- ↓
| otherwise -> return "\x2192" -- →
_ -> return "\x2192"
if isProvedConfidence (lookupString "confidence" meta)
then do
case lookupStringList "confidence-history" meta of
Just _ -> unsafeCompiler $ hPutStrLn stderr $
"[Marks] " ++ toFilePath (itemIdentifier item) ++
": confidence: proved is incompatible with confidence-history; ignoring history"
Nothing -> return ()
fail "confidence is proved; trend suppressed"
else case lookupStringList "confidence-history" meta of
Nothing -> fail "no confidence history"
Just xs -> case lastTwo xs of
Nothing -> fail "no confidence history"
Just (prevS, curS) ->
let prev = readMaybe prevS :: Maybe Int
cur = readMaybe curS :: Maybe Int
in case (prev, cur) of
(Just p, Just c)
| c - p > trendThreshold -> return "\x2191" -- ↑
| p - c > trendThreshold -> return "\x2193" -- ↓
| otherwise -> return "\x2192" -- →
_ -> return "\x2192"
where
trendThreshold :: Int
trendThreshold = 5
@ -534,11 +559,21 @@ confidenceTrendField = field "confidence-trend" $ \item -> do
--
-- Formula: raw = conf/100 · 0.6 + (ev 1)/4 · 0.4 (01)
-- score = clamp₀₋₁₀₀(round(raw · 100))
--
-- The @confidence: proved@ (or @proven@) sentinel — see MARKS.md §4.3 —
-- substitutes @conf = 100@ in the formula. Evidence still varies, so
-- trust is not pinned to 100; a complete proof with weak supporting
-- apparatus (evidence=1) lands at 60, the same as a numeric
-- confidence=100, evidence=1 entry would.
overallScoreField :: Context String
overallScoreField = field "overall-score" $ \item -> do
meta <- getMetadata (itemIdentifier item)
let readInt s = readMaybe s :: Maybe Int
case ( readInt =<< lookupString "confidence" meta
confRaw = lookupString "confidence" meta
confInt = if isProvedConfidence confRaw
then Just 100
else readInt =<< confRaw
case ( confInt
, readInt =<< lookupString "evidence" meta
) of
(Just conf, Just ev) ->
@ -549,16 +584,106 @@ overallScoreField = field "overall-score" $ \item -> do
in return (show score)
_ -> fail "overall-score: confidence or evidence not set"
-- | @$confidence$@: numeric override that suppresses the @proved@ /
-- @proven@ sentinel. When the frontmatter value is parseable as an
-- integer this returns its 'show' form; otherwise 'noResult' so the
-- template's @$if(confidence)$@ guard collapses cleanly. The sentinel
-- case is surfaced via 'confidenceProvedField' instead.
--
-- Composed before 'defaultContext' so this override wins; without it
-- @$confidence$@ would render the literal string @"proved"@ and the
-- template's @$confidence$% confidence@ would print @"proved%
-- confidence"@.
confidenceField :: Context String
confidenceField = field "confidence" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "confidence" meta of
Nothing -> noResult "no confidence"
Just s -> case readMaybe (trim s) :: Maybe Int of
Just n -> return (show n)
Nothing -> noResult "confidence not numeric"
-- | @$confidence-proved$@: present (renders as @"true"@) when
-- @confidence:@ is the @proved@ / @proven@ sentinel; 'noResult'
-- otherwise. Templates branch on this to render @"proved confidence"@
-- in place of the @"XX% confidence"@ chip.
confidenceProvedField :: Context String
confidenceProvedField = field "confidence-proved" $ \item -> do
meta <- getMetadata (itemIdentifier item)
if isProvedConfidence (lookupString "confidence" meta)
then return "true"
else noResult "confidence is not proved"
-- | @$peer-status$@: validated raw value (slug form) from the
-- @peer-status:@ frontmatter. Used by the template as a class-attribute
-- modifier (@ep-peer-status--retracted@ etc.). Invalid values warn and
-- degrade to 'noResult', so a typo doesn't render an unstyled chip.
-- Absent and @unreviewed@ both produce 'noResult' — the chip is the
-- exception, not the default.
peerStatusField :: Context String
peerStatusField = field "peer-status" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "peer-status" meta of
Nothing -> noResult "no peer-status"
Just raw ->
let s = map toLower (trim raw)
in if s `elem` knownPeerStatuses
then if s == "unreviewed"
then noResult "peer-status is unreviewed (default)"
else return s
else do
unsafeCompiler $ hPutStrLn stderr $
"[Marks] " ++ toFilePath (itemIdentifier item) ++
": invalid peer-status value \"" ++ raw ++
"\"; treating as unreviewed"
noResult "invalid peer-status"
where
knownPeerStatuses = ["unreviewed", "under-review", "peer-reviewed",
"published", "retracted"]
-- | @$peer-status-display$@: human-readable form of the @peer-status@
-- value, suitable for the compact-row chip text. Per MARKS.md §4.1 the
-- display strings are:
--
-- * @under-review@ → @"under review"@ (hyphen → space)
-- * @peer-reviewed@ → @"peer-reviewed"@ (kept as-is)
-- * @published@ → @"published"@
-- * @retracted@ → @"retracted"@
--
-- 'noResult' for absent, invalid, or @unreviewed@ values, mirroring
-- 'peerStatusField'.
peerStatusDisplayField :: Context String
peerStatusDisplayField = field "peer-status-display" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "peer-status" meta of
Nothing -> noResult "no peer-status"
Just raw ->
case lookup (map toLower (trim raw)) displayMap of
Just disp -> return disp
Nothing -> noResult "no display form (absent / unreviewed / invalid)"
where
displayMap =
[ ("under-review", "under review")
, ("peer-reviewed", "peer-reviewed")
, ("published", "published")
, ("retracted", "retracted")
]
-- | All epistemic context fields composed.
epistemicCtx :: Context String
epistemicCtx =
dotsField "importance-dots" "importance"
<> dotsField "evidence-dots" "evidence"
<> confidenceField
<> confidenceProvedField
<> peerStatusField
<> peerStatusDisplayField
<> overallScoreField
<> confidenceTrendField
<> stabilityField
<> lastReviewedField
<> lastReviewedIsoField
<> epistemicSvgField
-- ---------------------------------------------------------------------------
-- Essay context
@ -742,6 +867,23 @@ postCtx =
<> dateField "date" "%-d %B %Y"
<> dateField "date-iso" "%Y-%m-%d"
<> constField "math" "true"
-- Blog posts can opt in to the epistemic figure / chips by setting
-- the relevant frontmatter fields. The Marks module's epistemic SVG
-- field returns 'noResult' when @status:@ is absent, so unstatused
-- posts render unchanged. The dot / strip fields below mirror the
-- essay context so a status-bearing post gets the same chips.
<> dotsField "importance-dots" "importance"
<> dotsField "evidence-dots" "evidence"
<> confidenceField
<> confidenceProvedField
<> peerStatusField
<> peerStatusDisplayField
<> overallScoreField
<> confidenceTrendField
<> stabilityField
<> lastReviewedField
<> lastReviewedIsoField
<> epistemicSvgField
<> siteCtx
-- ---------------------------------------------------------------------------

562
build/Marks.hs Normal file
View File

@ -0,0 +1,562 @@
{-# LANGUAGE GHC2021 #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Frontmatter marks: the monogram (a hand-authored SVG per piece) and
-- the epistemic figure (a build-time SVG generated from frontmatter).
-- See MARKS.md for the full specification.
--
-- Two Hakyll context fields are exported:
--
-- * @$monogramSvg$@ — the inlined monogram for the current item, or
-- 'noResult' when no co-located @mark.svg@ exists.
-- * @$epistemicSvg$@ — the generated epistemic figure, or 'noResult'
-- when the item has no @status:@ frontmatter
-- (MARKS.md §3.1).
--
-- Both fields are deterministic: byte-identical inputs produce
-- byte-identical SVGs, so the GPG signing pipeline is undisturbed.
module Marks
( monogramSvgField
, epistemicSvgField
) where
import Control.Exception (IOException, try)
import Data.Char (toLower)
import Data.Maybe (catMaybes, isJust)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Numeric (showFFloat)
import System.Directory (doesFileExist)
import System.FilePath (takeBaseName, takeDirectory,
takeFileName, (</>))
import System.IO (hPutStrLn, stderr)
import Text.Read (readMaybe)
import Hakyll
import Stability (resolveStability)
-- ---------------------------------------------------------------------------
-- Monogram path resolution
-- ---------------------------------------------------------------------------
-- | Candidate monogram paths for a given source path. The build picks the
-- first one that exists on disk. This dual-form resolver matches the
-- site's mixed flat / directory essay convention:
--
-- > content/essays/foo.md → content/essays/foo.mark.svg
-- > content/essays/foo/index.md → content/essays/foo/mark.svg
monogramCandidates :: FilePath -> [FilePath]
monogramCandidates fp =
let dir = takeDirectory fp
fname = takeFileName fp
in if fname == "index.md"
then [dir </> "mark.svg"]
else [dir </> takeBaseName fp ++ ".mark.svg"]
-- | Return the first candidate path that exists on disk, or 'Nothing'.
resolveMonogramPath :: Item a -> Compiler (Maybe FilePath)
resolveMonogramPath item =
unsafeCompiler $ firstExisting (monogramCandidates fp)
where
fp = toFilePath (itemIdentifier item)
firstExisting [] = return Nothing
firstExisting (p:ps) = do
e <- doesFileExist p
if e then return (Just p) else firstExisting ps
-- ---------------------------------------------------------------------------
-- Monogram inlining
-- ---------------------------------------------------------------------------
-- | @$monogramSvg$@. Reads the resolved @mark.svg@, normalizes black
-- fills/strokes to @currentColor@ (defensive — authors using AI-assist
-- tools may produce hardcoded blacks; the contract still holds), strips
-- the @width@/@height@ presentation attributes from the root @<svg>@,
-- and wraps the result in @<figure class="frontmatter-mark
-- frontmatter-mark--monogram">@. Returns 'noResult' when no candidate
-- exists; warns and returns 'noResult' on read failure.
monogramSvgField :: Context String
monogramSvgField = field "monogramSvg" $ \item -> do
mPath <- resolveMonogramPath item
case mPath of
Nothing -> noResult "no mark.svg"
Just path -> do
result <- unsafeCompiler $ try (TIO.readFile path)
:: Compiler (Either IOException T.Text)
case result of
Left e -> do
unsafeCompiler $ hPutStrLn stderr $
"[Marks] " ++ toFilePath (itemIdentifier item) ++
": failed to read " ++ path ++ ": " ++ show e
noResult "monogram read failed"
Right svg -> return $ T.unpack $ wrapMonogram (processSvg svg)
-- | Wrap inlined monogram SVG in its outer figure element.
wrapMonogram :: T.Text -> T.Text
wrapMonogram svg = T.concat
[ "<figure class=\"frontmatter-mark frontmatter-mark--monogram\">"
, svg
, "</figure>"
]
-- | Replace hardcoded black fills/strokes with @currentColor@ and strip
-- the root @<svg>@'s @width@/@height@ attributes (presentation lives
-- in CSS via the @.frontmatter-mark svg@ selector). Mirrors the color
-- substitution in 'Filters.Score.processColors' so the two SVG
-- inliners agree on the contract.
processSvg :: T.Text -> T.Text
processSvg = stripRootDims . normalizeColors
-- | The same chain 'Filters.Score' applies, kept in sync deliberately.
-- 6-digit patterns first so the 3-digit replacement doesn't match
-- the prefix of a 6-digit value.
normalizeColors :: T.Text -> T.Text
normalizeColors
= T.replace "fill=\"#000\"" "fill=\"currentColor\""
. T.replace "fill=\"black\"" "fill=\"currentColor\""
. T.replace "stroke=\"#000\"" "stroke=\"currentColor\""
. T.replace "stroke=\"black\"" "stroke=\"currentColor\""
. T.replace "fill:#000" "fill:currentColor"
. T.replace "fill:black" "fill:currentColor"
. T.replace "stroke:#000" "stroke:currentColor"
. T.replace "stroke:black" "stroke:currentColor"
. T.replace "fill=\"#000000\"" "fill=\"currentColor\""
. T.replace "stroke=\"#000000\"" "stroke=\"currentColor\""
. T.replace "fill:#000000" "fill:currentColor"
. T.replace "stroke:#000000" "stroke:currentColor"
-- | Remove @width="..."@ and @height="..."@ from the root @<svg>@.
-- The substitution is conservative: it walks once and only touches
-- the first occurrence of each attribute (the root tag in a
-- well-formed monogram).
stripRootDims :: T.Text -> T.Text
stripRootDims = stripFirst "width" . stripFirst "height"
where
stripFirst attr txt =
case T.breakOn (T.pack (" " ++ attr ++ "=\"")) txt of
(before, after)
| T.null after -> txt
| otherwise ->
-- Drop ` attr="..."` including its closing quote.
let restAfterEq = T.drop (T.length (T.pack (" " ++ attr ++ "=\""))) after
in case T.breakOn "\"" restAfterEq of
(_, rest) | T.null rest -> txt
| otherwise -> before <> T.drop 1 rest
-- ---------------------------------------------------------------------------
-- Epistemic figure: data extraction
-- ---------------------------------------------------------------------------
-- | Captures the frontmatter inputs the figure consumes. Constructed
-- once per item by 'readEpistemicData', then handed to the pure
-- geometry below. Keeps the I/O step (metadata + git) separate from
-- the SVG-string formatter, so the formatter is testable in isolation
-- without mocking Hakyll.
data EpistemicData = EpistemicData
{ epConfidence :: Maybe Int -- ^ Numeric confidence; 'Nothing' if proved/absent/unparseable.
, epConfidenceProved :: Bool -- ^ True when @confidence: proved@ / @proven@.
, epImportance :: Maybe Int -- ^ 15 ordinal.
, epEvidence :: Maybe Int -- ^ 15 ordinal.
, epScope :: Maybe String -- ^ Validated scope value.
, epNovelty :: Maybe String -- ^ Validated novelty value.
, epPracticality :: Maybe String -- ^ Validated practicality value.
, epPeerStatus :: Maybe String -- ^ Validated peer-status slug ('Nothing' when absent / unreviewed / invalid).
, epResultShape :: Maybe String -- ^ Validated result-shape value.
, epStability :: String -- ^ Always one of the five stability labels.
, epTrust :: Int -- ^ Trust score 0100 (60/40 weighted; @proved@ substitutes 100 for confidence).
}
-- | Read the figure inputs from a Hakyll item's metadata + git history.
readEpistemicData :: Item a -> Compiler EpistemicData
readEpistemicData item = do
meta <- getMetadata (itemIdentifier item)
stab <- resolveStability item
let confRaw = lookupString "confidence" meta
proved = isProvedConfidenceM confRaw
confInt = if proved then Just 100 else readMaybe . trimS =<< confRaw
confNumeric = if proved then Nothing else confInt
importance = readMaybe . trimS =<< lookupString "importance" meta
evidence = readMaybe . trimS =<< lookupString "evidence" meta
scope = validate scopeValues =<< lookupString "scope" meta
novelty = validate noveltyValues =<< lookupString "novelty" meta
practical = validate practicalityValues =<< lookupString "practicality" meta
peer = validatePeerStatus =<< lookupString "peer-status" meta
resultShape = validate resultShapeValues =<< lookupString "result-shape" meta
trust = computeTrust confInt evidence
return EpistemicData
{ epConfidence = confNumeric
, epConfidenceProved = proved
, epImportance = importance
, epEvidence = evidence
, epScope = scope
, epNovelty = novelty
, epPracticality = practical
, epPeerStatus = peer
, epResultShape = resultShape
, epStability = stab
, epTrust = trust
}
where
trimS = trim'
-- | Trust score: the same 60/40 weighted composite of confidence and
-- evidence used by 'Contexts.overallScoreField'. Returns 0 when either
-- input is missing — which is fine for the figure (the polygon and
-- trust label simply collapse to the bare frame).
computeTrust :: Maybe Int -> Maybe Int -> Int
computeTrust (Just c) (Just e) =
let raw :: Double
raw = fromIntegral c / 100.0 * 0.6 + fromIntegral (e - 1) / 4.0 * 0.4
in max 0 (min 100 (round (raw * 100.0)))
computeTrust _ _ = 0
-- | Same predicate as 'Contexts.isProvedConfidence' — local copy to keep
-- the module's dependency graph light (Marks → Stability only). The
-- two are tested against the same vocabulary; if either drifts the
-- build still warns via the schema validators in Contexts.hs.
isProvedConfidenceM :: Maybe String -> Bool
isProvedConfidenceM (Just s) = map toLower (trim' s) `elem` ["proved", "proven"]
isProvedConfidenceM _ = False
trim' :: String -> String
trim' = f . f
where f = reverse . dropWhile (`elem` (" \t\n\r" :: String))
-- | Validate a value against an enum list. Returns the lowercase form
-- on hit, 'Nothing' otherwise (no warning here — Contexts.hs's parsers
-- already warn on invalid frontmatter; the figure simply degrades).
validate :: [String] -> String -> Maybe String
validate vs raw =
let s = map toLower (trim' raw)
in if s `elem` vs then Just s else Nothing
-- | Peer-status validator: matches @peerStatusField@ in Contexts.hs but
-- maps @unreviewed@ to 'Nothing' so the figure's outer ring stays
-- neutral by default.
validatePeerStatus :: String -> Maybe String
validatePeerStatus raw =
let s = map toLower (trim' raw)
in if s `elem` ["under-review", "peer-reviewed", "published", "retracted"]
then Just s
else Nothing -- includes "unreviewed" and any unknown value
scopeValues, noveltyValues, practicalityValues, resultShapeValues :: [String]
scopeValues = ["personal", "local", "average", "broad", "civilizational"]
noveltyValues = ["conventional", "moderate", "idiosyncratic", "innovative"]
practicalityValues = ["abstract", "low", "moderate", "high", "exceptional"]
resultShapeValues = ["positive", "negative", "mixed", "comparative", "descriptive"]
-- | Map an ordinal value to its numeric rank (1-based).
ordinalRank :: [String] -> String -> Maybe Int
ordinalRank vs s = lookup s (zip vs [1..])
-- ---------------------------------------------------------------------------
-- Epistemic figure: geometry
-- ---------------------------------------------------------------------------
-- | Centre of the figure (viewBox coordinates).
fxCenter, fyCenter :: Double
fxCenter = 100
fyCenter = 100
-- | Inner / outer radii of the roundel and the polygon's full extent.
fxOuter, fxOuterPlus, fxAxisFull :: Double
fxOuter = 88 -- inner roundel circle
fxOuterPlus = 90 -- outer roundel circle
fxAxisFull = 80 -- full axis length (polygon vertex when value = 1.0)
-- | Six axis angles, clockwise from 12 o'clock, in degrees.
-- Index → field: 0 confidence, 1 novelty, 2 practicality,
-- 3 scope, 4 evidence, 5 importance.
axisAngles :: [Double]
axisAngles = [0, 60, 120, 180, 240, 300]
-- | Convert a (clockwise-angle-from-12-o'clock, distance-from-centre) pair
-- to absolute viewBox coordinates.
polar :: Double -> Double -> (Double, Double)
polar angleDeg dist =
let theta = (angleDeg - 90) * pi / 180
in (fxCenter + dist * cos theta, fyCenter + dist * sin theta)
-- | Axis index → normalized [0,1] value, or 'Nothing' when the
-- underlying frontmatter field is absent / unparseable.
axisValue :: EpistemicData -> Int -> Maybe Double
axisValue d i = case i of
0 -> if epConfidenceProved d
then Just 1.0
else fmap (\c -> fromIntegral c / 100.0) (epConfidence d)
1 -> normalizeOrdinal noveltyValues 4 (epNovelty d)
2 -> normalizeOrdinal practicalityValues 5 (epPracticality d)
3 -> normalizeOrdinal scopeValues 5 (epScope d)
4 -> normalizeIntScale 5 (epEvidence d)
5 -> normalizeIntScale 5 (epImportance d)
_ -> Nothing
-- | Map a 1..n ordinal-name value to a [0,1] value via @(rank-1)/(n-1)@.
normalizeOrdinal :: [String] -> Int -> Maybe String -> Maybe Double
normalizeOrdinal vs n (Just s) = do
r <- ordinalRank vs s
return $ fromIntegral (r - 1) / fromIntegral (n - 1)
normalizeOrdinal _ _ Nothing = Nothing
-- | Map a 1..n integer to [0,1] via @(v-1)/(n-1)@.
normalizeIntScale :: Int -> Maybe Int -> Maybe Double
normalizeIntScale n (Just v) = Just $ fromIntegral (v - 1) / fromIntegral (n - 1)
normalizeIntScale _ Nothing = Nothing
-- ---------------------------------------------------------------------------
-- Epistemic figure: SVG rendering
-- ---------------------------------------------------------------------------
-- | Format a Double with two decimal places. Determinism (§8.1) requires
-- no platform-dependent floating-point formatting.
ff :: Double -> T.Text
ff x = T.pack (showFFloat (Just 2) x "")
-- | Format a "x,y" coordinate pair.
xy :: Double -> Double -> T.Text
xy x y = ff x <> T.singleton ',' <> ff y
-- | Render the full epistemic figure SVG.
renderEpistemicFigure :: EpistemicData -> T.Text
renderEpistemicFigure d = T.concat
[ "<svg xmlns=\"http://www.w3.org/2000/svg\""
, " viewBox=\"0 0 200 200\""
, " role=\"img\""
, " aria-label=\"Epistemic figure: trust ", T.pack (show (epTrust d))
, ", stability ", T.pack (epStability d), "\">"
, renderRoundel
, renderGuides
, renderAxes
, renderPolygon d
, renderVertexMarks d
, renderTicks (epStability d) (epPeerStatus d)
, renderTrustLabel (epTrust d)
, renderResultShape (epResultShape d) (epTrust d)
, "</svg>"
]
-- | Two thin concentric circles forming the outer roundel.
renderRoundel :: T.Text
renderRoundel = T.concat
[ "<circle cx=\"", ff fxCenter, "\" cy=\"", ff fyCenter
, "\" r=\"", ff fxOuter
, "\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"0.5\" opacity=\"0.7\"/>"
, "<circle cx=\"", ff fxCenter, "\" cy=\"", ff fyCenter
, "\" r=\"", ff fxOuterPlus
, "\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"0.5\" opacity=\"0.7\"/>"
]
-- | Four concentric guide circles at 0.2 R, 0.4 R, 0.6 R, 0.8 R.
renderGuides :: T.Text
renderGuides = T.concat $ map oneGuide [0.2, 0.4, 0.6, 0.8 :: Double]
where
oneGuide t = T.concat
[ "<circle cx=\"", ff fxCenter, "\" cy=\"", ff fyCenter
, "\" r=\"", ff (fxAxisFull * t)
, "\" fill=\"none\" stroke=\"currentColor\""
, " stroke-width=\"0.25\" opacity=\"0.4\"/>"
]
-- | Six radial axes from centre to the inner roundel.
renderAxes :: T.Text
renderAxes = T.concat $ map oneAxis axisAngles
where
oneAxis a =
let (x, y) = polar a fxAxisFull
in T.concat
[ "<line x1=\"", ff fxCenter, "\" y1=\"", ff fyCenter
, "\" x2=\"", ff x, "\" y2=\"", ff y
, "\" stroke=\"currentColor\" stroke-width=\"0.3\" opacity=\"0.55\"/>"
]
-- | Polygon connecting the present field values along their axes.
-- When all six axes have a value the polygon is closed; otherwise
-- it's an open polyline through the present vertices in axis order.
renderPolygon :: EpistemicData -> T.Text
renderPolygon d =
let pairs = [ (i, axisValue d i) | i <- [0..5] ]
verts = [ polar a (fxAxisFull * v)
| (i, Just v) <- pairs
, let a = axisAngles !! i ]
in case verts of
[] -> ""
_ ->
let pointsTxt = T.intercalate " " [ xy x y | (x, y) <- verts ]
allPresent = all (isJust . snd) pairs
tag = if allPresent then "polygon" else "polyline"
fillAttr = if allPresent
then " fill=\"currentColor\" fill-opacity=\"0.08\""
else " fill=\"none\""
in T.concat
[ "<", tag
, " points=\"", pointsTxt
, "\" stroke=\"currentColor\" stroke-width=\"1.1\""
, fillAttr
, " stroke-linejoin=\"round\" stroke-linecap=\"round\"/>"
]
-- | One vertex point per present axis. Confidence axis gets a 3×3 square
-- instead of a 2-px circle when @confidence: proved@ is in effect — the
-- "proof cap" marker (MARKS.md §4.3).
renderVertexMarks :: EpistemicData -> T.Text
renderVertexMarks d = T.concat $ catMaybes
[ vertexMark d i | i <- [0..5] ]
vertexMark :: EpistemicData -> Int -> Maybe T.Text
vertexMark d i = do
v <- axisValue d i
let (x, y) = polar (axisAngles !! i) (fxAxisFull * v)
squareCap = i == 0 && epConfidenceProved d
return $ if squareCap
then T.concat
[ "<rect x=\"", ff (x - 1.5), "\" y=\"", ff (y - 1.5)
, "\" width=\"3\" height=\"3\""
, " fill=\"currentColor\" stroke=\"none\"/>"
]
else T.concat
[ "<circle cx=\"", ff x, "\" cy=\"", ff y
, "\" r=\"2\" fill=\"currentColor\" stroke=\"none\"/>"
]
-- | Outer-ring stability ticks at the top of the figure. Always five
-- positions; inactive ticks render at opacity 0.4 so the full scale
-- stays visible. Peer-status modulates tick *style*; see
-- 'renderPeerStatusOverlay'.
renderTicks :: String -> Maybe String -> T.Text
renderTicks stability peerStatus =
let activeCount = case stability of
"volatile" -> 1
"revising" -> 2
"fairly stable" -> 3
"stable" -> 4
"established" -> 5
_ -> 1
tickAngles :: [Double]
tickAngles = [0, -15, 15, -30, 30]
tickOne :: Int -> Double -> T.Text
tickOne idx a =
let (x1, y1) = polar a fxOuterPlus
(x2, y2) = polar a (fxOuterPlus + 1.5)
op = if idx < activeCount then "1.0" else "0.4"
in T.concat
[ "<line x1=\"", ff x1, "\" y1=\"", ff y1
, "\" x2=\"", ff x2, "\" y2=\"", ff y2
, "\" stroke=\"currentColor\" stroke-width=\"1\""
, " stroke-linecap=\"round\" opacity=\"", op, "\"/>"
]
in T.concat (zipWith tickOne [0..] tickAngles)
<> renderPeerStatusOverlay peerStatus
-- | Per-peer-status decorations layered on top of the tick group.
-- Geometry per MARKS.md §4.1.
renderPeerStatusOverlay :: Maybe String -> T.Text
renderPeerStatusOverlay Nothing = ""
renderPeerStatusOverlay (Just "under-review") =
-- Small unfilled circle just outside the outermost tick, at the top.
let (x, y) = polar 0 (fxOuterPlus + 3.5)
in T.concat
[ "<circle cx=\"", ff x, "\" cy=\"", ff y
, "\" r=\"1\" fill=\"none\" stroke=\"currentColor\""
, " stroke-width=\"0.6\"/>"
]
renderPeerStatusOverlay (Just "peer-reviewed") =
-- Single horizontal bar above the outer roundel arc, centred on top.
T.concat
[ "<line x1=\"", ff (fxCenter - 6), "\" y1=\"", ff (fyCenter - fxOuterPlus - 3)
, "\" x2=\"", ff (fxCenter + 6), "\" y2=\"", ff (fyCenter - fxOuterPlus - 3)
, "\" stroke=\"currentColor\" stroke-width=\"0.7\""
, " stroke-linecap=\"round\"/>"
]
renderPeerStatusOverlay (Just "published") =
-- Printer's-bracket: short vertical marks at ±15° on the outer roundel.
let (lx1, ly1) = polar (-15) (fxOuterPlus + 1)
(lx2, ly2) = polar (-15) (fxOuterPlus + 4)
(rx1, ry1) = polar 15 (fxOuterPlus + 1)
(rx2, ry2) = polar 15 (fxOuterPlus + 4)
in T.concat
[ "<line x1=\"", ff lx1, "\" y1=\"", ff ly1
, "\" x2=\"", ff lx2, "\" y2=\"", ff ly2
, "\" stroke=\"currentColor\" stroke-width=\"0.8\""
, " stroke-linecap=\"round\"/>"
, "<line x1=\"", ff rx1, "\" y1=\"", ff ry1
, "\" x2=\"", ff rx2, "\" y2=\"", ff ry2
, "\" stroke=\"currentColor\" stroke-width=\"0.8\""
, " stroke-linecap=\"round\"/>"
]
renderPeerStatusOverlay (Just "retracted") =
-- Horizontal strikethrough across the tick group.
T.concat
[ "<line x1=\"", ff (fxCenter - 9), "\" y1=\"", ff (fyCenter - fxOuterPlus - 1)
, "\" x2=\"", ff (fxCenter + 9), "\" y2=\"", ff (fyCenter - fxOuterPlus - 1)
, "\" stroke=\"currentColor\" stroke-width=\"1.5\""
, " stroke-linecap=\"round\"/>"
]
renderPeerStatusOverlay (Just _) = ""
-- | Trust score (Spectral, 16 px) and the small "TRUST" label below it.
renderTrustLabel :: Int -> T.Text
renderTrustLabel score = T.concat
[ "<text x=\"", ff fxCenter, "\" y=\"", ff (fyCenter + 4)
, "\" text-anchor=\"middle\""
, " fill=\"currentColor\" stroke=\"none\""
, " font-family=\"Spectral, serif\" font-weight=\"500\" font-size=\"16\">"
, T.pack (show score)
, "</text>"
, "<text x=\"", ff fxCenter, "\" y=\"", ff (fyCenter + 14)
, "\" text-anchor=\"middle\""
, " fill=\"currentColor\" stroke=\"none\""
, " font-family=\"&quot;Fira Sans&quot;, sans-serif\""
, " font-size=\"5\" letter-spacing=\"0.18em\""
, " opacity=\"0.7\">TRUST</text>"
]
-- | Result-shape glyph immediately to the right of the trust score.
renderResultShape :: Maybe String -> Int -> T.Text
renderResultShape Nothing _ = ""
renderResultShape (Just shape) score =
let glyph = case shape of
"positive" -> "+"
"negative" -> "\x2212" -- minus sign (not hyphen-minus)
"mixed" -> "\x00B1" -- ±
"comparative" -> "\x223C" --
"descriptive" -> "\x25A1" -- □
_ -> ""
-- Offset proportional to the trust number's width (digits ≈ 8 px each).
digitCount = length (show score)
offset = fromIntegral digitCount * 4.5 + 3 :: Double
in if T.null (T.pack glyph)
then ""
else T.concat
[ "<text x=\"", ff (fxCenter + offset)
, "\" y=\"", ff (fyCenter + 4)
, "\" text-anchor=\"start\""
, " fill=\"currentColor\" stroke=\"none\""
, " font-family=\"Spectral, serif\" font-size=\"16\">"
, T.pack glyph
, "</text>"
]
-- ---------------------------------------------------------------------------
-- Field exports
-- ---------------------------------------------------------------------------
-- | @$epistemicSvg$@. Returns 'noResult' when @status:@ is absent
-- (matches the existing visibility rule for the epistemic block —
-- MARKS.md §3.1). Otherwise returns the inline SVG string ready for
-- template interpolation.
epistemicSvgField :: Context String
epistemicSvgField = field "epistemicSvg" $ \item -> do
meta <- getMetadata (itemIdentifier item)
case lookupString "status" meta of
Nothing -> noResult "no status; epistemic figure suppressed"
Just _ -> do
d <- readEpistemicData item
return $ T.unpack (wrapEpistemic (renderEpistemicFigure d))
wrapEpistemic :: T.Text -> T.Text
wrapEpistemic svg = T.concat
[ "<figure class=\"frontmatter-mark frontmatter-mark--epistemic\">"
, svg
, "</figure>"
]

View File

@ -17,6 +17,7 @@
-- every successful build, so pins are one-shot.
module Stability
( stabilityField
, resolveStability
, lastReviewedField
, lastReviewedIsoField
, versionHistoryField
@ -133,10 +134,15 @@ fmtIso s = case parseIso s of
-- Stability and last-reviewed context fields
-- ---------------------------------------------------------------------------
-- | Context field @$stability$@.
-- Always resolves to a label; prefers frontmatter when the file is pinned.
stabilityField :: Context String
stabilityField = field "stability" $ \item -> do
-- | Resolve the stability label for an item — frontmatter override
-- when the source path is pinned via @IGNORE.txt@, else the heuristic
-- run over @git log --follow@ on the source path.
--
-- Used by 'stabilityField' (which exposes the label as a context field)
-- and by Marks.hs (which feeds the label into the epistemic figure's
-- outer-ring tick count).
resolveStability :: Item a -> Compiler String
resolveStability item = do
let srcPath = toFilePath (itemIdentifier item)
meta <- getMetadata (itemIdentifier item)
unsafeCompiler $ do
@ -145,6 +151,11 @@ stabilityField = field "stability" $ \item -> do
then return $ fromMaybe "volatile" (lookupString "stability" meta)
else stabilityFromDates <$> gitDates srcPath
-- | Context field @$stability$@.
-- Always resolves to a label; prefers frontmatter when the file is pinned.
stabilityField :: Context String
stabilityField = field "stability" resolveStability
-- | Context field @$last-reviewed$@.
-- Returns the formatted date of the most-recent commit, or @noResult@ when
-- unavailable (making @$if(last-reviewed)$@ false in templates).

View File

@ -22,6 +22,7 @@ executable site
SimilarLinks
Compilers
Contexts
Marks
Patterns
Photography
Stats

197
static/css/marks.css Normal file
View File

@ -0,0 +1,197 @@
/* ============================================================
FRONTMATTER MARKS monogram + epistemic figure
See MARKS.md for the full specification.
The header is a three-column grid sitting above the
cursive-L `content-divider`. Either or both mark columns
may be absent; the grid collapses cleanly because empty
columns size to zero (the conditional template guards
suppress the slot div entirely when its SVG is empty).
============================================================ */
/* Three-column grid:
[ 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. */
.essay-frontmatter {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
column-gap: clamp(0.75rem, 2vw, 1.75rem);
row-gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
}
/* 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;
}
/* Title block stays in the centre; never shrinks below 0. */
.frontmatter-title {
min-width: 0;
}
/* 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;
}
/* Subtitle: a short secondary line, lighter than the H1, never
competing with it. Kept restrained so existing essays without
a subtitle render unchanged. */
.essay-subtitle {
font-family: var(--font-serif);
font-size: 1.05rem;
font-style: italic;
color: var(--text-muted);
margin: 0 0 0.75rem 0;
line-height: 1.35;
}
/* Mark slot: a column wrapper that hosts the monogram or the
epistemic figure. The slot itself is a plain block; the SVG
inside controls its dimensions. line-height: 0 suppresses the
baseline descender gap that would otherwise add a few pixels
below the figure. */
.frontmatter-mark-slot {
line-height: 0;
}
/* The mark itself: a <figure> wrapping the inlined SVG. Two
inherited styles need to be overridden:
`figure { margin: 2rem 0; max-width: 100% }` in images.css
(otherwise vertical margin pushes the mark off-grid).
`#markdownBody figure { background, padding, border,
border-radius, box-shadow }` in typography.css (the
card-style chrome that wraps inline body figures with
a subtle paper-edge effect not what we want for a
frontispiece glyph that should sit seamlessly on the
page background).
The `#markdownBody` prefix on the second rule has higher
specificity than a plain `.frontmatter-mark`, so we match
that prefix here too. */
.frontmatter-mark,
#markdownBody .frontmatter-mark {
margin: 0;
padding: 0;
width: 170px;
height: 170px;
max-width: none;
background: none;
border: none;
border-radius: 0;
box-shadow: none;
color: var(--text);
}
/* 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
figure as a tall, narrow strip when the column is grid-auto'd). */
.frontmatter-mark svg {
display: block;
width: 100%;
height: 100%;
color: inherit;
}
/* Epistemic figure inside an anchor: the anchor inherits color
from the slot (so currentColor in the SVG resolves to --text)
and stays inline-block so it doesn't pick up underline / link
chrome from the surrounding prose styles. */
.frontmatter-mark-slot--right > a {
display: inline-block;
color: inherit;
text-decoration: none;
line-height: 0;
}
/* ============================================================
COMPACT-STRIP CHIP ADDITIONS
============================================================ */
/* Inline trend arrow ( ) appended to the confidence percentage.
No leading separator sits flush against the value it modifies. */
.ep-trend {
display: inline-block;
margin-left: 0.15em;
font-family: var(--font-sans);
font-size: 0.7rem;
color: var(--text-muted);
vertical-align: 0.05em;
}
.ep-row .ep-trend::before { content: none; }
/* Peer-status chip same typography as .ep-status, distinct
data-term so future revisions can style it differently. */
.ep-peer-status {
font-family: var(--font-sans);
font-size: 0.72rem;
font-variant-caps: all-small-caps;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.ep-peer-status::before {
content: "·\00a0";
color: var(--border);
}
/* Retracted: strikethrough across the chip text the spec's
§4.1 visual marker for a retracted piece. */
.ep-peer-status--retracted {
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* Proved-confidence variant: same row typography, italic to
differentiate from a numeric XX% confidence chip. */
.ep-row--proved {
font-style: italic;
}
/* ============================================================
MOBILE / NARROW SCREENS
Stack the marks above and below the title at the same
breakpoint where the TOC sidebar collapses (900px), and
shrink the SVGs at the mobile breakpoint (680px) per
MARKS.md §3.3 (170px desktop 130px mobile).
============================================================ */
@media (max-width: 900px) {
.essay-frontmatter {
grid-template-columns: minmax(0, 1fr);
justify-items: center;
}
.frontmatter-mark-slot--left { order: 0; }
.frontmatter-title { order: 1; }
.frontmatter-mark-slot--right { order: 2; }
.frontmatter-title { text-align: center; }
}
@media (max-width: 680px) {
.frontmatter-mark {
width: 130px;
height: 130px;
}
}
/* ============================================================
FOCUS MODE
The Display panel's focus mode (toggled via the
data-focus-mode attribute on <html>, see settings.js)
suppresses marks alongside the TOC and other chrome.
============================================================ */
[data-focus-mode] .frontmatter-mark,
[data-focus-mode] .frontmatter-mark-slot {
display: none;
}
[data-focus-mode] .essay-frontmatter {
grid-template-columns: minmax(0, 1fr);
}

View File

@ -0,0 +1,42 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Epistemic figure reference: skeleton (no field data)">
<!-- Reference skeleton for the epistemic figure generator
(build/Marks.hs). Renders the parts that are present whether
or not frontmatter fields are set: the outer roundel, the
four guide circles, the six radial axes, and the stability-
tick scale at 12 o'clock (all five ticks at "inactive"
opacity 0.4). The polygon, vertex marks, trust label, and
result-shape glyph are absent from this skeleton; they
appear only when frontmatter supplies values.
Used as a visual-regression baseline: compare against
generator output stripped of data-bearing elements. The
coordinate values mirror MARKS.md §3.3 exactly. -->
<!-- Outer roundel: r=88 inner, r=90 outer, stroke 0.5 -->
<circle cx="100.00" cy="100.00" r="88.00" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.7"/>
<circle cx="100.00" cy="100.00" r="90.00" fill="none" stroke="currentColor" stroke-width="0.5" opacity="0.7"/>
<!-- Inner guide circles at 0.2, 0.4, 0.6, 0.8 of axis radius (R=80) -->
<circle cx="100.00" cy="100.00" r="16.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/>
<circle cx="100.00" cy="100.00" r="32.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/>
<circle cx="100.00" cy="100.00" r="48.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/>
<circle cx="100.00" cy="100.00" r="64.00" fill="none" stroke="currentColor" stroke-width="0.25" opacity="0.4"/>
<!-- Six radial axes: 0° confidence, 60° novelty, 120° practicality,
180° scope, 240° evidence, 300° importance -->
<line x1="100.00" y1="100.00" x2="100.00" y2="20.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/>
<line x1="100.00" y1="100.00" x2="169.28" y2="60.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/>
<line x1="100.00" y1="100.00" x2="169.28" y2="140.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/>
<line x1="100.00" y1="100.00" x2="100.00" y2="180.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/>
<line x1="100.00" y1="100.00" x2="30.72" y2="140.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/>
<line x1="100.00" y1="100.00" x2="30.72" y2="60.00" stroke="currentColor" stroke-width="0.3" opacity="0.55"/>
<!-- Stability tick scale at 12 o'clock; all five at inactive
opacity 0.4 in this skeleton (no frontmatter → no live
stability label). -->
<line x1="100.00" y1="10.00" x2="100.00" y2="8.50" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
<line x1="76.71" y1="11.55" x2="75.32" y2="10.13" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
<line x1="123.29" y1="11.55" x2="124.68" y2="10.13" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
<line x1="55.00" y1="22.06" x2="53.70" y2="19.81" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
<line x1="145.00" y1="22.06" x2="146.30" y2="19.81" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,26 @@
<svg viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="mark-template-title">
<title id="mark-template-title">Frontmatter monogram template</title>
<desc>A reference monogram demonstrating the §2.2 visual contract: outer roundel, currentColor strokes, no fills except small filled point-marks, round line caps. Authors copy this file to content/{section}/{slug}/mark.svg (or content/{section}/{slug}.mark.svg for flat-form pieces) and replace the inner geometry with a glyph abstracted from their work's central concept.</desc>
<!-- Outer roundel — required (§2.2 M3). Do not remove. -->
<circle cx="140" cy="140" r="128" fill="none" stroke="currentColor" stroke-width="0.6"/>
<!-- Inner geometry: replace this group with your monogram.
Constraints (§2.2):
stroke-width drawn from {0.3, 0.5, 0.6, 0.8, 1.0, 1.2, 1.4}
fill="none" everywhere except small filled point-marks
stroke-linecap="round", stroke-linejoin="round" everywhere
no <text>, <image>, gradients, filters, embedded fonts, rasters
file size ≤ 8 KiB
well-formed XML (round-trips through xmllint --noout) -->
<g stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- Sample horizon line. -->
<line x1="60" y1="180" x2="220" y2="180" stroke-width="0.8"/>
<!-- Sample plinth — a low rectangular form on the horizon. -->
<rect x="120" y="160" width="40" height="20" stroke-width="0.6"/>
<!-- Sample column shaft. -->
<line x1="140" y1="160" x2="140" y2="100" stroke-width="1.0"/>
<!-- Sample capital. -->
<line x1="125" y1="100" x2="155" y2="100" stroke-width="0.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,8 +1,21 @@
<main id="markdownBody" data-pagefind-body>
<h1 class="page-title">$title$</h1>
$if(date)$
<p class="post-date"><time class="date-hover" datetime="$date-iso$" data-date-start="$date-iso$">$date$</time></p>
$endif$
<header class="essay-frontmatter essay-frontmatter--blog">
$if(monogramSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
$endif$
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$if(date)$
<p class="post-date"><time class="date-hover" datetime="$date-iso$" data-date-start="$date-iso$">$date$</time></p>
$endif$
</div>
$if(epistemicSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--right">
<a href="#epistemic" aria-label="Jump to epistemic profile">$epistemicSvg$</a>
</div>
$endif$
</header>
$body$
$if(backlinks)$
<footer class="page-meta-footer">

View File

@ -9,8 +9,22 @@
</nav>
</aside>
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$>
<h1 class="page-title">$title$</h1>
$partial("templates/partials/metadata.html")$
<header class="essay-frontmatter">
$if(monogramSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
$endif$
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$partial("templates/partials/metadata-header.html")$
</div>
$if(epistemicSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--right">
<a href="#epistemic" aria-label="Jump to epistemic profile">$epistemicSvg$</a>
</div>
$endif$
</header>
$partial("templates/partials/metadata-tail.html")$
$if(summary)$
<div class="essay-summary" data-pagefind-ignore="all">
<div class="essay-summary-label">Summary</div>

View File

@ -33,6 +33,7 @@ $if(description)$<meta name="twitter:description" content="$description$">$endif
<link rel="stylesheet" href="/css/selection-popup.css">
<link rel="stylesheet" href="/css/annotations.css">
<link rel="stylesheet" href="/css/images.css">
<link rel="stylesheet" href="/css/marks.css">
$if(home)$<link rel="stylesheet" href="/css/home.css">$endif$
$if(library)$<link rel="stylesheet" href="/css/library.css">$endif$
$if(library)$<link rel="stylesheet" href="/css/item-card.css">$endif$

View File

@ -0,0 +1,23 @@
<div class="metadata metadata-header">
<div class="meta-row meta-authors">
<span class="meta-label">by</span>$if(poet)$$poet$$else$$for(author-links)$<a href="$author-url$">$author-name$</a>$sep$, $endfor$$endif$
</div>
$if(abstract)$
<div class="meta-row meta-description">
$abstract$
</div>
$endif$
$if(status)$
<div class="meta-row meta-epistemic-strip" data-pagefind-ignore="all">
$if(overall-score)$<span class="ep-trust" data-ep-term="trust"><span class="ep-score">$overall-score$%</span> trust</span>$endif$
<span class="ep-status" data-ep-term="status">$status$</span>
$if(peer-status-display)$<span class="ep-peer-status ep-peer-status--$peer-status$" data-ep-term="peer-status">$peer-status-display$</span>$endif$
$if(confidence-proved)$<span class="ep-row ep-row--proved" data-ep-term="confidence">proved confidence</span>$else$$if(confidence)$<span class="ep-row" data-ep-term="confidence">$confidence$% confidence$if(confidence-trend)$<span class="ep-trend" aria-hidden="true">$confidence-trend$</span>$endif$</span>$endif$$endif$
$if(importance-dots)$<span class="ep-row" data-ep-term="importance"><span class="ep-dots">$importance-dots$</span> importance</span>$endif$
$if(evidence-dots)$<span class="ep-row" data-ep-term="evidence"><span class="ep-dots">$evidence-dots$</span> evidence quality</span>$endif$
$if(scope)$<span class="ep-row" data-ep-term="scope">$scope$ scope</span>$endif$
$if(novelty)$<span class="ep-row" data-ep-term="novelty">$novelty$ novelty</span>$endif$
$if(practicality)$<span class="ep-row" data-ep-term="practicality">$practicality$ practicality</span>$endif$
</div>
$endif$
</div>

View File

@ -1,4 +1,4 @@
<div class="metadata">
<div class="metadata metadata-tail">
$if(essay-tags)$
<div class="meta-row meta-tags">
$for(essay-tags)$<a class="meta-tag" href="$tag-url$">$tag-name$</a>$endfor$
@ -9,31 +9,11 @@
$for(essay-keywords)$<a class="meta-keyword" href="$kw-url$">$kw-name$</a>$endfor$
</div>
$endif$
$if(abstract)$
<div class="meta-row meta-description">
$abstract$
</div>
$endif$
<div class="meta-row meta-authors">
<span class="meta-label">by</span>$if(poet)$$poet$$else$$for(author-links)$<a href="$author-url$">$author-name$</a>$sep$, $endfor$$endif$
</div>
$if(affiliation-links)$
<div class="meta-row meta-affiliation">
$for(affiliation-links)$$if(affiliation-url)$<a href="$affiliation-url$">$affiliation-name$</a>$else$$affiliation-name$$endif$$sep$ · $endfor$
</div>
$endif$
$if(status)$
<div class="meta-row meta-epistemic-strip" data-pagefind-ignore="all">
$if(overall-score)$<span class="ep-trust" data-ep-term="trust"><span class="ep-score">$overall-score$%</span> trust</span>$endif$
<span class="ep-status" data-ep-term="status">$status$</span>
$if(confidence)$<span class="ep-row" data-ep-term="confidence">$confidence$% confidence</span>$endif$
$if(importance-dots)$<span class="ep-row" data-ep-term="importance"><span class="ep-dots">$importance-dots$</span> importance</span>$endif$
$if(evidence-dots)$<span class="ep-row" data-ep-term="evidence"><span class="ep-dots">$evidence-dots$</span> evidence quality</span>$endif$
$if(scope)$<span class="ep-row" data-ep-term="scope">$scope$ scope</span>$endif$
$if(novelty)$<span class="ep-row" data-ep-term="novelty">$novelty$ novelty</span>$endif$
$if(practicality)$<span class="ep-row" data-ep-term="practicality">$practicality$ practicality</span>$endif$
</div>
$endif$
<nav class="meta-row meta-pagelinks" aria-label="Page sections">
$if(version-history-range)$
<a href="#version-history" class="date-hover"

View File

@ -1,7 +1,16 @@
<div id="reading-progress" aria-hidden="true"></div>
<main id="markdownBody" data-pagefind-body$if(no-collapse)$ data-no-collapse$endif$>
<h1 class="page-title">$title$</h1>
$partial("templates/partials/metadata.html")$
<header class="essay-frontmatter essay-frontmatter--reading">
$if(monogramSvg)$
<div class="frontmatter-mark-slot frontmatter-mark-slot--left">$monogramSvg$</div>
$endif$
<div class="frontmatter-title">
<h1 class="page-title">$title$</h1>
$if(subtitle)$<p class="essay-subtitle">$subtitle$</p>$endif$
$partial("templates/partials/metadata-header.html")$
</div>
</header>
$partial("templates/partials/metadata-tail.html")$
$if(summary)$
<div class="essay-summary" data-pagefind-ignore="all">
<div class="essay-summary-label">Summary</div>