diff --git a/MARKS.md b/MARKS.md new file mode 100644 index 0000000..f8de1a4 --- /dev/null +++ b/MARKS.md @@ -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 130–280 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: `` | 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 ``, no ``, no gradients, no filters, no embedded fonts, no rasters | Letterforms and color belong to the page, not the mark. Note: `` 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 + A frontispiece mark for the essay "Ozymandias: A Static Site Framework". + ... + + +The author writes the visible-content description in ``. 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 (0–100) | confidence axis length | +| `importance` | yes (1–5) | importance axis length | +| `evidence` | yes (1–5) | 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 0–1 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 0–100; 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, +1–1.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 8–10 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 §§2–9 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. diff --git a/WRITING.md b/WRITING.md index 441e718..0d6fcbb 100644 --- a/WRITING.md +++ b/WRITING.md @@ -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 # 0–100 integer (%) +confidence: 72 # 0–100 integer (%); also accepts the sentinel `proved` / `proven` for formal mathematical results importance: 3 # 1–5 integer (rendered as filled/empty dots ●●●○○) evidence: 2 # 1–5 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 diff --git a/build/Contexts.hs b/build/Contexts.hs index 0203b41..b7197f7 100644 --- a/build/Contexts.hs +++ b/build/Contexts.hs @@ -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 (0–1) -- 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 -- --------------------------------------------------------------------------- diff --git a/build/Marks.hs b/build/Marks.hs new file mode 100644 index 0000000..09a7bcb --- /dev/null +++ b/build/Marks.hs @@ -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 -- ^ 1–5 ordinal. + , epEvidence :: Maybe Int -- ^ 1–5 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 0–100 (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=\""Fira Sans", 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>" + ] + diff --git a/build/Stability.hs b/build/Stability.hs index 66ce58b..3b66b4d 100644 --- a/build/Stability.hs +++ b/build/Stability.hs @@ -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). diff --git a/levineuwirth.cabal b/levineuwirth.cabal index e2991c2..cbba1f4 100644 --- a/levineuwirth.cabal +++ b/levineuwirth.cabal @@ -22,6 +22,7 @@ executable site SimilarLinks Compilers Contexts + Marks Patterns Photography Stats diff --git a/static/css/marks.css b/static/css/marks.css new file mode 100644 index 0000000..c42edb5 --- /dev/null +++ b/static/css/marks.css @@ -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); +} diff --git a/static/templates/epistemic-figure-reference.svg b/static/templates/epistemic-figure-reference.svg new file mode 100644 index 0000000..dbdadc9 --- /dev/null +++ b/static/templates/epistemic-figure-reference.svg @@ -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> diff --git a/static/templates/mark-template.svg b/static/templates/mark-template.svg new file mode 100644 index 0000000..c9cc042 --- /dev/null +++ b/static/templates/mark-template.svg @@ -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 + 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. + + + + + + + + + + + + + + + + diff --git a/templates/blog-post.html b/templates/blog-post.html index 9b199e4..4edc53f 100644 --- a/templates/blog-post.html +++ b/templates/blog-post.html @@ -1,8 +1,21 @@
-

$title$

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

$title$

+ $if(subtitle)$

$subtitle$

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